summaryrefslogtreecommitdiff
path: root/tests/cloud_tests/platforms/lxd/instance.py
blob: 2b973a08284c7d3dba16d1734e133db19fe9bcca (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# 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