~cjwatson/launchpad-buildd/local-snap-proxy

« back to all changes in this revision

Viewing changes to lpbuildd/target/lxd.py

  • Committer: Colin Watson
  • Date: 2018-02-27 14:06:36 UTC
  • mfrom: (215.2.111 launchpad-buildd)
  • Revision ID: cjwatson@canonical.com-20180227140636-f80jb5t2o3hyywpg
Merge trunk.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2017 Canonical Ltd.  This software is licensed under the
 
2
# GNU Affero General Public License version 3 (see the file LICENSE).
 
3
 
 
4
from __future__ import print_function
 
5
 
 
6
__metaclass__ = type
 
7
 
 
8
from contextlib import closing
 
9
import io
 
10
import json
 
11
import os
 
12
import re
 
13
import stat
 
14
import subprocess
 
15
import tarfile
 
16
import tempfile
 
17
from textwrap import dedent
 
18
import time
 
19
 
 
20
import netaddr
 
21
import pylxd
 
22
from pylxd.exceptions import LXDAPIException
 
23
 
 
24
from lpbuildd.target.backend import (
 
25
    Backend,
 
26
    BackendException,
 
27
    )
 
28
from lpbuildd.util import (
 
29
    set_personality,
 
30
    shell_escape,
 
31
    )
 
32
 
 
33
 
 
34
LXD_RUNNING = 103
 
35
 
 
36
 
 
37
fallback_hosts = dedent("""\
 
38
    127.0.0.1\tlocalhost
 
39
    ::1\tlocalhost ip6-localhost ip6-loopback
 
40
    fe00::0\tip6-localnet
 
41
    ff00::0\tip6-mcastprefix
 
42
    ff02::1\tip6-allnodes
 
43
    ff02::2\tip6-allrouters
 
44
    """)
 
45
 
 
46
 
 
47
policy_rc_d = dedent("""\
 
48
    #! /bin/sh
 
49
    while :; do
 
50
        case "$1" in
 
51
            -*) shift ;;
 
52
            systemd-udevd|systemd-udevd.service|udev|udev.service)
 
53
                exit 0 ;;
 
54
            snapd|snapd.socket|snapd.service)
 
55
                exit 0 ;;
 
56
            *)
 
57
                echo "Not running services in chroot."
 
58
                exit 101
 
59
                ;;
 
60
        esac
 
61
    done
 
62
    """)
 
63
 
 
64
 
 
65
class LXDException(Exception):
 
66
    """Wrap an LXDAPIException with some more useful information."""
 
67
 
 
68
    def __init__(self, action, lxdapi_exc):
 
69
        self.action = action
 
70
        self.lxdapi_exc = lxdapi_exc
 
71
 
 
72
    def __str__(self):
 
73
        return "%s: %s" % (self.action, self.lxdapi_exc)
 
74
 
 
75
 
 
76
class LXD(Backend):
 
77
 
 
78
    # Architecture mapping
 
79
    arches = {
 
80
        "amd64": "x86_64",
 
81
        "arm64": "aarch64",
 
82
        "armhf": "armv7l",
 
83
        "i386": "i686",
 
84
        "powerpc": "ppc",
 
85
        "ppc64el": "ppc64le",
 
86
        "s390x": "s390x",
 
87
        }
 
88
 
 
89
    profile_name = "lpbuildd"
 
90
    bridge_name = "lpbuilddbr0"
 
91
    # XXX cjwatson 2017-08-07: Hardcoded for now to be in a range reserved
 
92
    # for employee private networks in
 
93
    # https://wiki.canonical.com/InformationInfrastructure/IS/Network, so it
 
94
    # won't collide with any production networks.  This should be
 
95
    # configurable.
 
96
    ipv4_network = netaddr.IPNetwork("10.10.10.1/24")
 
97
    run_dir = "/run/launchpad-buildd"
 
98
 
 
99
    _client = None
 
100
 
 
101
    @property
 
102
    def client(self):
 
103
        if self._client is None:
 
104
            self._client = pylxd.Client()
 
105
        return self._client
 
106
 
 
107
    @property
 
108
    def lxc_arch(self):
 
109
        return self.arches[self.arch]
 
110
 
 
111
    @property
 
112
    def alias(self):
 
113
        return "lp-%s-%s" % (self.series, self.arch)
 
114
 
 
115
    @property
 
116
    def name(self):
 
117
        return self.alias
 
118
 
 
119
    def is_running(self):
 
120
        try:
 
121
            container = self.client.containers.get(self.name)
 
122
            return container.status_code == LXD_RUNNING
 
123
        except LXDAPIException:
 
124
            return False
 
125
 
 
126
    def _convert(self, source_tarball, target_tarball):
 
127
        creation_time = source_tarball.getmember("chroot-autobuild").mtime
 
128
        metadata = {
 
129
            "architecture": self.lxc_arch,
 
130
            "creation_date": creation_time,
 
131
            "properties": {
 
132
                "os": "Ubuntu",
 
133
                "series": self.series,
 
134
                "architecture": self.arch,
 
135
                "description": "Launchpad chroot for Ubuntu %s (%s)" % (
 
136
                    self.series, self.arch),
 
137
                },
 
138
            }
 
139
        # Encoding this as JSON is good enough, and saves pulling in a YAML
 
140
        # library dependency.
 
141
        metadata_yaml = json.dumps(
 
142
            metadata, sort_keys=True, indent=4, separators=(",", ": "),
 
143
            ensure_ascii=False).encode("UTF-8") + b"\n"
 
144
        metadata_file = tarfile.TarInfo(name="metadata.yaml")
 
145
        metadata_file.size = len(metadata_yaml)
 
146
        target_tarball.addfile(metadata_file, io.BytesIO(metadata_yaml))
 
147
 
 
148
        # Mangle the chroot tarball into the form needed by LXD: when using
 
149
        # the combined metadata/rootfs form, the rootfs must be under
 
150
        # rootfs/ rather than under chroot-autobuild/.
 
151
        for entry in source_tarball:
 
152
            fileptr = None
 
153
            try:
 
154
                orig_name = entry.name.split("chroot-autobuild", 1)[-1]
 
155
                entry.name = "rootfs" + orig_name
 
156
 
 
157
                if entry.isfile():
 
158
                    try:
 
159
                        fileptr = source_tarball.extractfile(entry.name)
 
160
                    except KeyError:
 
161
                        pass
 
162
                elif entry.islnk():
 
163
                    # Update hardlinks to point to the right target
 
164
                    entry.linkname = (
 
165
                        "rootfs" +
 
166
                        entry.linkname.split("chroot-autobuild", 1)[-1])
 
167
 
 
168
                target_tarball.addfile(entry, fileobj=fileptr)
 
169
            finally:
 
170
                if fileptr is not None:
 
171
                    fileptr.close()
 
172
 
 
173
    def create(self, tarball_path):
 
174
        """See `Backend`."""
 
175
        self.remove_image()
 
176
 
 
177
        # This is a lot of data to shuffle around in Python, but there
 
178
        # doesn't currently seem to be any way to ask pylxd to ask lxd to
 
179
        # import an image from a file on disk.
 
180
        with io.BytesIO() as target_file:
 
181
            with tarfile.open(name=tarball_path, mode="r") as source_tarball:
 
182
                with tarfile.open(
 
183
                        fileobj=target_file, mode="w") as target_tarball:
 
184
                    self._convert(source_tarball, target_tarball)
 
185
 
 
186
            image = self.client.images.create(
 
187
                target_file.getvalue(), wait=True)
 
188
            image.add_alias(self.alias, self.alias)
 
189
 
 
190
    @property
 
191
    def sys_dir(self):
 
192
        return os.path.join("/sys/class/net", self.bridge_name)
 
193
 
 
194
    @property
 
195
    def dnsmasq_pid_file(self):
 
196
        return os.path.join(self.run_dir, "dnsmasq.pid")
 
197
 
 
198
    def iptables(self, args, check=True):
 
199
        call = subprocess.check_call if check else subprocess.call
 
200
        call(
 
201
            ["sudo", "iptables", "-w"] + args +
 
202
            ["-m", "comment", "--comment", "managed by launchpad-buildd"])
 
203
 
 
204
    def start_bridge(self):
 
205
        if not os.path.isdir(self.run_dir):
 
206
            os.makedirs(self.run_dir)
 
207
        subprocess.check_call(
 
208
            ["sudo", "ip", "link", "add", "dev", self.bridge_name,
 
209
             "type", "bridge"])
 
210
        subprocess.check_call(
 
211
            ["sudo", "ip", "addr", "add", str(self.ipv4_network),
 
212
             "dev", self.bridge_name])
 
213
        subprocess.check_call(
 
214
            ["sudo", "ip", "link", "set", "dev", self.bridge_name, "up"])
 
215
        subprocess.check_call(
 
216
            ["sudo", "sysctl", "-q", "-w", "net.ipv4.ip_forward=1"])
 
217
        self.iptables(
 
218
            ["-t", "mangle", "-A", "FORWARD", "-i", self.bridge_name,
 
219
             "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
 
220
             "-j", "TCPMSS", "--clamp-mss-to-pmtu"])
 
221
        self.iptables(
 
222
            ["-t", "nat", "-A", "POSTROUTING",
 
223
             "-s", str(self.ipv4_network), "!", "-d", str(self.ipv4_network),
 
224
             "-j", "MASQUERADE"])
 
225
        subprocess.check_call(
 
226
            ["sudo", "/usr/sbin/dnsmasq", "-s", "lpbuildd", "-S", "/lpbuildd/",
 
227
             "-u", "buildd", "--strict-order", "--bind-interfaces",
 
228
             "--pid-file=%s" % self.dnsmasq_pid_file,
 
229
             "--except-interface=lo", "--interface=%s" % self.bridge_name,
 
230
             "--listen-address=%s" % str(self.ipv4_network.ip)])
 
231
 
 
232
    def stop_bridge(self):
 
233
        if not os.path.isdir(self.sys_dir):
 
234
            return
 
235
        subprocess.call(
 
236
            ["sudo", "ip", "addr", "flush", "dev", self.bridge_name])
 
237
        subprocess.call(
 
238
            ["sudo", "ip", "link", "set", "dev", self.bridge_name, "down"])
 
239
        self.iptables(
 
240
            ["-t", "mangle", "-D", "FORWARD", "-i", self.bridge_name,
 
241
             "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
 
242
             "-j", "TCPMSS", "--clamp-mss-to-pmtu"])
 
243
        self.iptables(
 
244
            ["-t", "nat", "-D", "POSTROUTING",
 
245
             "-s", str(self.ipv4_network), "!", "-d", str(self.ipv4_network),
 
246
             "-j", "MASQUERADE"], check=False)
 
247
        if os.path.exists(self.dnsmasq_pid_file):
 
248
            with open(self.dnsmasq_pid_file) as f:
 
249
                try:
 
250
                    dnsmasq_pid = int(f.read())
 
251
                except Exception:
 
252
                    pass
 
253
                else:
 
254
                    # dnsmasq is supposed to drop privileges, but kill it as
 
255
                    # root just in case it fails to do so for some reason.
 
256
                    subprocess.call(["sudo", "kill", "-9", str(dnsmasq_pid)])
 
257
            os.unlink(self.dnsmasq_pid_file)
 
258
        subprocess.call(["sudo", "ip", "link", "delete", self.bridge_name])
 
259
 
 
260
    def create_profile(self):
 
261
        for addr in self.ipv4_network:
 
262
            if addr not in (
 
263
                    self.ipv4_network.network, self.ipv4_network.ip,
 
264
                    self.ipv4_network.broadcast):
 
265
                ipv4_address = netaddr.IPNetwork(
 
266
                    (int(addr), self.ipv4_network.prefixlen))
 
267
                break
 
268
        else:
 
269
            raise BackendException(
 
270
                "%s has no usable IP addresses" % self.ipv4_network)
 
271
 
 
272
        try:
 
273
            old_profile = self.client.profiles.get(self.profile_name)
 
274
        except LXDAPIException:
 
275
            pass
 
276
        else:
 
277
            old_profile.delete()
 
278
 
 
279
        raw_lxc_config = [
 
280
            ("lxc.aa_profile", "unconfined"),
 
281
            ("lxc.cap.drop", ""),
 
282
            ("lxc.cap.drop", "sys_time sys_module"),
 
283
            ("lxc.cgroup.devices.deny", ""),
 
284
            ("lxc.cgroup.devices.allow", ""),
 
285
            ("lxc.mount.auto", ""),
 
286
            ("lxc.mount.auto", "proc:rw sys:rw"),
 
287
            ("lxc.network.0.ipv4", ipv4_address),
 
288
            ("lxc.network.0.ipv4.gateway", self.ipv4_network.ip),
 
289
            ]
 
290
        # Linux 4.4 on powerpc doesn't support all the seccomp bits that LXD
 
291
        # needs.
 
292
        if self.arch == "powerpc":
 
293
            raw_lxc_config.append(("lxc.seccomp", ""))
 
294
        config = {
 
295
            "security.privileged": "true",
 
296
            "security.nesting": "true",
 
297
            "raw.lxc": "".join(
 
298
                "{key}={value}\n".format(key=key, value=value)
 
299
                for key, value in raw_lxc_config),
 
300
            }
 
301
        devices = {
 
302
            "eth0": {
 
303
                "name": "eth0",
 
304
                "nictype": "bridged",
 
305
                "parent": self.bridge_name,
 
306
                "type": "nic",
 
307
                },
 
308
            }
 
309
        self.client.profiles.create(self.profile_name, config, devices)
 
310
 
 
311
    def start(self):
 
312
        """See `Backend`."""
 
313
        self.stop()
 
314
 
 
315
        self.create_profile()
 
316
        self.start_bridge()
 
317
 
 
318
        container = self.client.containers.create({
 
319
            "name": self.name,
 
320
            "profiles": [self.profile_name],
 
321
            "source": {"type": "image", "alias": self.alias},
 
322
            }, wait=True)
 
323
 
 
324
        with tempfile.NamedTemporaryFile(mode="w+b") as hosts_file:
 
325
            try:
 
326
                self.copy_out("/etc/hosts", hosts_file.name)
 
327
            except LXDException:
 
328
                hosts_file.seek(0, os.SEEK_SET)
 
329
                hosts_file.write(fallback_hosts.encode("UTF-8"))
 
330
            hosts_file.seek(0, os.SEEK_END)
 
331
            print("\n127.0.1.1\t%s" % self.name, file=hosts_file)
 
332
            hosts_file.flush()
 
333
            os.fchmod(hosts_file.fileno(), 0o644)
 
334
            self.copy_in(hosts_file.name, "/etc/hosts")
 
335
        with tempfile.NamedTemporaryFile(mode="w+") as hostname_file:
 
336
            print(self.name, file=hostname_file)
 
337
            hostname_file.flush()
 
338
            os.fchmod(hostname_file.fileno(), 0o644)
 
339
            self.copy_in(hostname_file.name, "/etc/hostname")
 
340
        self.copy_in("/etc/resolv.conf", "/etc/resolv.conf")
 
341
        with tempfile.NamedTemporaryFile(mode="w+") as policy_rc_d_file:
 
342
            policy_rc_d_file.write(policy_rc_d)
 
343
            policy_rc_d_file.flush()
 
344
            os.fchmod(policy_rc_d_file.fileno(), 0o755)
 
345
            self.copy_in(policy_rc_d_file.name, "/usr/local/sbin/policy-rc.d")
 
346
        # For targets that use Upstart, prevent the mounted-dev job from
 
347
        # creating devices.  Most of the devices it creates are unnecessary
 
348
        # in a container, and creating loop devices will race with our own
 
349
        # code to do so.
 
350
        with tempfile.NamedTemporaryFile(mode="w+") as mounted_dev_file:
 
351
            try:
 
352
                self.copy_out(
 
353
                    "/etc/init/mounted-dev.conf", mounted_dev_file.name)
 
354
            except LXDException:
 
355
                pass
 
356
            else:
 
357
                mounted_dev_file.seek(0, os.SEEK_SET)
 
358
                script = ""
 
359
                in_script = False
 
360
                for line in mounted_dev_file:
 
361
                    if in_script:
 
362
                        script += re.sub(
 
363
                            r"^(\s*)(.*MAKEDEV)", r"\1: # \2", line)
 
364
                        if line.strip() == "end script":
 
365
                            in_script = False
 
366
                    elif line.strip() == "script":
 
367
                        script += line
 
368
                        in_script = True
 
369
                if script:
 
370
                    mounted_dev_file.seek(0, os.SEEK_SET)
 
371
                    mounted_dev_file.truncate()
 
372
                    mounted_dev_file.write(script)
 
373
                    mounted_dev_file.flush()
 
374
                    os.fchmod(mounted_dev_file.fileno(), 0o644)
 
375
                    self.copy_in(
 
376
                        mounted_dev_file.name,
 
377
                        "/etc/init/mounted-dev.override")
 
378
 
 
379
        # Start the container and wait for it to start.
 
380
        container.start(wait=True)
 
381
        timeout = 60
 
382
        now = time.time()
 
383
        while time.time() < now + timeout:
 
384
            try:
 
385
                container = self.client.containers.get(self.name)
 
386
            except LXDAPIException:
 
387
                container = None
 
388
                break
 
389
            if container.status_code == LXD_RUNNING:
 
390
                break
 
391
            time.sleep(1)
 
392
        if container is None or container.status_code != LXD_RUNNING:
 
393
            raise BackendException(
 
394
                "Container failed to start within %d seconds" % timeout)
 
395
 
 
396
        # Create loop devices.  We do this by hand rather than via the LXD
 
397
        # profile, as the latter approach creates lots of independent mounts
 
398
        # under /dev/, and that can cause confusion when building live
 
399
        # filesystems.
 
400
        self.run(
 
401
            ["mknod", "-m", "0660", "/dev/loop-control", "c", "10", "237"])
 
402
        for minor in range(8):
 
403
            self.run(
 
404
                ["mknod", "-m", "0660", "/dev/loop%d" % minor,
 
405
                 "b", "7", str(minor)])
 
406
 
 
407
        # XXX cjwatson 2017-09-07: With LXD < 2.2 we can't create the
 
408
        # directory until the container has started.  We can get away with
 
409
        # this for the time being because snapd isn't in the buildd chroots.
 
410
        self.run(["mkdir", "-p", "/etc/systemd/system/snapd.service.d"])
 
411
        with tempfile.NamedTemporaryFile(mode="w+") as no_cdn_file:
 
412
            print(dedent("""\
 
413
                [Service]
 
414
                Environment=SNAPPY_STORE_NO_CDN=1
 
415
                """), file=no_cdn_file, end="")
 
416
            no_cdn_file.flush()
 
417
            os.fchmod(no_cdn_file.fileno(), 0o644)
 
418
            self.copy_in(
 
419
                no_cdn_file.name,
 
420
                "/etc/systemd/system/snapd.service.d/no-cdn.conf")
 
421
 
 
422
    def run(self, args, cwd=None, env=None, input_text=None, get_output=False,
 
423
            echo=False, **kwargs):
 
424
        """See `Backend`."""
 
425
        env_params = []
 
426
        if env:
 
427
            for key, value in env.items():
 
428
                env_params.extend(["--env", "%s=%s" % (key, value)])
 
429
        if self.arch is not None:
 
430
            args = set_personality(args, self.arch, series=self.series)
 
431
        if cwd is not None:
 
432
            # This requires either a helper program in the chroot or
 
433
            # unpleasant quoting.  For now we go for the unpleasant quoting,
 
434
            # though once we have coreutils >= 8.28 everywhere we'll be able
 
435
            # to use "env --chdir".
 
436
            args = [
 
437
                "/bin/sh", "-c", "cd %s && %s" % (
 
438
                    shell_escape(cwd),
 
439
                    " ".join(shell_escape(arg) for arg in args)),
 
440
                ]
 
441
        if echo:
 
442
            print("Running in container: %s" % ' '.join(
 
443
                shell_escape(arg) for arg in args))
 
444
        # pylxd's Container.execute doesn't support sending stdin, and it's
 
445
        # tedious to implement ourselves.
 
446
        cmd = ["lxc", "exec", self.name] + env_params + ["--"] + args
 
447
        if input_text is None and not get_output:
 
448
            subprocess.check_call(cmd, **kwargs)
 
449
        else:
 
450
            if get_output:
 
451
                kwargs["stdout"] = subprocess.PIPE
 
452
            proc = subprocess.Popen(
 
453
                cmd, stdin=subprocess.PIPE, universal_newlines=True, **kwargs)
 
454
            output, _ = proc.communicate(input_text)
 
455
            if proc.returncode:
 
456
                raise subprocess.CalledProcessError(proc.returncode, cmd)
 
457
            if get_output:
 
458
                if echo:
 
459
                    print("Output:")
 
460
                    print(output)
 
461
                return output
 
462
 
 
463
    def copy_in(self, source_path, target_path):
 
464
        """See `Backend`."""
 
465
        # pylxd's FilesManager doesn't support sending UID/GID/mode.
 
466
        container = self.client.containers.get(self.name)
 
467
        with open(source_path, "rb") as source_file:
 
468
            params = {"path": target_path}
 
469
            data = source_file.read()
 
470
            mode = stat.S_IMODE(os.fstat(source_file.fileno()).st_mode)
 
471
            headers = {
 
472
                "X-LXD-uid": 0,
 
473
                "X-LXD-gid": 0,
 
474
                "X-LXD-mode": "%#o" % mode,
 
475
                }
 
476
            try:
 
477
                container.api.files.post(
 
478
                    params=params, data=data, headers=headers)
 
479
            except LXDAPIException as e:
 
480
                raise LXDException(
 
481
                    "Failed to push %s:%s" % (self.name, target_path), e)
 
482
 
 
483
    def _get_file(self, container, *args, **kwargs):
 
484
        # pylxd < 2.1.1 tries to validate the response as JSON in streaming
 
485
        # mode and ends up running out of memory on large files.  Work
 
486
        # around this.
 
487
        response = container.api.files.session.get(
 
488
            container.api.files._api_endpoint, *args, **kwargs)
 
489
        if response.status_code != 200:
 
490
            raise LXDAPIException(response)
 
491
        return response
 
492
 
 
493
    def copy_out(self, source_path, target_path):
 
494
        # pylxd's FilesManager doesn't support streaming, which is important
 
495
        # since copied-out files may be large.
 
496
        # This ignores UID/GID/mode, but then so does "lxc file pull".
 
497
        container = self.client.containers.get(self.name)
 
498
        with open(target_path, "wb") as target_file:
 
499
            params = {"path": source_path}
 
500
            try:
 
501
                with closing(
 
502
                        self._get_file(
 
503
                            container, params=params,
 
504
                            stream=True)) as response:
 
505
                    for chunk in response.iter_content(chunk_size=65536):
 
506
                        target_file.write(chunk)
 
507
            except LXDAPIException as e:
 
508
                raise LXDException(
 
509
                    "Failed to pull %s:%s" % (self.name, source_path), e)
 
510
 
 
511
    def stop(self):
 
512
        """See `Backend`."""
 
513
        try:
 
514
            container = self.client.containers.get(self.name)
 
515
        except LXDAPIException:
 
516
            pass
 
517
        else:
 
518
            if container.status_code == LXD_RUNNING:
 
519
                container.stop(wait=True)
 
520
            container.delete(wait=True)
 
521
        self.stop_bridge()
 
522
 
 
523
    def remove_image(self):
 
524
        for image in self.client.images.all():
 
525
            if any(alias["name"] == self.alias for alias in image.aliases):
 
526
                image.delete(wait=True)
 
527
                return
 
528
 
 
529
    def remove(self):
 
530
        """See `Backend`."""
 
531
        self.remove_image()
 
532
        super(LXD, self).remove()