Blob Blame History Raw
# This file is part of cloud-init. See LICENSE file for license information.

"""Base LXD instance."""

import os
import shutil
import time
from tempfile import mkdtemp

from cloudinit.subp import subp, ProcessExecutionError, which
from cloudinit.util import load_yaml
from tests.cloud_tests import LOG
from tests.cloud_tests.util import PlatformError

from ..instances import Instance

from pylxd import exceptions as pylxd_exc


class LXDInstance(Instance):
    """LXD container backed instance."""

    platform_name = "lxd"
    _console_log_method = None
    _console_log_file = None

    def __init__(self, platform, name, properties, config, features,
                 pylxd_container):
        """Set up instance.

        @param platform: platform object
        @param name: hostname of instance
        @param properties: image properties
        @param config: image config
        @param features: supported feature flags
        """
        if not pylxd_container:
            raise ValueError("Invalid value pylxd_container: %s" %
                             pylxd_container)
        self._pylxd_container = pylxd_container
        super(LXDInstance, self).__init__(
            platform, name, properties, config, features)
        self.tmpd = mkdtemp(prefix="%s-%s" % (type(self).__name__, name))
        self.name = name
        self._setup_console_log()

    @property
    def pylxd_container(self):
        """Property function."""
        if self._pylxd_container is None:
            raise RuntimeError(
                "%s: Attempted use of pylxd_container after deletion." % self)
        self._pylxd_container.sync()
        return self._pylxd_container

    def __str__(self):
        return (
            '%s(name=%s) status=%s' %
            (self.__class__.__name__, self.name,
             ("deleted" if self._pylxd_container is None else
              self.pylxd_container.status)))

    def _execute(self, command, stdin=None, env=None):
        if env is None:
            env = {}

        env_args = []
        if env:
            env_args = ['env'] + ["%s=%s" for k, v in env.items()]

        # ensure instance is running and execute the command
        self.start()

        # Use cmdline client due to https://github.com/lxc/pylxd/issues/268
        exit_code = 0
        try:
            stdout, stderr = subp(
                ['lxc', 'exec', self.name, '--'] + env_args + list(command),
                data=stdin, decode=False)
        except ProcessExecutionError as e:
            exit_code = e.exit_code
            stdout = e.stdout
            stderr = e.stderr

        return stdout, stderr, exit_code

    def read_data(self, remote_path, decode=False):
        """Read data from instance filesystem.

        @param remote_path: path in instance
        @param decode: decode data before returning.
        @return_value: content of remote_path as bytes if 'decode' is False,
                       and as string if 'decode' is True.
        """
        data = self.pylxd_container.files.get(remote_path)
        return data.decode() if decode else data

    def write_data(self, remote_path, data):
        """Write data to instance filesystem.

        @param remote_path: path in instance
        @param data: data to write in bytes
        """
        self.pylxd_container.files.put(remote_path, data)

    @property
    def console_log_method(self):
        if self._console_log_method is not None:
            return self._console_log_method

        client = which('lxc')
        if not client:
            raise PlatformError("No 'lxc' client.")

        elif _has_proper_console_support():
            self._console_log_method = 'show-log'
        elif client.startswith("/snap"):
            self._console_log_method = 'logfile-snap'
        else:
            self._console_log_method = 'logfile-tmp'

        LOG.debug("Set console log method to %s", self._console_log_method)
        return self._console_log_method

    def _setup_console_log(self):
        method = self.console_log_method
        if not method.startswith("logfile-"):
            return

        if method == "logfile-snap":
            log_dir = "/var/snap/lxd/common/consoles"
            if not os.path.exists(log_dir):
                raise PlatformError(
                    "Unable to log with snap lxc.  Please run:\n"
                    "  sudo mkdir --mode=1777 -p %s" % log_dir)
        elif method == "logfile-tmp":
            log_dir = "/tmp"
        else:
            raise PlatformError(
                "Unexpected value for console method: %s" % method)

        # doing this ensures we can read it. Otherwise it ends up root:root.
        log_file = os.path.join(log_dir, self.name)
        with open(log_file, "w") as fp:
            fp.write("# %s\n" % self.name)

        cfg = "lxc.console.logfile=%s" % log_file
        orig = self._pylxd_container.config.get('raw.lxc', "")
        if orig:
            orig += "\n"
        self._pylxd_container.config['raw.lxc'] = orig + cfg
        self._pylxd_container.save()
        self._console_log_file = log_file

    def console_log(self):
        """Console log.

        @return_value: bytes of this instance's console
        """

        if self._console_log_file:
            if not os.path.exists(self._console_log_file):
                raise NotImplementedError(
                    "Console log '%s' does not exist. If this is a remote "
                    "lxc, then this is really NotImplementedError.  If it is "
                    "A local lxc, then this is a RuntimeError."
                    "https://github.com/lxc/lxd/issues/1129")
            with open(self._console_log_file, "rb") as fp:
                return fp.read()

        try:
            return subp(['lxc', 'console', '--show-log', self.name],
                        decode=False)[0]
        except ProcessExecutionError as e:
            raise PlatformError(
                "console log",
                "Console log failed [%d]: stdout=%s stderr=%s" % (
                    e.exit_code, e.stdout, e.stderr)
            ) from e

    def reboot(self, wait=True):
        """Reboot instance."""
        self.shutdown(wait=wait)
        self.start(wait=wait)

    def shutdown(self, wait=True, retry=1):
        """Shutdown instance."""
        if self.pylxd_container.status == 'Stopped':
            return

        try:
            LOG.debug("%s: shutting down (wait=%s)", self, wait)
            self.pylxd_container.stop(wait=wait)
        except (pylxd_exc.LXDAPIException, pylxd_exc.NotFound) as e:
            # An exception happens here sometimes (LP: #1783198)
            # LOG it, and try again.
            LOG.warning(
                ("%s: shutdown(retry=%d) caught %s in shutdown "
                 "(response=%s): %s"),
                self, retry, e.__class__.__name__, e.response, e)
            if isinstance(e, pylxd_exc.NotFound):
                LOG.debug("container_exists(%s) == %s",
                          self.name, self.platform.container_exists(self.name))
            if retry == 0:
                raise e
            return self.shutdown(wait=wait, retry=retry - 1)

    def start(self, wait=True, wait_for_cloud_init=False):
        """Start instance."""
        if self.pylxd_container.status != 'Running':
            self.pylxd_container.start(wait=wait)
            if wait:
                self._wait_for_system(wait_for_cloud_init)

    def freeze(self):
        """Freeze instance."""
        if self.pylxd_container.status != 'Frozen':
            self.pylxd_container.freeze(wait=True)

    def unfreeze(self):
        """Unfreeze instance."""
        if self.pylxd_container.status == 'Frozen':
            self.pylxd_container.unfreeze(wait=True)

    def destroy(self):
        """Clean up instance."""
        LOG.debug("%s: deleting container.", self)
        self.unfreeze()
        self.shutdown()
        retries = [1] * 5
        for attempt, wait in enumerate(retries):
            try:
                self.pylxd_container.delete(wait=True)
                break
            except Exception:
                if attempt + 1 >= len(retries):
                    raise
                LOG.debug('Failed to delete container %s (%s/%s) retrying...',
                          self, attempt + 1, len(retries))
                time.sleep(wait)

        self._pylxd_container = None

        if self.platform.container_exists(self.name):
            raise OSError('%s: container was not properly removed' % self)
        if self._console_log_file and os.path.exists(self._console_log_file):
            os.unlink(self._console_log_file)
        shutil.rmtree(self.tmpd)
        super(LXDInstance, self).destroy()


def _has_proper_console_support():
    stdout, _ = subp(['lxc', 'info'])
    info = load_yaml(stdout)
    reason = None
    if 'console' not in info.get('api_extensions', []):
        reason = "LXD server does not support console api extension"
    else:
        dver = str(info.get('environment', {}).get('driver_version', ""))
        if dver.startswith("2.") or dver.startswith("1."):
            reason = "LXD Driver version not 3.x+ (%s)" % dver
        else:
            try:
                stdout = subp(['lxc', 'console', '--help'], decode=False)[0]
                if not (b'console' in stdout and b'log' in stdout):
                    reason = "no '--log' in lxc console --help"
            except ProcessExecutionError:
                reason = "no 'console' command in lxc client"

    if reason:
        LOG.debug("no console-support: %s", reason)
        return False
    else:
        LOG.debug("console-support looks good")
        return True


# vi: ts=4 expandtab