1
# Copyright 2017 Canonical Ltd. This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
4
from __future__ import print_function
8
from contextlib import closing
17
from textwrap import dedent
22
from pylxd.exceptions import LXDAPIException
24
from lpbuildd.target.backend import (
28
from lpbuildd.util import (
37
fallback_hosts = dedent("""\
39
::1\tlocalhost ip6-localhost ip6-loopback
41
ff00::0\tip6-mcastprefix
43
ff02::2\tip6-allrouters
47
policy_rc_d = dedent("""\
52
systemd-udevd|systemd-udevd.service|udev|udev.service)
54
snapd|snapd.socket|snapd.service)
57
echo "Not running services in chroot."
65
class LXDException(Exception):
66
"""Wrap an LXDAPIException with some more useful information."""
68
def __init__(self, action, lxdapi_exc):
70
self.lxdapi_exc = lxdapi_exc
73
return "%s: %s" % (self.action, self.lxdapi_exc)
78
# Architecture mapping
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
96
ipv4_network = netaddr.IPNetwork("10.10.10.1/24")
97
run_dir = "/run/launchpad-buildd"
103
if self._client is None:
104
self._client = pylxd.Client()
109
return self.arches[self.arch]
113
return "lp-%s-%s" % (self.series, self.arch)
119
def is_running(self):
121
container = self.client.containers.get(self.name)
122
return container.status_code == LXD_RUNNING
123
except LXDAPIException:
126
def _convert(self, source_tarball, target_tarball):
127
creation_time = source_tarball.getmember("chroot-autobuild").mtime
129
"architecture": self.lxc_arch,
130
"creation_date": creation_time,
133
"series": self.series,
134
"architecture": self.arch,
135
"description": "Launchpad chroot for Ubuntu %s (%s)" % (
136
self.series, self.arch),
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))
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:
154
orig_name = entry.name.split("chroot-autobuild", 1)[-1]
155
entry.name = "rootfs" + orig_name
159
fileptr = source_tarball.extractfile(entry.name)
163
# Update hardlinks to point to the right target
166
entry.linkname.split("chroot-autobuild", 1)[-1])
168
target_tarball.addfile(entry, fileobj=fileptr)
170
if fileptr is not None:
173
def create(self, tarball_path):
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:
183
fileobj=target_file, mode="w") as target_tarball:
184
self._convert(source_tarball, target_tarball)
186
image = self.client.images.create(
187
target_file.getvalue(), wait=True)
188
image.add_alias(self.alias, self.alias)
192
return os.path.join("/sys/class/net", self.bridge_name)
195
def dnsmasq_pid_file(self):
196
return os.path.join(self.run_dir, "dnsmasq.pid")
198
def iptables(self, args, check=True):
199
call = subprocess.check_call if check else subprocess.call
201
["sudo", "iptables", "-w"] + args +
202
["-m", "comment", "--comment", "managed by launchpad-buildd"])
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,
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"])
218
["-t", "mangle", "-A", "FORWARD", "-i", self.bridge_name,
219
"-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
220
"-j", "TCPMSS", "--clamp-mss-to-pmtu"])
222
["-t", "nat", "-A", "POSTROUTING",
223
"-s", str(self.ipv4_network), "!", "-d", str(self.ipv4_network),
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)])
232
def stop_bridge(self):
233
if not os.path.isdir(self.sys_dir):
236
["sudo", "ip", "addr", "flush", "dev", self.bridge_name])
238
["sudo", "ip", "link", "set", "dev", self.bridge_name, "down"])
240
["-t", "mangle", "-D", "FORWARD", "-i", self.bridge_name,
241
"-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
242
"-j", "TCPMSS", "--clamp-mss-to-pmtu"])
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:
250
dnsmasq_pid = int(f.read())
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])
260
def create_profile(self):
261
for addr in self.ipv4_network:
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))
269
raise BackendException(
270
"%s has no usable IP addresses" % self.ipv4_network)
273
old_profile = self.client.profiles.get(self.profile_name)
274
except LXDAPIException:
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),
290
# Linux 4.4 on powerpc doesn't support all the seccomp bits that LXD
292
if self.arch == "powerpc":
293
raw_lxc_config.append(("lxc.seccomp", ""))
295
"security.privileged": "true",
296
"security.nesting": "true",
298
"{key}={value}\n".format(key=key, value=value)
299
for key, value in raw_lxc_config),
304
"nictype": "bridged",
305
"parent": self.bridge_name,
309
self.client.profiles.create(self.profile_name, config, devices)
315
self.create_profile()
318
container = self.client.containers.create({
320
"profiles": [self.profile_name],
321
"source": {"type": "image", "alias": self.alias},
324
with tempfile.NamedTemporaryFile(mode="w+b") as hosts_file:
326
self.copy_out("/etc/hosts", hosts_file.name)
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)
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
350
with tempfile.NamedTemporaryFile(mode="w+") as mounted_dev_file:
353
"/etc/init/mounted-dev.conf", mounted_dev_file.name)
357
mounted_dev_file.seek(0, os.SEEK_SET)
360
for line in mounted_dev_file:
363
r"^(\s*)(.*MAKEDEV)", r"\1: # \2", line)
364
if line.strip() == "end script":
366
elif line.strip() == "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)
376
mounted_dev_file.name,
377
"/etc/init/mounted-dev.override")
379
# Start the container and wait for it to start.
380
container.start(wait=True)
383
while time.time() < now + timeout:
385
container = self.client.containers.get(self.name)
386
except LXDAPIException:
389
if container.status_code == LXD_RUNNING:
392
if container is None or container.status_code != LXD_RUNNING:
393
raise BackendException(
394
"Container failed to start within %d seconds" % timeout)
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
401
["mknod", "-m", "0660", "/dev/loop-control", "c", "10", "237"])
402
for minor in range(8):
404
["mknod", "-m", "0660", "/dev/loop%d" % minor,
405
"b", "7", str(minor)])
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:
414
Environment=SNAPPY_STORE_NO_CDN=1
415
"""), file=no_cdn_file, end="")
417
os.fchmod(no_cdn_file.fileno(), 0o644)
420
"/etc/systemd/system/snapd.service.d/no-cdn.conf")
422
def run(self, args, cwd=None, env=None, input_text=None, get_output=False,
423
echo=False, **kwargs):
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)
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".
437
"/bin/sh", "-c", "cd %s && %s" % (
439
" ".join(shell_escape(arg) for arg in args)),
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)
451
kwargs["stdout"] = subprocess.PIPE
452
proc = subprocess.Popen(
453
cmd, stdin=subprocess.PIPE, universal_newlines=True, **kwargs)
454
output, _ = proc.communicate(input_text)
456
raise subprocess.CalledProcessError(proc.returncode, cmd)
463
def copy_in(self, source_path, target_path):
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)
474
"X-LXD-mode": "%#o" % mode,
477
container.api.files.post(
478
params=params, data=data, headers=headers)
479
except LXDAPIException as e:
481
"Failed to push %s:%s" % (self.name, target_path), e)
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
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)
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}
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:
509
"Failed to pull %s:%s" % (self.name, source_path), e)
514
container = self.client.containers.get(self.name)
515
except LXDAPIException:
518
if container.status_code == LXD_RUNNING:
519
container.stop(wait=True)
520
container.delete(wait=True)
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)
532
super(LXD, self).remove()