# Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
#
# Author: Avishai Ish-Shalom <avishai@fewbytes.com>
# Author: Mike Moulton <mike@meltmedia.com>
# Author: Juerg Haefliger <juerg.haefliger@hp.com>
#
# This file is part of cloud-init. See LICENSE file for license information.

"""Chef: module that configures, starts and installs chef."""

import itertools
import json
import logging
import os
import shutil
from typing import List

from cloudinit import subp, temp_utils, templater, url_helper, util
from cloudinit.cloud import Cloud
from cloudinit.config import Config
from cloudinit.config.schema import MetaSchema
from cloudinit.distros import Distro
from cloudinit.settings import PER_ALWAYS

RUBY_VERSION_DEFAULT = "1.8"

CHEF_DIRS = (
    "/etc/chef",
    "/var/log/chef",
    "/var/lib/chef",
    "/var/chef/cache",
    "/var/chef/backup",
    "/var/run/chef",
)
REQUIRED_CHEF_DIRS = ("/etc/chef",)

CHEF_DIR_MIGRATION = {
    "/var/cache/chef": "/var/chef/cache",
    "/var/backups/chef": "/var/chef/backup",
}

# Used if fetching chef from a omnibus style package
OMNIBUS_URL = "https://www.chef.io/chef/install.sh"
OMNIBUS_URL_RETRIES = 5

CHEF_VALIDATION_PEM_PATH = "/etc/chef/validation.pem"
CHEF_FB_PATH = "/etc/chef/firstboot.json"
CHEF_RB_TPL_DEFAULTS = {
    # These are ruby symbols...
    "ssl_verify_mode": ":verify_none",
    "log_level": ":info",
    # These are not symbols...
    "log_location": "/var/log/chef/client.log",
    "validation_key": CHEF_VALIDATION_PEM_PATH,
    "validation_cert": None,
    "client_key": "/etc/chef/client.pem",
    "json_attribs": CHEF_FB_PATH,
    "file_cache_path": "/var/chef/cache",
    "file_backup_path": "/var/chef/backup",
    "pid_file": "/var/run/chef/client.pid",
    "show_time": True,
    "encrypted_data_bag_secret": None,
}
CHEF_RB_TPL_BOOL_KEYS = frozenset(["show_time"])
CHEF_RB_TPL_PATH_KEYS = frozenset(
    [
        "log_location",
        "validation_key",
        "client_key",
        "file_cache_path",
        "json_attribs",
        "pid_file",
        "encrypted_data_bag_secret",
    ]
)
CHEF_RB_TPL_KEYS = frozenset(
    [
        *CHEF_RB_TPL_DEFAULTS.keys(),
        *CHEF_RB_TPL_BOOL_KEYS,
        *CHEF_RB_TPL_PATH_KEYS,
        "server_url",
        "node_name",
        "environment",
        "validation_name",
        "chef_license",
    ]
)
CHEF_RB_PATH = "/etc/chef/client.rb"
CHEF_EXEC_PATH = "/usr/bin/chef-client"
CHEF_EXEC_DEF_ARGS = ("-d", "-i", "1800", "-s", "20")


LOG = logging.getLogger(__name__)

meta: MetaSchema = {
    "id": "cc_chef",
    "distros": ["all"],
    "frequency": PER_ALWAYS,
    "activate_by_schema_keys": ["chef"],
}


def post_run_chef(chef_cfg):
    delete_pem = util.get_cfg_option_bool(
        chef_cfg, "delete_validation_post_exec", default=False
    )
    if delete_pem and os.path.isfile(CHEF_VALIDATION_PEM_PATH):
        os.unlink(CHEF_VALIDATION_PEM_PATH)


def get_template_params(iid, chef_cfg):
    params = CHEF_RB_TPL_DEFAULTS.copy()
    # Allow users to overwrite any of the keys they want (if they so choose),
    # when a value is None, then the value will be set to None and no boolean
    # or string version will be populated...
    for k, v in chef_cfg.items():
        if k not in CHEF_RB_TPL_KEYS:
            LOG.debug("Skipping unknown chef template key '%s'", k)
            continue
        if v is None:
            params[k] = None
        else:
            # This will make the value a boolean or string...
            if k in CHEF_RB_TPL_BOOL_KEYS:
                params[k] = util.get_cfg_option_bool(chef_cfg, k)
            else:
                params[k] = util.get_cfg_option_str(chef_cfg, k)
    # These ones are overwritten to be exact values...
    params.update(
        {
            "generated_by": util.make_header(),
            "node_name": util.get_cfg_option_str(
                chef_cfg, "node_name", default=iid
            ),
            "environment": util.get_cfg_option_str(
                chef_cfg, "environment", default="_default"
            ),
            # These two are mandatory...
            "server_url": chef_cfg["server_url"],
            "validation_name": chef_cfg["validation_name"],
        }
    )
    return params


def migrate_chef_config_dirs():
    """Migrate legacy chef backup and cache directories to new config paths."""
    for old_dir, migrated_dir in CHEF_DIR_MIGRATION.items():
        if os.path.exists(old_dir):
            for filename in os.listdir(old_dir):
                if os.path.exists(os.path.join(migrated_dir, filename)):
                    LOG.debug(
                        "Ignoring migration of %s. File already exists in %s.",
                        os.path.join(old_dir, filename),
                        migrated_dir,
                    )
                    continue
                LOG.debug(
                    "Moving %s to %s.",
                    os.path.join(old_dir, filename),
                    migrated_dir,
                )
                shutil.move(os.path.join(old_dir, filename), migrated_dir)


def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
    """Handler method activated by cloud-init."""

    # If there isn't a chef key in the configuration don't do anything
    if "chef" not in cfg:
        LOG.debug(
            "Skipping module named %s, no 'chef' key in configuration", name
        )
        return

    chef_cfg = cfg["chef"]

    # Ensure the chef directories we use exist
    chef_dirs = util.get_cfg_option_list(chef_cfg, "directories")
    if not chef_dirs:
        chef_dirs = list(CHEF_DIRS)
    for d in itertools.chain(chef_dirs, REQUIRED_CHEF_DIRS):
        util.ensure_dir(d)

    # Migrate old directory cache and backups to new
    migrate_chef_config_dirs()

    vkey_path = chef_cfg.get("validation_key", CHEF_VALIDATION_PEM_PATH)
    vcert = chef_cfg.get("validation_cert")
    # special value 'system' means do not overwrite the file
    # but still render the template to contain 'validation_key'
    if vcert:
        if vcert != "system":
            util.write_file(vkey_path, vcert)
        elif not os.path.isfile(vkey_path):
            LOG.warning(
                "chef validation_cert provided as 'system', but "
                "validation_key path '%s' does not exist.",
                vkey_path,
            )

    # Create the chef config from template
    cfg_filename = util.get_cfg_option_str(
        chef_cfg, "config_path", default=CHEF_RB_PATH
    )
    template_fn = cloud.get_template_filename("chef_client.rb")
    if template_fn:
        iid = str(cloud.datasource.get_instance_id())
        params = get_template_params(iid, chef_cfg)
        # Do a best effort attempt to ensure that the template values that
        # are associated with paths have their parent directory created
        # before they are used by the chef-client itself.
        param_paths = set()
        for k, v in params.items():
            if k in CHEF_RB_TPL_PATH_KEYS and v:
                param_paths.add(os.path.dirname(v))
        util.ensure_dirs(param_paths)
        templater.render_to_file(template_fn, cfg_filename, params)
    else:
        LOG.warning("No template found, not rendering to %s", cfg_filename)

    # Set the firstboot json
    fb_filename = util.get_cfg_option_str(
        chef_cfg, "firstboot_path", default=CHEF_FB_PATH
    )
    if not fb_filename:
        LOG.info("First boot path empty, not writing first boot json file")
    else:
        initial_json = {}
        if "run_list" in chef_cfg:
            initial_json["run_list"] = chef_cfg["run_list"]
        if "initial_attributes" in chef_cfg:
            initial_attributes = chef_cfg["initial_attributes"]
            for k in list(initial_attributes.keys()):
                initial_json[k] = initial_attributes[k]
        util.write_file(fb_filename, json.dumps(initial_json))

    # Try to install chef, if its not already installed...
    force_install = util.get_cfg_option_bool(
        chef_cfg, "force_install", default=False
    )
    installed = subp.is_exe(CHEF_EXEC_PATH)
    if not installed or force_install:
        run = install_chef(cloud, chef_cfg)
    elif installed:
        run = util.get_cfg_option_bool(chef_cfg, "exec", default=False)
    else:
        run = False
    if run:
        run_chef(chef_cfg)
        post_run_chef(chef_cfg)


def run_chef(chef_cfg):
    LOG.debug("Running chef-client")
    cmd = [CHEF_EXEC_PATH]
    if "exec_arguments" in chef_cfg:
        cmd_args = chef_cfg["exec_arguments"]
        if isinstance(cmd_args, (list, tuple)):
            cmd.extend(cmd_args)
        elif isinstance(cmd_args, str):
            cmd.append(cmd_args)
        else:
            LOG.warning(
                "Unknown type %s provided for chef"
                " 'exec_arguments' expected list, tuple,"
                " or string",
                type(cmd_args),
            )
            cmd.extend(CHEF_EXEC_DEF_ARGS)
    else:
        cmd.extend(CHEF_EXEC_DEF_ARGS)
    subp.subp(cmd, capture=False)


def subp_blob_in_tempfile(blob, distro: Distro, args: list, **kwargs):
    """Write blob to a tempfile, and call subp with args, kwargs. Then cleanup.

    'basename' as a kwarg allows providing the basename for the file.
    The 'args' argument to subp will be updated with the full path to the
    filename as the first argument.
    """
    args = args.copy()
    basename = kwargs.pop("basename", "subp_blob")
    # Use tmpdir over tmpfile to avoid 'text file busy' on execute
    with temp_utils.tempdir(
        dir=distro.get_tmp_exec_path(), needs_exe=True
    ) as tmpd:
        tmpf = os.path.join(tmpd, basename)
        args.insert(0, tmpf)
        util.write_file(tmpf, blob, mode=0o700)
        return subp.subp(args=args, **kwargs)


def install_chef_from_omnibus(
    distro: Distro, url=None, retries=None, omnibus_version=None
):
    """Install an omnibus unified package from url.

    @param url: URL where blob of chef content may be downloaded. Defaults to
        OMNIBUS_URL.
    @param retries: Number of retries to perform when attempting to read url.
        Defaults to OMNIBUS_URL_RETRIES
    @param omnibus_version: Optional version string to require for omnibus
        install.
    """
    if url is None:
        url = OMNIBUS_URL
    if retries is None:
        retries = OMNIBUS_URL_RETRIES

    if omnibus_version is None:
        args = []
    else:
        args = ["-v", omnibus_version]
    content = url_helper.readurl(url=url, retries=retries).contents
    return subp_blob_in_tempfile(
        distro=distro,
        blob=content,
        args=args,
        basename="chef-omnibus-install",
        capture=False,
    )


def install_chef(cloud: Cloud, chef_cfg):
    # If chef is not installed, we install chef based on 'install_type'
    install_type = util.get_cfg_option_str(
        chef_cfg, "install_type", "packages"
    )
    run = util.get_cfg_option_bool(chef_cfg, "exec", default=False)
    if install_type == "gems":
        # This will install and run the chef-client from gems
        chef_version = util.get_cfg_option_str(chef_cfg, "version", None)
        ruby_version = util.get_cfg_option_str(
            chef_cfg, "ruby_version", RUBY_VERSION_DEFAULT
        )
        install_chef_from_gems(ruby_version, chef_version, cloud.distro)
        # Retain backwards compat, by preferring True instead of False
        # when not provided/overriden...
        run = util.get_cfg_option_bool(chef_cfg, "exec", default=True)
    elif install_type == "packages":
        # This will install and run the chef-client from packages
        cloud.distro.install_packages(["chef"])
    elif install_type == "omnibus":
        omnibus_version = util.get_cfg_option_str(chef_cfg, "omnibus_version")
        install_chef_from_omnibus(
            distro=cloud.distro,
            url=util.get_cfg_option_str(chef_cfg, "omnibus_url"),
            retries=util.get_cfg_option_int(chef_cfg, "omnibus_url_retries"),
            omnibus_version=omnibus_version,
        )
    else:
        LOG.warning("Unknown chef install type '%s'", install_type)
        run = False
    return run


def get_ruby_packages(version) -> List[str]:
    # return a list of packages needed to install ruby at version
    pkgs: List[str] = ["ruby%s" % version, "ruby%s-dev" % version]
    if version == "1.8":
        pkgs.extend(("libopenssl-ruby1.8", "rubygems1.8"))
    return pkgs


def install_chef_from_gems(ruby_version, chef_version, distro):
    distro.install_packages(get_ruby_packages(ruby_version))
    if not os.path.exists("/usr/bin/gem"):
        util.sym_link("/usr/bin/gem%s" % ruby_version, "/usr/bin/gem")
    if not os.path.exists("/usr/bin/ruby"):
        util.sym_link("/usr/bin/ruby%s" % ruby_version, "/usr/bin/ruby")
    if chef_version:
        subp.subp(
            [
                "/usr/bin/gem",
                "install",
                "chef",
                "-v %s" % chef_version,
                "--no-ri",
                "--no-rdoc",
                "--bindir",
                "/usr/bin",
                "-q",
            ],
            capture=False,
        )
    else:
        subp.subp(
            [
                "/usr/bin/gem",
                "install",
                "chef",
                "--no-ri",
                "--no-rdoc",
                "--bindir",
                "/usr/bin",
                "-q",
            ],
            capture=False,
        )
