← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad-buildd/lxd-backend into lp:launchpad-buildd

 

Colin Watson has proposed merging lp:~cjwatson/launchpad-buildd/lxd-backend into lp:launchpad-buildd with lp:~cjwatson/launchpad-buildd/build-snap-operation as a prerequisite.

Commit message:
Add a LXD backend.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad-buildd/lxd-backend/+merge/328666

This would normally also require adding an extra choice to --backend in lpbuildd.target.operation:Operation.make_parser, but I already left lxd in that list of choices by mistake in an earlier branch in this series.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad-buildd/lxd-backend into lp:launchpad-buildd.
=== modified file 'debian/changelog'
--- debian/changelog	2017-08-07 14:35:18 +0000
+++ debian/changelog	2017-08-07 14:35:18 +0000
@@ -22,6 +22,7 @@
   * Rewrite scan-for-processes in Python, allowing it to have unit tests.
   * Convert buildlivefs to the new Operation framework and add unit tests.
   * Convert buildsnap to the new Operation framework and add unit tests.
+  * Add a LXD backend.
 
  -- Colin Watson <cjwatson@xxxxxxxxxx>  Tue, 25 Jul 2017 23:07:58 +0100
 

=== modified file 'debian/control'
--- debian/control	2017-08-07 14:35:18 +0000
+++ debian/control	2017-08-07 14:35:18 +0000
@@ -21,7 +21,7 @@
 Package: python-lpbuildd
 Section: python
 Architecture: all
-Depends: python, python-twisted-core, python-twisted-web, python-zope.interface, python-apt, python-debian (>= 0.1.23), apt-utils, ${misc:Depends}
+Depends: python, python-twisted-core, python-twisted-web, python-zope.interface, python-apt, python-debian (>= 0.1.23), python-netaddr, apt-utils, ${misc:Depends}
 Breaks: launchpad-buildd (<< 88)
 Replaces: launchpad-buildd (<< 88)
 Description: Python libraries for a Launchpad buildd slave

=== modified file 'lpbuildd/target/backend.py'
--- lpbuildd/target/backend.py	2017-08-07 14:35:18 +0000
+++ lpbuildd/target/backend.py	2017-08-07 14:35:18 +0000
@@ -27,6 +27,9 @@
         if name == "chroot":
             from lpbuildd.target.chroot import Chroot
             backend_factory = Chroot
+        elif name == "lxd":
+            from lpbuildd.target.lxd import LXD
+            backend_factory = LXD
         elif name == "fake":
             # Only for use in tests.
             from lpbuildd.tests.fakeslave import FakeBackend

=== added file 'lpbuildd/target/lxd.py'
--- lpbuildd/target/lxd.py	1970-01-01 00:00:00 +0000
+++ lpbuildd/target/lxd.py	2017-08-07 14:35:18 +0000
@@ -0,0 +1,384 @@
+# Copyright 2017 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import print_function
+
+__metaclass__ = type
+
+import io
+import json
+import os
+import shutil
+import stat
+import subprocess
+import tarfile
+import tempfile
+from textwrap import dedent
+import time
+
+import netaddr
+
+from lpbuildd.target.backend import (
+    Backend,
+    BackendException,
+    )
+from lpbuildd.util import (
+    set_personality,
+    shell_escape,
+    )
+
+
+class LXD(Backend):
+
+    # Architecture mapping
+    arches = {
+        "amd64": "x86_64",
+        "arm64": "aarch64",
+        "armhf": "armv7l",
+        "i386": "i686",
+        "powerpc": "ppc",
+        "ppc64el": "ppc64le",
+        "s390x": "s390x",
+        }
+
+    profile_name = "lpbuildd"
+    bridge_name = "lpbr0"
+    # XXX cjwatson 2017-08-07: Hardcoded for now to be in a range reserved
+    # for employee private networks in
+    # https://wiki.canonical.com/InformationInfrastructure/IS/Network, so it
+    # won't collide with any production networks.  This should be
+    # configurable.
+    ipv4_network = netaddr.IPNetwork("10.10.10.1/24")
+    run_dir = "/run/launchpad-buildd"
+
+    @property
+    def lxc_arch(self):
+        return self.arches[self.arch]
+
+    @property
+    def alias(self):
+        return "lp-%s-%s" % (self.series, self.arch)
+
+    @property
+    def name(self):
+        return self.alias
+
+    def profile_exists(self):
+        with open("/dev/null", "w") as devnull:
+            return subprocess.call(
+                ["sudo", "lxc", "profile", "show", self.profile_name],
+                stdout=devnull, stderr=devnull) == 0
+
+    def image_exists(self):
+        with open("/dev/null", "w") as devnull:
+            return subprocess.call(
+                ["sudo", "lxc", "image", "info", self.alias],
+                stdout=devnull, stderr=devnull) == 0
+
+    def container_exists(self):
+        with open("/dev/null", "w") as devnull:
+            return subprocess.call(
+                ["sudo", "lxc", "info", self.name],
+                stdout=devnull, stderr=devnull) == 0
+
+    def is_running(self):
+        try:
+            with open("/dev/null", "w") as devnull:
+                output = subprocess.check_output(
+                    ["sudo", "lxc", "info", self.name], stderr=devnull)
+            for line in output.splitlines():
+                if line.strip() == "Status: Running":
+                    return True
+            else:
+                return False
+        except Exception:
+            return False
+
+    def _convert(self, source_tarball, target_tarball):
+        creation_time = source_tarball.getmember("chroot-autobuild").mtime
+        metadata = {
+            "architecture": self.lxc_arch,
+            "creation_date": creation_time,
+            "properties": {
+                "os": "Ubuntu",
+                "series": self.series,
+                "architecture": self.arch,
+                "description": "Launchpad chroot for Ubuntu %s (%s)" % (
+                    self.series, self.arch),
+                },
+            }
+        # Encoding this as JSON is good enough, and saves pulling in a YAML
+        # library dependency.
+        metadata_yaml = json.dumps(
+            metadata, sort_keys=True, indent=4, separators=(",", ": "),
+            ensure_ascii=False).encode("UTF-8") + b"\n"
+        metadata_file = tarfile.TarInfo()
+        metadata_file.size = len(metadata_yaml)
+        metadata_file.name = "metadata.yaml"
+        target_tarball.addfile(metadata_file, io.BytesIO(metadata_yaml))
+
+        copy_from_host = {"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"}
+
+        for entry in source_tarball:
+            fileptr = None
+            try:
+                orig_name = entry.name.split("chroot-autobuild", 1)[-1]
+                entry.name = "rootfs" + orig_name
+
+                if entry.isfile():
+                    if orig_name in copy_from_host:
+                        target_tarball.add(
+                            os.path.realpath(orig_name), arcname=entry.name)
+                        continue
+                    elif orig_name == "/usr/local/sbin/policy-rc.d":
+                        new_bytes = dedent("""\
+                            #! /bin/sh
+                            while :; do
+                                case "$1" in
+                                    -*)     shift ;;
+                                    snapd)  exit 0 ;;
+                                    *)
+                                        echo "Not running services in chroot."
+                                        exit 101
+                                        ;;
+                                esac
+                            done
+                            """).encode("UTF-8")
+                        entry.size = len(new_bytes)
+                        fileptr = io.BytesIO(new_bytes)
+                    else:
+                        try:
+                            fileptr = source_tarball.extractfile(entry.name)
+                        except KeyError:
+                            pass
+                elif entry.islnk():
+                    # Update hardlinks to point to the right target
+                    entry.linkname = (
+                        "rootfs" +
+                        entry.linkname.split("chroot-autobuild", 1)[-1])
+
+                target_tarball.addfile(entry, fileobj=fileptr)
+            finally:
+                if fileptr is not None:
+                    fileptr.close()
+
+    def create(self, tarball_path):
+        """See `Backend`."""
+        if self.image_exists():
+            self.remove_image()
+
+        tempdir = tempfile.mkdtemp()
+        try:
+            target_path = os.path.join(tempdir, "lxd.tar.gz")
+            with tarfile.open(tarball_path, "r") as source_tarball:
+                with tarfile.open(target_path, "w:gz") as target_tarball:
+                    self._convert(source_tarball, target_tarball)
+
+            with open("/dev/null", "w") as devnull:
+                subprocess.check_call(
+                    ["sudo", "lxc", "image", "import", target_path,
+                     "--alias", self.alias], stdout=devnull)
+        finally:
+            shutil.rmtree(tempdir)
+
+    @property
+    def sys_dir(self):
+        return os.path.join("/sys/class/net", self.bridge_name)
+
+    @property
+    def dnsmasq_pid_file(self):
+        return os.path.join(self.run_dir, "dnsmasq.pid")
+
+    def iptables(self, args, check=True):
+        call = subprocess.check_call if check else subprocess.call
+        call(
+            ["sudo", "iptables", "-w"] + args +
+            ["-m", "comment", "--comment", "managed by launchpad-buildd"])
+
+    def start_bridge(self):
+        if not os.path.isdir(self.run_dir):
+            os.makedirs(self.run_dir)
+        subprocess.check_call(
+            ["sudo", "ip", "link", "add", "dev", self.bridge_name,
+             "type", "bridge"])
+        subprocess.check_call(
+            ["sudo", "ip", "addr", "add", str(self.ipv4_network),
+             "dev", self.bridge_name])
+        subprocess.check_call(
+            ["sudo", "ip", "link", "set", "dev", self.bridge_name, "up"])
+        subprocess.check_call(
+            ["sudo", "sh", "-c", "echo 1 >/proc/sys/net/ipv4/ip_forward"])
+        self.iptables(
+            ["-t", "nat", "-A", "POSTROUTING",
+             "-s", str(self.ipv4_network), "!", "-d", str(self.ipv4_network),
+             "-j", "MASQUERADE"])
+        for protocol in ("udp", "tcp"):
+            self.iptables(
+                ["-I", "INPUT", "-i", self.bridge_name,
+                 "-p", protocol, "--dport", "53", "-j", "ACCEPT"])
+        self.iptables(
+            ["-I", "FORWARD", "-i", self.bridge_name, "-j", "ACCEPT"])
+        self.iptables(
+            ["-I", "FORWARD", "-o", self.bridge_name, "-j", "ACCEPT"])
+        subprocess.check_call(
+            ["sudo", "/usr/sbin/dnsmasq", "-s", "lpbuildd", "-S", "/lpbuildd/",
+             "-u", "buildd", "--strict-order", "--bind-interfaces",
+             "--pid-file=%s" % self.dnsmasq_pid_file,
+             "--except-interface=lo", "--interface=%s" % self.bridge_name,
+             "--listen-address=%s" % str(self.ipv4_network.ip)])
+
+    def stop_bridge(self):
+        if not os.path.isdir(self.sys_dir):
+            return
+        subprocess.call(
+            ["sudo", "ip", "addr", "flush", "dev", self.bridge_name])
+        subprocess.call(
+            ["sudo", "ip", "link", "set", "dev", self.bridge_name, "down"])
+        for protocol in ("udp", "tcp"):
+            self.iptables(
+                ["-D", "INPUT", "-i", self.bridge_name,
+                 "-p", protocol, "--dport", "53", "-j", "ACCEPT"], check=False)
+        self.iptables(
+            ["-D", "FORWARD", "-i", self.bridge_name, "-j", "ACCEPT"],
+            check=False)
+        self.iptables(
+            ["-D", "FORWARD", "-o", self.bridge_name, "-j", "ACCEPT"],
+            check=False)
+        self.iptables(
+            ["-t", "nat", "-D", "POSTROUTING",
+             "-s", str(self.ipv4_network), "!", "-d", str(self.ipv4_network),
+             "-j", "MASQUERADE"], check=False)
+        if os.path.exists(self.dnsmasq_pid_file):
+            with open(self.dnsmasq_pid_file) as f:
+                try:
+                    dnsmasq_pid = int(f.read())
+                except Exception:
+                    pass
+                else:
+                    # dnsmasq is supposed to drop privileges, but kill it as
+                    # root just in case it fails to do so for some reason.
+                    subprocess.call(["sudo", "kill", "-9", str(dnsmasq_pid)])
+            os.unlink(self.dnsmasq_pid_file)
+        subprocess.call(["sudo", "ip", "link", "delete", self.bridge_name])
+
+    def start(self):
+        """See `Backend`."""
+        self.stop()
+
+        for addr in self.ipv4_network:
+            if addr not in (
+                    self.ipv4_network.network, self.ipv4_network.ip,
+                    self.ipv4_network.broadcast):
+                ipv4_address = netaddr.IPNetwork(
+                    (int(addr), self.ipv4_network.prefixlen))
+                break
+        else:
+            raise BackendException(
+                "%s has no usable IP addresses" % self.ipv4_network)
+
+        if self.profile_exists():
+            with open("/dev/null", "w") as devnull:
+                subprocess.check_call(
+                    ["sudo", "lxc", "profile", "delete", self.profile_name],
+                    stdout=devnull)
+        subprocess.check_call(
+            ["sudo", "lxc", "profile", "copy", "default", self.profile_name])
+        subprocess.check_call(
+            ["sudo", "lxc", "profile", "device", "set", self.profile_name,
+             "eth0", "parent", self.bridge_name])
+
+        def set_key(key, value):
+            subprocess.check_call(
+                ["sudo", "lxc", "profile", "set", self.profile_name,
+                 key, value])
+
+        set_key("security.privileged", "true")
+        set_key("raw.lxc", dedent("""\
+            lxc.aa_profile=unconfined
+            lxc.cgroup.devices.deny=
+            lxc.cgroup.devices.allow=
+            lxc.network.0.ipv4={ipv4_address}
+            lxc.network.0.ipv4.gateway={ipv4_gateway}
+            """.format(
+                ipv4_address=ipv4_address, ipv4_gateway=self.ipv4_network.ip)))
+
+        self.start_bridge()
+
+        subprocess.check_call(
+            ["sudo", "lxc", "init", "--ephemeral", "-p", self.profile_name,
+             self.alias, self.name])
+
+        for path in ("/etc/hosts", "/etc/hostname", "/etc/resolv.conf"):
+            self.copy_in(path, path)
+
+        # Start the container
+        with open("/dev/null", "w") as devnull:
+            subprocess.check_call(
+                ["sudo", "lxc", "start", self.name], stdout=devnull)
+
+        # Wait for container to start
+        timeout = 60
+        now = time.time()
+        while time.time() < now + timeout:
+            if self.is_running():
+                return
+            time.sleep(5)
+        if not self.is_running():
+            raise BackendException(
+                "Container failed to start within %d seconds" % timeout)
+
+    def run(self, args, env=None, input_text=None, get_output=False,
+            echo=False, **kwargs):
+        """See `Backend`."""
+        if env:
+            args = ["env"] + [
+                "%s=%s" % (key, shell_escape(value))
+                for key, value in env.items()] + args
+        if self.arch is not None:
+            args = set_personality(args, self.arch, series=self.series)
+        if echo:
+            print("Running in container: %s" % ' '.join(
+                shell_escape(arg) for arg in args))
+        cmd = ["sudo", "lxc", "exec", self.name, "--"] + args
+        if input_text is None and not get_output:
+            subprocess.check_call(cmd, **kwargs)
+        else:
+            if get_output:
+                kwargs["stdout"] = subprocess.PIPE
+            proc = subprocess.Popen(
+                cmd, stdin=subprocess.PIPE, universal_newlines=True, **kwargs)
+            output, _ = proc.communicate(input_text)
+            if proc.returncode:
+                raise subprocess.CalledProcessError(proc.returncode, cmd)
+            if get_output:
+                return output
+
+    def copy_in(self, source_path, target_path):
+        """See `Backend`."""
+        mode = stat.S_IMODE(os.stat(source_path).st_mode)
+        subprocess.check_call(
+            ["sudo", "lxc", "file", "push",
+             "--uid=0", "--gid=0", "--mode=%o" % mode,
+             source_path, self.name + target_path])
+
+    def copy_out(self, source_path, target_path):
+        subprocess.check_call(
+            ["sudo", "lxc", "file", "pull",
+             self.name + source_path, target_path])
+
+    def stop(self):
+        """See `Backend`."""
+        if self.is_running():
+            subprocess.check_call(["sudo", "lxc", "stop", self.name])
+        if self.container_exists():
+            subprocess.check_call(["sudo", "lxc", "delete", self.name])
+        self.stop_bridge()
+
+    def remove_image(self):
+        subprocess.check_call(["sudo", "lxc", "image", "delete", self.alias])
+
+    def remove(self):
+        """See `Backend`."""
+        if self.image_exists():
+            self.remove_image()
+        super(LXD, self).remove()

=== added file 'lpbuildd/target/tests/test_lxd.py'
--- lpbuildd/target/tests/test_lxd.py	1970-01-01 00:00:00 +0000
+++ lpbuildd/target/tests/test_lxd.py	2017-08-07 14:35:18 +0000
@@ -0,0 +1,467 @@
+# Copyright 2017 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import io
+import json
+import os.path
+import tarfile
+from textwrap import dedent
+
+from fixtures import (
+    EnvironmentVariable,
+    MonkeyPatch,
+    TempDir,
+    )
+from systemfixtures import (
+    FakeFilesystem,
+    FakeProcesses,
+    )
+from testtools import TestCase
+from testtools.matchers import (
+    DirContains,
+    EndsWith,
+    Equals,
+    FileContains,
+    HasPermissions,
+    MatchesDict,
+    MatchesListwise,
+    )
+
+from lpbuildd.target.lxd import LXD
+
+
+class TestLXD(TestCase):
+
+    def make_chroot_tarball(self, output_path):
+        source = self.useFixture(TempDir()).path
+        hello = os.path.join(source, "bin", "hello")
+        os.mkdir(os.path.dirname(hello))
+        with open(hello, "w") as f:
+            f.write("hello\n")
+            os.fchmod(f.fileno(), 0o755)
+        os.mkdir(os.path.join(source, "etc"))
+        for name in ("hosts", "hostname", "resolv.conf"):
+            with open(os.path.join(source, "etc", name), "w") as f:
+                f.write("%s\n" % name)
+        policy_rc_d = os.path.join(
+            source, "usr", "local", "sbin", "policy-rc.d")
+        os.makedirs(os.path.dirname(policy_rc_d))
+        with open(policy_rc_d, "w") as f:
+            f.write("original policy-rc.d\n")
+            os.fchmod(f.fileno(), 0o755)
+        with tarfile.open(output_path, "w:bz2") as tar:
+            tar.add(source, arcname="chroot-autobuild")
+
+    def make_fake_etc(self):
+        fs_fixture = self.useFixture(FakeFilesystem())
+        fs_fixture.add("/etc")
+        os.mkdir("/etc")
+        for name in ("hosts", "hostname", "resolv.conf"):
+            with open(os.path.join("/etc", name), "w") as f:
+                f.write("host %s\n" % name)
+        # systemfixtures doesn't patch this, but arguably should.
+        self.useFixture(MonkeyPatch("tarfile.bltn_open", open))
+
+    def test_convert(self):
+        tmp = self.useFixture(TempDir()).path
+        source_tarball_path = os.path.join(tmp, "source.tar.bz2")
+        target_tarball_path = os.path.join(tmp, "target.tar.gz")
+        self.make_chroot_tarball(source_tarball_path)
+        self.make_fake_etc()
+        with tarfile.open(source_tarball_path, "r") as source_tarball:
+            creation_time = source_tarball.getmember("chroot-autobuild").mtime
+            with tarfile.open(target_tarball_path, "w:gz") as target_tarball:
+                LXD("1", "xenial", "amd64")._convert(
+                    source_tarball, target_tarball)
+
+        target = os.path.join(tmp, "target")
+        with tarfile.open(target_tarball_path, "r") as target_tarball:
+            target_tarball.extractall(path=target)
+        self.assertThat(target, DirContains(["metadata.yaml", "rootfs"]))
+        with open(os.path.join(target, "metadata.yaml")) as metadata_file:
+            metadata = json.load(metadata_file)
+        self.assertThat(metadata, MatchesDict({
+            "architecture": Equals("x86_64"),
+            "creation_date": Equals(creation_time),
+            "properties": MatchesDict({
+                "os": Equals("Ubuntu"),
+                "series": Equals("xenial"),
+                "architecture": Equals("amd64"),
+                "description": Equals(
+                    "Launchpad chroot for Ubuntu xenial (amd64)"),
+                }),
+            }))
+        rootfs = os.path.join(target, "rootfs")
+        self.assertThat(rootfs, DirContains(["bin", "etc", "usr"]))
+        self.assertThat(os.path.join(rootfs, "bin"), DirContains(["hello"]))
+        hello = os.path.join(rootfs, "bin", "hello")
+        self.assertThat(hello, FileContains("hello\n"))
+        self.assertThat(hello, HasPermissions("0755"))
+        self.assertThat(
+            os.path.join(rootfs, "etc"),
+            DirContains(["hosts", "hostname", "resolv.conf"]))
+        for name in ("hosts", "hostname", "resolv.conf"):
+            self.assertThat(
+                os.path.join(rootfs, "etc", name),
+                FileContains("host %s\n" % name))
+        policy_rc_d = os.path.join(
+            rootfs, "usr", "local", "sbin", "policy-rc.d")
+        self.assertThat(
+            policy_rc_d,
+            FileContains(dedent("""\
+                #! /bin/sh
+                while :; do
+                    case "$1" in
+                        -*)     shift ;;
+                        snapd)  exit 0 ;;
+                        *)
+                            echo "Not running services in chroot."
+                            exit 101
+                            ;;
+                    esac
+                done
+                """)))
+        self.assertThat(policy_rc_d, HasPermissions("0755"))
+
+    def test_create(self):
+        tmp = self.useFixture(TempDir()).path
+        source_tarball_path = os.path.join(tmp, "source.tar.bz2")
+        self.make_chroot_tarball(source_tarball_path)
+        self.make_fake_etc()
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda proc_args: {
+                "returncode": 1 if "info" in proc_args["args"] else 0,
+                },
+            name="sudo")
+        LXD("1", "xenial", "amd64").create(source_tarball_path)
+
+        self.assertThat(
+            [proc._args["args"] for proc in processes_fixture.procs],
+            MatchesListwise([
+                Equals(["sudo", "lxc", "image", "info", "lp-xenial-amd64"]),
+                MatchesListwise([
+                    Equals("sudo"), Equals("lxc"), Equals("image"),
+                    Equals("import"), EndsWith("/lxd.tar.gz"),
+                    Equals("--alias"), Equals("lp-xenial-amd64"),
+                    ]),
+                ]))
+
+    def test_start(self):
+        class SudoLXC:
+            def __init__(self):
+                self.created = False
+                self.started = False
+
+            def __call__(self, proc_info):
+                ret = {}
+                if proc_info["args"][:4] == ["sudo", "lxc", "profile", "show"]:
+                    ret["returncode"] = 1
+                elif proc_info["args"][:3] == ["sudo", "lxc", "init"]:
+                    self.created = True
+                elif proc_info["args"][:3] == ["sudo", "lxc", "start"]:
+                    self.started = True
+                elif proc_info["args"][:3] == ["sudo", "lxc", "info"]:
+                    if not self.created:
+                        ret["returncode"] = 1
+                    else:
+                        status = "Running" if self.started else "Stopped"
+                        ret["stdout"] = io.BytesIO(
+                            ("Status: %s\n" % status).encode("UTF-8"))
+                return ret
+
+        fs_fixture = self.useFixture(FakeFilesystem())
+        fs_fixture.add("/sys")
+        fs_fixture.add("/run")
+        os.makedirs("/run/launchpad-buildd")
+        fs_fixture.add("/etc")
+        os.mkdir("/etc")
+        for name in ("hosts", "hostname", "resolv.conf"):
+            with open(os.path.join("/etc", name), "w") as f:
+                f.write("host %s\n" % name)
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(SudoLXC(), name="sudo")
+        LXD("1", "xenial", "amd64").start()
+
+        lxc = ["sudo", "lxc"]
+        raw_lxc = dedent("""\
+            lxc.aa_profile=unconfined
+            lxc.cgroup.devices.deny=
+            lxc.cgroup.devices.allow=
+            lxc.network.0.ipv4=10.10.10.2/24
+            lxc.network.0.ipv4.gateway=10.10.10.1
+            """)
+        ip = ["sudo", "ip"]
+        iptables = ["sudo", "iptables", "-w"]
+        iptables_comment = [
+            "-m", "comment", "--comment", "managed by launchpad-buildd"]
+        self.assertThat(
+            [proc._args["args"] for proc in processes_fixture.procs],
+            MatchesListwise([
+                Equals(lxc + ["info", "lp-xenial-amd64"]),
+                Equals(lxc + ["info", "lp-xenial-amd64"]),
+                Equals(lxc + ["profile", "show", "lpbuildd"]),
+                Equals(lxc + ["profile", "copy", "default", "lpbuildd"]),
+                Equals(lxc + ["profile", "device", "set", "lpbuildd", "eth0",
+                              "parent", "lpbr0"]),
+                Equals(lxc + ["profile", "set", "lpbuildd",
+                              "security.privileged", "true"]),
+                Equals(lxc + ["profile", "set", "lpbuildd",
+                              "raw.lxc", raw_lxc]),
+                Equals(ip + ["link", "add", "dev", "lpbr0", "type", "bridge"]),
+                Equals(ip + ["addr", "add", "10.10.10.1/24", "dev", "lpbr0"]),
+                Equals(ip + ["link", "set", "dev", "lpbr0", "up"]),
+                Equals(
+                    ["sudo", "sh", "-c",
+                     "echo 1 >/proc/sys/net/ipv4/ip_forward"]),
+                Equals(
+                    iptables +
+                    ["-t", "nat", "-A", "POSTROUTING",
+                     "-s", "10.10.10.1/24", "!", "-d", "10.10.10.1/24",
+                     "-j", "MASQUERADE"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-I", "INPUT", "-i", "lpbr0",
+                     "-p", "udp", "--dport", "53", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-I", "INPUT", "-i", "lpbr0",
+                     "-p", "tcp", "--dport", "53", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-I", "FORWARD", "-i", "lpbr0", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-I", "FORWARD", "-o", "lpbr0", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    ["sudo", "/usr/sbin/dnsmasq", "-s", "lpbuildd",
+                     "-S", "/lpbuildd/", "-u", "buildd", "--strict-order",
+                     "--bind-interfaces",
+                     "--pid-file=/run/launchpad-buildd/dnsmasq.pid",
+                     "--except-interface=lo", "--interface=lpbr0",
+                     "--listen-address=10.10.10.1"]),
+                Equals(lxc + ["init", "--ephemeral", "-p", "lpbuildd",
+                              "lp-xenial-amd64", "lp-xenial-amd64"]),
+                Equals(lxc + ["file", "push",
+                              "--uid=0", "--gid=0", "--mode=644",
+                              "/etc/hosts", "lp-xenial-amd64/etc/hosts"]),
+                Equals(lxc + ["file", "push",
+                              "--uid=0", "--gid=0", "--mode=644",
+                              "/etc/hostname",
+                              "lp-xenial-amd64/etc/hostname"]),
+                Equals(lxc + ["file", "push",
+                              "--uid=0", "--gid=0", "--mode=644",
+                              "/etc/resolv.conf",
+                              "lp-xenial-amd64/etc/resolv.conf"]),
+                Equals(lxc + ["start", "lp-xenial-amd64"]),
+                Equals(lxc + ["info", "lp-xenial-amd64"]),
+                ]))
+
+    def test_run(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {}, name="sudo")
+        LXD("1", "xenial", "amd64").run(
+            ["apt-get", "update"], env={"LANG": "C"})
+
+        expected_args = [
+            ["sudo", "lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "env", "LANG=C", "apt-get", "update"],
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_run_get_output(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.BytesIO(b"hello\n")}, name="sudo")
+        self.assertEqual(
+            "hello\n",
+            LXD("1", "xenial", "amd64").run(
+                ["echo", "hello"], get_output=True))
+
+        expected_args = [
+            ["sudo", "lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "echo", "hello"],
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_copy_in(self):
+        source_dir = self.useFixture(TempDir()).path
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {}, name="sudo")
+        source_path = os.path.join(source_dir, "source")
+        with open(source_path, "w"):
+            pass
+        os.chmod(source_path, 0o644)
+        target_path = "/path/to/target"
+        LXD("1", "xenial", "amd64").copy_in(source_path, target_path)
+
+        expected_args = [
+            ["sudo", "lxc", "file", "push", "--uid=0", "--gid=0", "--mode=644",
+             source_path, "lp-xenial-amd64" + target_path],
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_copy_out(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {}, name="sudo")
+        LXD("1", "xenial", "amd64").copy_out(
+            "/path/to/source", "/path/to/target")
+
+        expected_args = [
+            ["sudo", "lxc", "file", "pull",
+             "lp-xenial-amd64/path/to/source", "/path/to/target"],
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_path_exists(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([{}, {"returncode": 1}])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="sudo")
+        self.assertTrue(LXD("1", "xenial", "amd64").path_exists("/present"))
+        self.assertFalse(LXD("1", "xenial", "amd64").path_exists("/absent"))
+
+        expected_args = [
+            ["sudo", "lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "test", "-e", path]
+            for path in ("/present", "/absent")
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_islink(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        test_proc_infos = iter([{}, {"returncode": 1}])
+        processes_fixture.add(lambda _: next(test_proc_infos), name="sudo")
+        self.assertTrue(LXD("1", "xenial", "amd64").islink("/link"))
+        self.assertFalse(LXD("1", "xenial", "amd64").islink("/file"))
+
+        expected_args = [
+            ["sudo", "lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "test", "-h", path]
+            for path in ("/link", "/file")
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_listdir(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.BytesIO(b"foo\0bar\0baz\0")}, name="sudo")
+        self.assertEqual(
+            ["foo", "bar", "baz"],
+            LXD("1", "xenial", "amd64").listdir("/path"))
+
+        expected_args = [
+            ["sudo", "lxc", "exec", "lp-xenial-amd64", "--",
+             "linux64", "find", "/path", "-mindepth", "1", "-maxdepth", "1",
+             "-printf", "%P\\0"],
+            ]
+        self.assertEqual(
+            expected_args,
+            [proc._args["args"] for proc in processes_fixture.procs])
+
+    def test_stop(self):
+        class SudoLXC:
+            def __init__(self):
+                self.stopped = False
+                self.deleted = False
+
+            def __call__(self, proc_info):
+                ret = {}
+                if proc_info["args"][:3] == ["sudo", "lxc", "stop"]:
+                    self.stopped = True
+                elif proc_info["args"][:3] == ["sudo", "lxc", "delete"]:
+                    self.deleted = True
+                elif proc_info["args"][:3] == ["sudo", "lxc", "info"]:
+                    if self.deleted:
+                        ret["returncode"] = 1
+                    else:
+                        status = "Stopped" if self.stopped else "Running"
+                        ret["stdout"] = io.BytesIO(
+                            ("Status: %s\n" % status).encode("UTF-8"))
+                return ret
+
+        fs_fixture = self.useFixture(FakeFilesystem())
+        fs_fixture.add("/sys")
+        os.makedirs("/sys/class/net/lpbr0")
+        fs_fixture.add("/run")
+        os.makedirs("/run/launchpad-buildd")
+        with open("/run/launchpad-buildd/dnsmasq.pid", "w") as f:
+            f.write("42\n")
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(SudoLXC(), name="sudo")
+        LXD("1", "xenial", "amd64").stop()
+
+        lxc = ["sudo", "lxc"]
+        ip = ["sudo", "ip"]
+        iptables = ["sudo", "iptables", "-w"]
+        iptables_comment = [
+            "-m", "comment", "--comment", "managed by launchpad-buildd"]
+        self.assertThat(
+            [proc._args["args"] for proc in processes_fixture.procs],
+            MatchesListwise([
+                Equals(lxc + ["info", "lp-xenial-amd64"]),
+                Equals(lxc + ["stop", "lp-xenial-amd64"]),
+                Equals(lxc + ["info", "lp-xenial-amd64"]),
+                Equals(lxc + ["delete", "lp-xenial-amd64"]),
+                Equals(ip + ["addr", "flush", "dev", "lpbr0"]),
+                Equals(ip + ["link", "set", "dev", "lpbr0", "down"]),
+                Equals(
+                    iptables +
+                    ["-D", "INPUT", "-i", "lpbr0",
+                     "-p", "udp", "--dport", "53", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-D", "INPUT", "-i", "lpbr0",
+                     "-p", "tcp", "--dport", "53", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-D", "FORWARD", "-i", "lpbr0", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-D", "FORWARD", "-o", "lpbr0", "-j", "ACCEPT"] +
+                    iptables_comment),
+                Equals(
+                    iptables +
+                    ["-t", "nat", "-D", "POSTROUTING",
+                     "-s", "10.10.10.1/24", "!", "-d", "10.10.10.1/24",
+                     "-j", "MASQUERADE"] +
+                    iptables_comment),
+                Equals(["sudo", "kill", "-9", "42"]),
+                Equals(ip + ["link", "delete", "lpbr0"]),
+                ]))
+
+    def test_remove(self):
+        self.useFixture(EnvironmentVariable("HOME", "/expected/home"))
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {}, name="sudo")
+        LXD("1", "xenial", "amd64").remove()
+
+        lxc = ["sudo", "lxc"]
+        self.assertThat(
+            [proc._args["args"] for proc in processes_fixture.procs],
+            MatchesListwise([
+                Equals(lxc + ["image", "info", "lp-xenial-amd64"]),
+                Equals(lxc + ["image", "delete", "lp-xenial-amd64"]),
+                Equals(["sudo", "rm", "-rf", "/expected/home/build-1"]),
+                ]))