# Copyright (C) 2011 Canonical Ltd.
# Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
# Copyright (C) 2012 Yahoo! Inc.
#
# Author: Scott Moser <scott.moser@canonical.com>
# Author: Juerg Hafliger <juerg.haefliger@hp.com>
# Author: Joshua Harlow <harlowja@yahoo-inc.com>
#
# This file is part of cloud-init. See LICENSE file for license information.

"""Cloud-Init DataSource for OVF

This module provides a cloud-init datasource for OVF data.
"""

import base64
import logging
import os
import re
from xml.dom import minidom  # nosec B408

import yaml

from cloudinit import sources, subp, util

LOG = logging.getLogger(__name__)


class DataSourceOVF(sources.DataSource):
    dsname = "OVF"

    def __init__(self, sys_cfg, distro, paths):
        sources.DataSource.__init__(self, sys_cfg, distro, paths)
        self.seed = None
        self.seed_dir = os.path.join(paths.seed_dir, "ovf")
        self.environment = None
        self.cfg = {}
        self.supported_seed_starts = ("/", "file://")
        self._network_config = None

    def __str__(self):
        root = sources.DataSource.__str__(self)
        return "%s [seed=%s]" % (root, self.seed)

    def _get_data(self):
        found = []
        md = {}
        ud = ""
        vd = ""

        defaults = {
            "instance-id": "iid-dsovf",
        }

        (seedfile, contents) = get_ovf_env(self.paths.seed_dir)

        if seedfile:
            # Found a seed dir
            seed = os.path.join(self.paths.seed_dir, seedfile)
            (md, ud, cfg) = read_ovf_environment(contents)
            self.environment = contents
            found.append(seed)
        else:
            np = [
                ("com.vmware.guestInfo", transport_vmware_guestinfo),
                ("iso", transport_iso9660),
            ]
            name = None
            for name, transfunc in np:
                contents = transfunc()
                if contents:
                    break
            if contents:
                (md, ud, cfg) = read_ovf_environment(contents, True)
                self.environment = contents
                if "network-config" in md and md["network-config"]:
                    self._network_config = md["network-config"]
                found.append(name)

        # There was no OVF transports found
        if not found:
            return False

        if "seedfrom" in md and md["seedfrom"]:
            seedfrom = md["seedfrom"]
            seedfound = False
            for proto in self.supported_seed_starts:
                if seedfrom.startswith(proto):
                    seedfound = proto
                    break
            if not seedfound:
                LOG.debug("Seed from %s not supported by %s", seedfrom, self)
                return False

            (md_seed, ud, vd, _) = util.read_seeded(seedfrom, timeout=None)
            LOG.debug("Using seeded cache data from %s", seedfrom)

            md = util.mergemanydict([md, md_seed])
            found.append(seedfrom)

        # Now that we have exhausted any other places merge in the defaults
        md = util.mergemanydict([md, defaults])

        self.seed = ",".join(found)
        self.metadata = md
        self.userdata_raw = ud
        self.vendordata_raw = vd
        self.cfg = cfg
        return True

    def _get_subplatform(self):
        return "ovf (%s)" % self.seed

    def get_public_ssh_keys(self):
        if "public-keys" not in self.metadata:
            return []
        pks = self.metadata["public-keys"]
        if isinstance(pks, (list)):
            return pks
        else:
            return [pks]

    # The data sources' config_obj is a cloud-config formatted
    # object that came to it from ways other than cloud-config
    # because cloud-config content would be handled elsewhere
    def get_config_obj(self):
        return self.cfg

    @property
    def network_config(self):
        return self._network_config


class DataSourceOVFNet(DataSourceOVF):
    def __init__(self, sys_cfg, distro, paths):
        DataSourceOVF.__init__(self, sys_cfg, distro, paths)
        self.seed_dir = os.path.join(paths.seed_dir, "ovf-net")
        self.supported_seed_starts = ("http://", "https://")


# This will return a dict with some content
#  meta-data, user-data, some config
def read_ovf_environment(contents, read_network=False):
    props = get_properties(contents)
    md = {}
    cfg = {}
    ud = None
    cfg_props = ["password"]
    md_props = ["seedfrom", "local-hostname", "public-keys", "instance-id"]
    network_props = ["network-config"]
    for prop, val in props.items():
        if prop == "hostname":
            prop = "local-hostname"
        if prop in md_props:
            md[prop] = val
        elif prop in cfg_props:
            cfg[prop] = val
        elif prop in network_props and read_network:
            try:
                network_config = base64.b64decode(val.encode())
                md[prop] = safeload_yaml_or_dict(network_config).get("network")
            except Exception:
                LOG.debug("Ignore network-config in wrong format")
        elif prop == "user-data":
            try:
                ud = base64.b64decode(val.encode())
            except Exception:
                ud = val.encode()
    return (md, ud, cfg)


# Returns tuple of filename (in 'dirname', and the contents of the file)
# on "not found", returns 'None' for filename and False for contents
def get_ovf_env(dirname):
    env_names = ("ovf-env.xml", "ovf_env.xml", "OVF_ENV.XML", "OVF-ENV.XML")
    for fname in env_names:
        full_fn = os.path.join(dirname, fname)
        if os.path.isfile(full_fn):
            try:
                contents = util.load_text_file(full_fn)
                return (fname, contents)
            except Exception:
                util.logexc(LOG, "Failed loading ovf file %s", full_fn)
    return (None, False)


def maybe_cdrom_device(devname):
    """Test if devname matches known list of devices which may contain iso9660
       filesystems.

    Be helpful in accepting either knames (with no leading /dev/) or full path
    names, but do not allow paths outside of /dev/, like /dev/foo/bar/xxx.
    """
    if not devname:
        return False
    elif not isinstance(devname, str):
        raise ValueError("Unexpected input for devname: %s" % devname)

    # resolve '..' and multi '/' elements
    devname = os.path.normpath(devname)

    # drop leading '/dev/'
    if devname.startswith("/dev/"):
        # partition returns tuple (before, partition, after)
        devname = devname.partition("/dev/")[-1]

    # ignore leading slash (/sr0), else fail on / in name (foo/bar/xvdc)
    if devname.startswith("/"):
        devname = devname.split("/")[-1]
    elif devname.count("/") > 0:
        return False

    # if empty string
    if not devname:
        return False

    # default_regex matches values in /lib/udev/rules.d/60-cdrom_id.rules
    # KERNEL!="sr[0-9]*|hd[a-z]|xvd*", GOTO="cdrom_end"
    default_regex = r"^(sr[0-9]+|hd[a-z]|xvd.*)"
    devname_regex = os.environ.get("CLOUD_INIT_CDROM_DEV_REGEX", default_regex)
    cdmatch = re.compile(devname_regex)

    return cdmatch.match(devname) is not None


# Transport functions are called with no arguments and return
# either None (indicating not present) or string content of an ovf-env.xml
def transport_iso9660(require_iso=True):
    # Go through mounts to see if it was already mounted
    mounts = util.mounts()
    for dev, info in mounts.items():
        fstype = info["fstype"]
        if fstype != "iso9660" and require_iso:
            continue
        if not maybe_cdrom_device(dev):
            continue
        mp = info["mountpoint"]
        (_fname, contents) = get_ovf_env(mp)
        if contents is not False:
            return contents

    if require_iso:
        mtype = "iso9660"
    else:
        mtype = None

    # generate a list of devices with mtype filesystem, filter by regex
    devs = [
        dev
        for dev in util.find_devs_with("TYPE=%s" % mtype if mtype else None)
        if maybe_cdrom_device(dev)
    ]
    for dev in devs:
        try:
            (_fname, contents) = util.mount_cb(dev, get_ovf_env, mtype=mtype)
        except util.MountFailedError:
            LOG.debug("%s not mountable as iso9660", dev)
            continue

        if contents is not False:
            return contents

    return None


def exec_vmware_rpctool(rpctool, arg):
    cmd = [rpctool, arg]
    (stdout, stderr) = subp.subp(cmd)
    return (cmd, stdout, stderr)


def exec_vmtoolsd(rpctool, arg):
    cmd = [rpctool, "--cmd", arg]
    (stdout, stderr) = subp.subp(cmd)
    return (cmd, stdout, stderr)


def transport_vmware_guestinfo():
    rpctool, rpctool_fn = None, None
    vmtoolsd = subp.which("vmtoolsd")
    vmware_rpctool = subp.which("vmware-rpctool")

    # Default to using vmware-rpctool if it is available.
    if vmware_rpctool:
        rpctool, rpctool_fn = vmware_rpctool, exec_vmware_rpctool
        LOG.debug("discovered vmware-rpctool: %s", vmware_rpctool)

    if vmtoolsd:
        # Default to using vmtoolsd if it is available and vmware-rpctool is
        # not.
        if not vmware_rpctool:
            rpctool, rpctool_fn = vmtoolsd, exec_vmtoolsd
        LOG.debug("discovered vmtoolsd: %s", vmtoolsd)

    # If neither vmware-rpctool nor vmtoolsd are available, then nothing can
    # be done.
    if not rpctool:
        LOG.debug("no rpctool discovered")
        return None

    def query_guestinfo(rpctool, rpctool_fn):
        LOG.info("query guestinfo.ovfEnv with %s", rpctool)
        try:
            cmd, stdout, _ = rpctool_fn(rpctool, "info-get guestinfo.ovfEnv")
            if stdout:
                return stdout
            LOG.debug("cmd %s exited 0 with empty stdout", cmd)
            return None
        except subp.ProcessExecutionError as error:
            if error.exit_code != 1:
                LOG.warning("%s exited with code %d", rpctool, error.exit_code)
            raise error

    try:
        # The first attempt to query guestinfo could occur via either
        # vmware-rpctool *or* vmtoolsd.
        return query_guestinfo(rpctool, rpctool_fn)
    except subp.ProcessExecutionError as error:
        # The second attempt to query guestinfo can only occur with
        # vmtoolsd.

        # If the first attempt at getting the data was with vmtoolsd, then
        # no second attempt is made.
        if vmtoolsd and rpctool == vmtoolsd:
            # The fallback failed and exit code is not 1, log the error.
            if error.exit_code != 1:
                util.logexc(
                    LOG, "vmtoolsd failed to get guestinfo.ovfEnv: %s", error
                )
            return None

        if not vmtoolsd:
            LOG.info("vmtoolsd fallback option not present")
            return None

        try:
            LOG.info("fallback to vmtoolsd")
            return query_guestinfo(vmtoolsd, exec_vmtoolsd)
        except subp.ProcessExecutionError as error:
            # The fallback failed and exit code is not 1, log the error.
            if error.exit_code != 1:
                util.logexc(
                    LOG, "vmtoolsd failed to get guestinfo.ovfEnv: %s", error
                )

    return None


def find_child(node, filter_func):
    ret = []
    if not node.hasChildNodes():
        return ret
    for child in node.childNodes:
        if filter_func(child):
            ret.append(child)
    return ret


def get_properties(contents):
    dom = minidom.parseString(contents)  # nosec B318
    if dom.documentElement.localName != "Environment":
        raise XmlError("No Environment Node")

    if not dom.documentElement.hasChildNodes():
        raise XmlError("No Child Nodes")

    envNsURI = "http://schemas.dmtf.org/ovf/environment/1"

    # could also check here that elem.namespaceURI ==
    #   "http://schemas.dmtf.org/ovf/environment/1"
    propSections = find_child(
        dom.documentElement, lambda n: n.localName == "PropertySection"
    )

    if not propSections:
        raise XmlError("No 'PropertySection's")

    props = {}
    propElems = find_child(
        propSections[0], (lambda n: n.localName == "Property")
    )

    for elem in propElems:
        key = elem.attributes.getNamedItemNS(envNsURI, "key").value
        val = elem.attributes.getNamedItemNS(envNsURI, "value").value
        props[key] = val

    return props


class XmlError(Exception):
    pass


# Used to match classes to dependencies
datasources = (
    (DataSourceOVF, (sources.DEP_FILESYSTEM,)),
    (DataSourceOVFNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
)


# Return a list of data sources that match this set of dependencies
def get_datasource_list(depends):
    return sources.list_from_depends(depends, datasources)


def safeload_yaml_or_dict(data):
    """
    The meta data could be JSON or YAML. Since YAML is a strict superset of
    JSON, we will unmarshal the data as YAML. If data is None then a new
    dictionary is returned.
    """
    if not data:
        return {}
    return yaml.safe_load(data)
