# Copyright (C) 2016 Canonical Ltd.
#
# Author: Ryan Harper <ryan.harper@canonical.com>
#
# This file is part of cloud-init. See LICENSE file for license information.

"""NTP: enable and configure ntp"""

import copy
import os
from textwrap import dedent

from cloudinit import log as logging
from cloudinit import subp, temp_utils, templater, type_utils, util
from cloudinit.config.schema import (
    MetaSchema,
    get_meta_doc,
    validate_cloudconfig_schema,
)
from cloudinit.settings import PER_INSTANCE

LOG = logging.getLogger(__name__)

frequency = PER_INSTANCE
NTP_CONF = "/etc/ntp.conf"
NR_POOL_SERVERS = 4
distros = [
    "almalinux",
    "alpine",
    "centos",
    "cloudlinux",
    "debian",
    "eurolinux",
    "fedora",
    "miraclelinux",
    "openEuler",
    "opensuse",
    "photon",
    "rhel",
    "rocky",
    "sles",
    "ubuntu",
    "virtuozzo",
]

NTP_CLIENT_CONFIG = {
    "chrony": {
        "check_exe": "chronyd",
        "confpath": "/etc/chrony.conf",
        "packages": ["chrony"],
        "service_name": "chrony",
        "template_name": "chrony.conf.{distro}",
        "template": None,
    },
    "ntp": {
        "check_exe": "ntpd",
        "confpath": NTP_CONF,
        "packages": ["ntp"],
        "service_name": "ntp",
        "template_name": "ntp.conf.{distro}",
        "template": None,
    },
    "ntpdate": {
        "check_exe": "ntpdate",
        "confpath": NTP_CONF,
        "packages": ["ntpdate"],
        "service_name": "ntpdate",
        "template_name": "ntp.conf.{distro}",
        "template": None,
    },
    "systemd-timesyncd": {
        "check_exe": "/lib/systemd/systemd-timesyncd",
        "confpath": "/etc/systemd/timesyncd.conf.d/cloud-init.conf",
        "packages": [],
        "service_name": "systemd-timesyncd",
        "template_name": "timesyncd.conf",
        "template": None,
    },
}

# This is Distro-specific configuration overrides of the base config
DISTRO_CLIENT_CONFIG = {
    "alpine": {
        "chrony": {
            "confpath": "/etc/chrony/chrony.conf",
            "service_name": "chronyd",
        },
        "ntp": {
            "confpath": "/etc/ntp.conf",
            "packages": [],
            "service_name": "ntpd",
        },
    },
    "debian": {
        "chrony": {
            "confpath": "/etc/chrony/chrony.conf",
        },
    },
    "opensuse": {
        "chrony": {
            "service_name": "chronyd",
        },
        "ntp": {
            "confpath": "/etc/ntp.conf",
            "service_name": "ntpd",
        },
        "systemd-timesyncd": {
            "check_exe": "/usr/lib/systemd/systemd-timesyncd",
        },
    },
    "photon": {
        "chrony": {
            "service_name": "chronyd",
        },
        "ntp": {"service_name": "ntpd", "confpath": "/etc/ntp.conf"},
        "systemd-timesyncd": {
            "check_exe": "/usr/lib/systemd/systemd-timesyncd",
            "confpath": "/etc/systemd/timesyncd.conf",
        },
    },
    "rhel": {
        "ntp": {
            "service_name": "ntpd",
        },
        "chrony": {
            "service_name": "chronyd",
        },
    },
    "sles": {
        "chrony": {
            "service_name": "chronyd",
        },
        "ntp": {
            "confpath": "/etc/ntp.conf",
            "service_name": "ntpd",
        },
        "systemd-timesyncd": {
            "check_exe": "/usr/lib/systemd/systemd-timesyncd",
        },
    },
    "ubuntu": {
        "chrony": {
            "confpath": "/etc/chrony/chrony.conf",
        },
    },
}


# The schema definition for each cloud-config module is a strict contract for
# describing supported configuration parameters for each cloud-config section.
# It allows cloud-config to validate and alert users to invalid or ignored
# configuration options before actually attempting to deploy with said
# configuration.

meta: MetaSchema = {
    "id": "cc_ntp",
    "name": "NTP",
    "title": "enable and configure ntp",
    "description": dedent(
        """\
        Handle ntp configuration. If ntp is not installed on the system and
        ntp configuration is specified, ntp will be installed. If there is a
        default ntp config file in the image or one is present in the
        distro's ntp package, it will be copied to a file with ``.dist``
        appended to the filename before any changes are made. A list of ntp
        pools and ntp servers can be provided under the ``ntp`` config key.
        If no ntp ``servers`` or ``pools`` are provided, 4 pools will be used
        in the format ``{0-3}.{distro}.pool.ntp.org``."""
    ),
    "distros": distros,
    "examples": [
        dedent(
            """\
        # Override ntp with chrony configuration on Ubuntu
        ntp:
          enabled: true
          ntp_client: chrony  # Uses cloud-init default chrony configuration
        """
        ),
        dedent(
            """\
        # Provide a custom ntp client configuration
        ntp:
          enabled: true
          ntp_client: myntpclient
          config:
             confpath: /etc/myntpclient/myntpclient.conf
             check_exe: myntpclientd
             packages:
               - myntpclient
             service_name: myntpclient
             template: |
                 ## template:jinja
                 # My NTP Client config
                 {% if pools -%}# pools{% endif %}
                 {% for pool in pools -%}
                 pool {{pool}} iburst
                 {% endfor %}
                 {%- if servers %}# servers
                 {% endif %}
                 {% for server in servers -%}
                 server {{server}} iburst
                 {% endfor %}
          pools: [0.int.pool.ntp.org, 1.int.pool.ntp.org, ntp.myorg.org]
          servers:
            - ntp.server.local
            - ntp.ubuntu.com
            - 192.168.23.2"""
        ),
    ],
    "frequency": PER_INSTANCE,
}

schema = {
    "type": "object",
    "properties": {
        "ntp": {
            "type": ["object", "null"],
            "properties": {
                "pools": {
                    "type": "array",
                    "items": {"type": "string", "format": "hostname"},
                    "uniqueItems": True,
                    "description": dedent(
                        """\
                        List of ntp pools. If both pools and servers are
                        empty, 4 default pool servers will be provided of
                        the format ``{0-3}.{distro}.pool.ntp.org``. NOTE:
                        for Alpine Linux when using the Busybox NTP client
                        this setting will be ignored due to the limited
                        functionality of Busybox's ntpd."""
                    ),
                },
                "servers": {
                    "type": "array",
                    "items": {"type": "string", "format": "hostname"},
                    "uniqueItems": True,
                    "description": dedent(
                        """\
                        List of ntp servers. If both pools and servers are
                        empty, 4 default pool servers will be provided with
                        the format ``{0-3}.{distro}.pool.ntp.org``."""
                    ),
                },
                "ntp_client": {
                    "type": "string",
                    "default": "auto",
                    "description": dedent(
                        """\
                        Name of an NTP client to use to configure system NTP.
                        When unprovided or 'auto' the default client preferred
                        by the distribution will be used. The following
                        built-in client names can be used to override existing
                        configuration defaults: chrony, ntp, ntpdate,
                        systemd-timesyncd."""
                    ),
                },
                "enabled": {
                    "type": "boolean",
                    "default": True,
                    "description": dedent(
                        """\
                        Attempt to enable ntp clients if set to True.  If set
                        to False, ntp client will not be configured or
                        installed"""
                    ),
                },
                "config": {
                    "description": dedent(
                        """\
                        Configuration settings or overrides for the
                        ``ntp_client`` specified."""
                    ),
                    "type": ["object"],
                    "properties": {
                        "confpath": {
                            "type": "string",
                            "description": dedent(
                                """\
                                The path to where the ``ntp_client``
                                configuration is written."""
                            ),
                        },
                        "check_exe": {
                            "type": "string",
                            "description": dedent(
                                """\
                                The executable name for the ``ntp_client``.
                                For example, ntp service ``check_exe`` is
                                'ntpd' because it runs the ntpd binary."""
                            ),
                        },
                        "packages": {
                            "type": "array",
                            "items": {
                                "type": "string",
                            },
                            "uniqueItems": True,
                            "description": dedent(
                                """\
                                List of packages needed to be installed for the
                                selected ``ntp_client``."""
                            ),
                        },
                        "service_name": {
                            "type": "string",
                            "description": dedent(
                                """\
                                The systemd or sysvinit service name used to
                                start and stop the ``ntp_client``
                                service."""
                            ),
                        },
                        "template": {
                            "type": "string",
                            "description": dedent(
                                """\
                                Inline template allowing users to define their
                                own ``ntp_client`` configuration template.
                                The value must start with '## template:jinja'
                                to enable use of templating support.
                                """
                            ),
                        },
                    },
                    # Don't use REQUIRED_NTP_CONFIG_KEYS to allow for override
                    # of builtin client values.
                    "minProperties": 1,  # If we have config, define something
                    "additionalProperties": False,
                },
            },
            "additionalProperties": False,
        }
    },
}
REQUIRED_NTP_CONFIG_KEYS = frozenset(
    ["check_exe", "confpath", "packages", "service_name"]
)


__doc__ = get_meta_doc(meta, schema)  # Supplement python help()


def distro_ntp_client_configs(distro):
    """Construct a distro-specific ntp client config dictionary by merging
       distro specific changes into base config.

    @param distro: String providing the distro class name.
    @returns: Dict of distro configurations for ntp clients.
    """
    dcfg = DISTRO_CLIENT_CONFIG
    cfg = copy.copy(NTP_CLIENT_CONFIG)
    if distro in dcfg:
        cfg = util.mergemanydict([cfg, dcfg[distro]], reverse=True)
    return cfg


def select_ntp_client(ntp_client, distro):
    """Determine which ntp client is to be used, consulting the distro
       for its preference.

    @param ntp_client: String name of the ntp client to use.
    @param distro: Distro class instance.
    @returns: Dict of the selected ntp client or {} if none selected.
    """

    # construct distro-specific ntp_client_config dict
    distro_cfg = distro_ntp_client_configs(distro.name)

    # user specified client, return its config
    if ntp_client and ntp_client != "auto":
        LOG.debug(
            'Selected NTP client "%s" via user-data configuration', ntp_client
        )
        return distro_cfg.get(ntp_client, {})

    # default to auto if unset in distro
    distro_ntp_client = distro.get_option("ntp_client", "auto")

    clientcfg = {}
    if distro_ntp_client == "auto":
        for client in distro.preferred_ntp_clients:
            cfg = distro_cfg.get(client)
            if subp.which(cfg.get("check_exe")):
                LOG.debug(
                    'Selected NTP client "%s", already installed', client
                )
                clientcfg = cfg
                break

        if not clientcfg:
            client = distro.preferred_ntp_clients[0]
            LOG.debug(
                'Selected distro preferred NTP client "%s", not yet installed',
                client,
            )
            clientcfg = distro_cfg.get(client)
    else:
        LOG.debug(
            'Selected NTP client "%s" via distro system config',
            distro_ntp_client,
        )
        clientcfg = distro_cfg.get(distro_ntp_client, {})

    return clientcfg


def install_ntp_client(install_func, packages=None, check_exe="ntpd"):
    """Install ntp client package if not already installed.

    @param install_func: function.  This parameter is invoked with the contents
    of the packages parameter.
    @param packages: list.  This parameter defaults to ['ntp'].
    @param check_exe: string.  The name of a binary that indicates the package
    the specified package is already installed.
    """
    if subp.which(check_exe):
        return
    if packages is None:
        packages = ["ntp"]

    install_func(packages)


def rename_ntp_conf(confpath=None):
    """Rename any existing ntp client config file

    @param confpath: string. Specify a path to an existing ntp client
    configuration file.
    """
    if os.path.exists(confpath):
        util.rename(confpath, confpath + ".dist")


def generate_server_names(distro):
    """Generate a list of server names to populate an ntp client configuration
    file.

    @param distro: string.  Specify the distro name
    @returns: list: A list of strings representing ntp servers for this distro.
    """
    names = []
    pool_distro = distro

    if distro == "sles":
        # For legal reasons x.pool.sles.ntp.org does not exist,
        # use the opensuse pool
        pool_distro = "opensuse"
    elif distro == "alpine" or distro == "eurolinux":
        # Alpine-specific pool (i.e. x.alpine.pool.ntp.org) does not exist
        # so use general x.pool.ntp.org instead. The same applies to EuroLinux
        pool_distro = ""

    for x in range(0, NR_POOL_SERVERS):
        names.append(
            ".".join(
                [n for n in [str(x)] + [pool_distro] + ["pool.ntp.org"] if n]
            )
        )

    return names


def write_ntp_config_template(
    distro_name,
    service_name=None,
    servers=None,
    pools=None,
    path=None,
    template_fn=None,
    template=None,
):
    """Render a ntp client configuration for the specified client.

    @param distro_name: string.  The distro class name.
    @param service_name: string. The name of the NTP client service.
    @param servers: A list of strings specifying ntp servers. Defaults to empty
    list.
    @param pools: A list of strings specifying ntp pools. Defaults to empty
    list.
    @param path: A string to specify where to write the rendered template.
    @param template_fn: A string to specify the template source file.
    @param template: A string specifying the contents of the template. This
    content will be written to a temporary file before being used to render
    the configuration file.

    @raises: ValueError when path is None.
    @raises: ValueError when template_fn is None and template is None.
    """
    if not servers:
        servers = []
    if not pools:
        pools = []

    if (
        len(servers) == 0
        and distro_name == "alpine"
        and service_name == "ntpd"
    ):
        # Alpine's Busybox ntpd only understands "servers" configuration
        # and not "pool" configuration.
        servers = generate_server_names(distro_name)
        LOG.debug("Adding distro default ntp servers: %s", ",".join(servers))
    elif len(servers) == 0 and len(pools) == 0:
        pools = generate_server_names(distro_name)
        LOG.debug(
            "Adding distro default ntp pool servers: %s", ",".join(pools)
        )

    if not path:
        raise ValueError("Invalid value for path parameter")

    if not template_fn and not template:
        raise ValueError("Not template_fn or template provided")

    params = {"servers": servers, "pools": pools}
    if template:
        tfile = temp_utils.mkstemp(prefix="template_name-", suffix=".tmpl")
        template_fn = tfile[1]  # filepath is second item in tuple
        util.write_file(template_fn, content=template)

    templater.render_to_file(template_fn, path, params)
    # clean up temporary template
    if template:
        util.del_file(template_fn)


def supplemental_schema_validation(ntp_config):
    """Validate user-provided ntp:config option values.

    This function supplements flexible jsonschema validation with specific
    value checks to aid in triage of invalid user-provided configuration.

    @param ntp_config: Dictionary of configuration value under 'ntp'.

    @raises: ValueError describing invalid values provided.
    """
    errors = []
    missing = REQUIRED_NTP_CONFIG_KEYS.difference(set(ntp_config.keys()))
    if missing:
        keys = ", ".join(sorted(missing))
        errors.append(
            "Missing required ntp:config keys: {keys}".format(keys=keys)
        )
    elif not any(
        [ntp_config.get("template"), ntp_config.get("template_name")]
    ):
        errors.append(
            "Either ntp:config:template or ntp:config:template_name values"
            " are required"
        )
    for key, value in sorted(ntp_config.items()):
        keypath = "ntp:config:" + key
        if key == "confpath":
            if not all([value, isinstance(value, str)]):
                errors.append(
                    "Expected a config file path {keypath}."
                    " Found ({value})".format(keypath=keypath, value=value)
                )
        elif key == "packages":
            if not isinstance(value, list):
                errors.append(
                    "Expected a list of required package names for {keypath}."
                    " Found ({value})".format(keypath=keypath, value=value)
                )
        elif key in ("template", "template_name"):
            if value is None:  # Either template or template_name can be none
                continue
            if not isinstance(value, str):
                errors.append(
                    "Expected a string type for {keypath}."
                    " Found ({value})".format(keypath=keypath, value=value)
                )
        elif not isinstance(value, str):
            errors.append(
                "Expected a string type for {keypath}. Found ({value})".format(
                    keypath=keypath, value=value
                )
            )

    if errors:
        raise ValueError(
            r"Invalid ntp configuration:\n{errors}".format(
                errors="\n".join(errors)
            )
        )


def handle(name, cfg, cloud, log, _args):
    """Enable and configure ntp."""
    if "ntp" not in cfg:
        LOG.debug(
            "Skipping module named %s, not present or disabled by cfg", name
        )
        return
    ntp_cfg = cfg["ntp"]
    if ntp_cfg is None:
        ntp_cfg = {}  # Allow empty config which will install the package

    # TODO drop this when validate_cloudconfig_schema is strict=True
    if not isinstance(ntp_cfg, (dict)):
        raise RuntimeError(
            "'ntp' key existed in config, but not a dictionary type,"
            " is a {_type} instead".format(_type=type_utils.obj_name(ntp_cfg))
        )

    validate_cloudconfig_schema(cfg, schema)

    # Allow users to explicitly enable/disable
    enabled = ntp_cfg.get("enabled", True)
    if util.is_false(enabled):
        LOG.debug("Skipping module named %s, disabled by cfg", name)
        return

    # Select which client is going to be used and get the configuration
    ntp_client_config = select_ntp_client(
        ntp_cfg.get("ntp_client"), cloud.distro
    )
    # Allow user ntp config to override distro configurations
    ntp_client_config = util.mergemanydict(
        [ntp_client_config, ntp_cfg.get("config", {})], reverse=True
    )

    supplemental_schema_validation(ntp_client_config)
    rename_ntp_conf(confpath=ntp_client_config.get("confpath"))

    template_fn = None
    if not ntp_client_config.get("template"):
        template_name = ntp_client_config.get("template_name").replace(
            "{distro}", cloud.distro.name
        )
        template_fn = cloud.get_template_filename(template_name)
        if not template_fn:
            msg = (
                "No template found, not rendering %s"
                % ntp_client_config.get("template_name")
            )
            raise RuntimeError(msg)

    write_ntp_config_template(
        cloud.distro.name,
        service_name=ntp_client_config.get("service_name"),
        servers=ntp_cfg.get("servers", []),
        pools=ntp_cfg.get("pools", []),
        path=ntp_client_config.get("confpath"),
        template_fn=template_fn,
        template=ntp_client_config.get("template"),
    )

    install_ntp_client(
        cloud.distro.install_packages,
        packages=ntp_client_config["packages"],
        check_exe=ntp_client_config["check_exe"],
    )
    try:
        cloud.distro.manage_service(
            "reload", ntp_client_config.get("service_name")
        )
    except subp.ProcessExecutionError as e:
        LOG.exception("Failed to reload/start ntp service: %s", e)
        raise


# vi: ts=4 expandtab
