# Copyright (C) 2018 Canonical Ltd.
#
# This file is part of cloud-init. See LICENSE file for license information.

"""Snap: Install, configure and manage snapd and snap packages."""

import sys
from textwrap import dedent

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

distros = ["ubuntu"]
frequency = PER_INSTANCE

LOG = logging.getLogger(__name__)

meta: MetaSchema = {
    "id": "cc_snap",
    "name": "Snap",
    "title": "Install, configure and manage snapd and snap packages",
    "description": dedent(
        """\
        This module provides a simple configuration namespace in cloud-init to
        both setup snapd and install snaps.

        .. note::
            Both ``assertions`` and ``commands`` values can be either a
            dictionary or a list. If these configs are provided as a
            dictionary, the keys are only used to order the execution of the
            assertions or commands and the dictionary is merged with any
            vendor-data snap configuration provided. If a list is provided by
            the user instead of a dict, any vendor-data snap configuration is
            ignored.

        The ``assertions`` configuration option is a dictionary or list of
        properly-signed snap assertions which will run before any snap
        ``commands``. They will be added to snapd's assertion database by
        invoking ``snap ack <aggregate_assertion_file>``.

        Snap ``commands`` is a dictionary or list of individual snap
        commands to run on the target system. These commands can be used to
        create snap users, install snaps and provide snap configuration.

        .. note::
            If 'side-loading' private/unpublished snaps on an instance, it is
            best to create a snap seed directory and seed.yaml manifest in
            **/var/lib/snapd/seed/** which snapd automatically installs on
            startup.

        **Development only**: The ``squashfuse_in_container`` boolean can be
        set true to install squashfuse package when in a container to enable
        snap installs. Default is false.
        """
    ),
    "distros": distros,
    "examples": [
        dedent(
            """\
        snap:
            assertions:
              00: |
                signed_assertion_blob_here
              02: |
                signed_assertion_blob_here
            commands:
              00: snap create-user --sudoer --known <snap-user>@mydomain.com
              01: snap install canonical-livepatch
              02: canonical-livepatch enable <AUTH_TOKEN>
    """
        ),
        dedent(
            """\
        # LXC-based containers require squashfuse before snaps can be installed
        snap:
            commands:
                00: apt-get install squashfuse -y
                11: snap install emoj

    """
        ),
        dedent(
            """\
        # Convenience: the snap command can be omitted when specifying commands
        # as a list and 'snap' will automatically be prepended.
        # The following commands are equivalent:
        snap:
            commands:
                00: ['install', 'vlc']
                01: ['snap', 'install', 'vlc']
                02: snap install vlc
                03: 'snap install vlc'
    """
        ),
        dedent(
            """\
        # You can use a list of commands
        snap:
            commands:
                - ['install', 'vlc']
                - ['snap', 'install', 'vlc']
                - snap install vlc
                - 'snap install vlc'
    """
        ),
        dedent(
            """\
        # You can use a list of assertions
        snap:
            assertions:
                - signed_assertion_blob_here
                - |
                    signed_assertion_blob_here
    """
        ),
    ],
    "frequency": PER_INSTANCE,
}

schema = {
    "type": "object",
    "properties": {
        "snap": {
            "type": "object",
            "properties": {
                "assertions": {
                    "type": ["object", "array"],  # Array of strings or dict
                    "items": {"type": "string"},
                    "additionalItems": False,  # Reject items non-string
                    "minItems": 1,
                    "minProperties": 1,
                    "uniqueItems": True,
                    "additionalProperties": {"type": "string"},
                },
                "commands": {
                    "type": ["object", "array"],  # Array of strings or dict
                    "items": {
                        "oneOf": [
                            {"type": "array", "items": {"type": "string"}},
                            {"type": "string"},
                        ]
                    },
                    "additionalItems": False,  # Reject non-string & non-list
                    "minItems": 1,
                    "minProperties": 1,
                    "additionalProperties": {
                        "oneOf": [
                            {"type": "string"},
                            {"type": "array", "items": {"type": "string"}},
                        ],
                    },
                },
                "squashfuse_in_container": {"type": "boolean"},
            },
            "additionalProperties": False,  # Reject keys not in schema
            "minProperties": 1,
        }
    },
}

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

SNAP_CMD = "snap"
ASSERTIONS_FILE = "/var/lib/cloud/instance/snapd.assertions"


def add_assertions(assertions):
    """Import list of assertions.

    Import assertions by concatenating each assertion into a
    string separated by a '\n'.  Write this string to a instance file and
    then invoke `snap ack /path/to/file` and check for errors.
    If snap exits 0, then all assertions are imported.
    """
    if not assertions:
        return
    LOG.debug("Importing user-provided snap assertions")
    if isinstance(assertions, dict):
        assertions = assertions.values()
    elif not isinstance(assertions, list):
        raise TypeError(
            "assertion parameter was not a list or dict: {assertions}".format(
                assertions=assertions
            )
        )

    snap_cmd = [SNAP_CMD, "ack"]
    combined = "\n".join(assertions)

    for asrt in assertions:
        LOG.debug("Snap acking: %s", asrt.split("\n")[0:2])

    util.write_file(ASSERTIONS_FILE, combined.encode("utf-8"))
    subp.subp(snap_cmd + [ASSERTIONS_FILE], capture=True)


def run_commands(commands):
    """Run the provided commands provided in snap:commands configuration.

    Commands are run individually. Any errors are collected and reported
    after attempting all commands.

    @param commands: A list or dict containing commands to run. Keys of a
        dict will be used to order the commands provided as dict values.
    """
    if not commands:
        return
    LOG.debug("Running user-provided snap commands")
    if isinstance(commands, dict):
        # Sort commands based on dictionary key
        commands = [v for _, v in sorted(commands.items())]
    elif not isinstance(commands, list):
        raise TypeError(
            "commands parameter was not a list or dict: {commands}".format(
                commands=commands
            )
        )

    fixed_snap_commands = prepend_base_command("snap", commands)

    cmd_failures = []
    for command in fixed_snap_commands:
        shell = isinstance(command, str)
        try:
            subp.subp(command, shell=shell, status_cb=sys.stderr.write)
        except subp.ProcessExecutionError as e:
            cmd_failures.append(str(e))
    if cmd_failures:
        msg = "Failures running snap commands:\n{cmd_failures}".format(
            cmd_failures=cmd_failures
        )
        util.logexc(LOG, msg)
        raise RuntimeError(msg)


# RELEASE_BLOCKER: Once LP: #1628289 is released on xenial, drop this function.
def maybe_install_squashfuse(cloud):
    """Install squashfuse if we are in a container."""
    if not util.is_container():
        return
    try:
        cloud.distro.update_package_sources()
    except Exception:
        util.logexc(LOG, "Package update failed")
        raise
    try:
        cloud.distro.install_packages(["squashfuse"])
    except Exception:
        util.logexc(LOG, "Failed to install squashfuse")
        raise


def handle(name, cfg, cloud, log, args):
    cfgin = cfg.get("snap", {})
    if not cfgin:
        LOG.debug(
            "Skipping module named %s, no 'snap' key in configuration", name
        )
        return

    validate_cloudconfig_schema(cfg, schema)
    if util.is_true(cfgin.get("squashfuse_in_container", False)):
        maybe_install_squashfuse(cloud)
    add_assertions(cfgin.get("assertions", []))
    run_commands(cfgin.get("commands", []))


# vi: ts=4 expandtab
