Merge lp:~cjwatson/launchpad-buildd/snapcraft into lp:launchpad-buildd

Proposed by Colin Watson on 2015-07-31
Status: Merged
Merged at revision: 166
Proposed branch: lp:~cjwatson/launchpad-buildd/snapcraft
Merge into: lp:launchpad-buildd
Diff against target: 573 lines (+434/-41)
9 files modified
Makefile (+1/-0)
buildd-slave.tac (+2/-0)
buildlivefs (+6/-40)
buildsnap (+157/-0)
debian/changelog (+1/-0)
debian/rules (+1/-1)
lpbuildd/snap.py (+95/-0)
lpbuildd/tests/test_snap.py (+121/-0)
lpbuildd/util.py (+50/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad-buildd/snapcraft
Reviewer Review Type Date Requested Status
William Grant code 2015-07-31 Approve on 2015-08-04
Review via email: mp+266541@code.launchpad.net

Commit Message

Add support for building snaps (LP: #1476405).

Description of the Change

Add support for building snaps.

I've successfully tested this end-to-end locally. Doing that requires fetching plainbox, ubuntu-snappy, and snapcraft from an assortment of locations (https://launchpad.net/~hardware-certification/+archive/ubuntu/public, the wily primary archive, and https://launchpad.net/~snappy-dev/+archive/ubuntu/snapcraft-daily respectively), building them in a local PPA, and then setting that PPA's reference as the value of config.snappy.tools_archive (once that code lands). For now it's quite a complicated procedure, but it does work; it'll be easier to run on production since the appropriate PPA already exists.

You may want to review this alongside https://code.launchpad.net/~cjwatson/launchpad/snap-build-behaviour/+merge/266736, which implements the master side of the builder protocol.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2015-06-15 16:48:51 +0000
3+++ Makefile 2015-07-31 11:57:12 +0000
4@@ -33,5 +33,6 @@
5 lpbuildd.tests.test_check_implicit_pointer_functions \
6 lpbuildd.tests.test_harness \
7 lpbuildd.tests.test_livefs \
8+ lpbuildd.tests.test_snap \
9 lpbuildd.tests.test_sourcepackagerecipe \
10 lpbuildd.tests.test_translationtemplatesbuildmanager
11
12=== modified file 'buildd-slave.tac'
13--- buildd-slave.tac 2015-05-11 14:37:01 +0000
14+++ buildd-slave.tac 2015-07-31 11:57:12 +0000
15@@ -23,6 +23,7 @@
16 from lpbuildd.livefs import LiveFilesystemBuildManager
17 from lpbuildd.log import RotatableFileLogObserver
18 from lpbuildd.slave import XMLRPCBuildDSlave
19+from lpbuildd.snap import SnapBuildManager
20 from lpbuildd.sourcepackagerecipe import SourcePackageRecipeBuildManager
21 from lpbuildd.translationtemplates import TranslationTemplatesBuildManager
22
23@@ -41,6 +42,7 @@
24 slave.registerBuilder(
25 TranslationTemplatesBuildManager, 'translation-templates')
26 slave.registerBuilder(LiveFilesystemBuildManager, "livefs")
27+slave.registerBuilder(SnapBuildManager, "snap")
28
29 application = service.Application('BuildDSlave')
30 application.addComponent(
31
32=== modified file 'buildlivefs'
33--- buildlivefs 2014-06-24 14:59:08 +0000
34+++ buildlivefs 2015-07-31 11:57:12 +0000
35@@ -8,11 +8,15 @@
36
37 from optparse import OptionParser
38 import os
39-import re
40 import subprocess
41 import sys
42 import traceback
43
44+from lpbuildd.util import (
45+ set_personality,
46+ shell_escape,
47+ )
48+
49
50 RETCODE_SUCCESS = 0
51 RETCODE_FAILURE_INSTALL = 200
52@@ -29,41 +33,6 @@
53 return os.path.join(os.environ["HOME"], "build-" + build_id, *extra)
54
55
56-non_meta_re = re.compile(r'^[a-zA-Z0-9+,./:=@_-]+$')
57-
58-def shell_escape(arg):
59- if non_meta_re.match(arg):
60- return arg
61- else:
62- return "'%s'" % arg.replace("'", "'\\''")
63-
64-
65-linux32_arches = [
66- "armel",
67- "armhf",
68- "hppa",
69- "i386",
70- "lpia",
71- "mips",
72- "mipsel",
73- "powerpc",
74- "s390",
75- "sparc",
76- ]
77-linux64_arches = [
78- "alpha",
79- "amd64",
80- "arm64",
81- "hppa64",
82- "ia64",
83- "ppc64",
84- "ppc64el",
85- "s390x",
86- "sparc64",
87- "x32",
88- ]
89-
90-
91 class LiveFSBuilder:
92 """Builds a live file system."""
93
94@@ -77,10 +46,7 @@
95
96 :param args: the command and arguments to run.
97 """
98- if self.options.arch in linux32_arches:
99- args = ["linux32"] + args
100- elif self.options.arch in linux64_arches:
101- args = ["linux64"] + args
102+ args = set_personality(self.options.arch, args)
103 if echo:
104 print "Running in chroot: %s" % ' '.join(
105 "'%s'" % arg for arg in args)
106
107=== added file 'buildsnap'
108--- buildsnap 1970-01-01 00:00:00 +0000
109+++ buildsnap 2015-07-31 11:57:12 +0000
110@@ -0,0 +1,157 @@
111+#! /usr/bin/python -u
112+# Copyright 2015 Canonical Ltd. This software is licensed under the
113+# GNU Affero General Public License version 3 (see the file LICENSE).
114+
115+"""A script that builds a snap."""
116+
117+from __future__ import print_function
118+
119+__metaclass__ = type
120+
121+from optparse import OptionParser
122+import os
123+import subprocess
124+import sys
125+import traceback
126+
127+from lpbuildd.util import (
128+ set_personality,
129+ shell_escape,
130+ )
131+
132+
133+RETCODE_SUCCESS = 0
134+RETCODE_FAILURE_INSTALL = 200
135+RETCODE_FAILURE_BUILD = 201
136+
137+
138+def get_build_path(build_id, *extra):
139+ """Generate a path within the build directory.
140+
141+ :param build_id: the build id to use.
142+ :param extra: the extra path segments within the build directory.
143+ :return: the generated path.
144+ """
145+ return os.path.join(os.environ["HOME"], "build-" + build_id, *extra)
146+
147+
148+class SnapBuilder:
149+ """Builds a snap."""
150+
151+ def __init__(self, options, name):
152+ self.options = options
153+ self.name = name
154+ self.chroot_path = get_build_path(
155+ self.options.build_id, 'chroot-autobuild')
156+ # Set to False for local testing if your chroot doesn't have an
157+ # appropriate certificate for your codehosting system.
158+ self.ssl_verify = True
159+
160+ def chroot(self, args, echo=False):
161+ """Run a command in the chroot.
162+
163+ :param args: the command and arguments to run.
164+ :param echo: if True, print the command before executing it.
165+ """
166+ args = set_personality(self.options.arch, args)
167+ if echo:
168+ print(
169+ "Running in chroot: %s" % ' '.join(
170+ "'%s'" % arg for arg in args))
171+ sys.stdout.flush()
172+ subprocess.check_call([
173+ "/usr/bin/sudo", "/usr/sbin/chroot", self.chroot_path] + args)
174+
175+ def run_build_command(self, args, path="/build", env=None, echo=False):
176+ """Run a build command in the chroot.
177+
178+ This is unpleasant because we need to run it in /build under sudo
179+ chroot, and there's no way to do this without either a helper
180+ program in the chroot or unpleasant quoting. We go for the
181+ unpleasant quoting.
182+
183+ :param args: the command and arguments to run.
184+ :param path: the working directory to use in the chroot.
185+ :param env: dictionary of additional environment variables to set.
186+ :param echo: if True, print the command before executing it.
187+ """
188+ args = [shell_escape(arg) for arg in args]
189+ path = shell_escape(path)
190+ if env:
191+ args = ["env"] + [
192+ "%s=%s" % (key, shell_escape(value))
193+ for key, value in env.items()] + args
194+ command = "cd %s && %s" % (path, " ".join(args))
195+ self.chroot(["/bin/sh", "-c", command], echo=echo)
196+
197+ def install(self):
198+ deps = ["snapcraft"]
199+ if self.options.branch is not None:
200+ deps.append("bzr")
201+ else:
202+ deps.append("git")
203+ # Fixed better by
204+ # https://code.launchpad.net/~cjwatson/snapcraft/depend-sudo/+merge/266533;
205+ # drop this when that has been merged.
206+ deps.append("sudo")
207+ self.chroot(["apt-get", "-y", "install"] + deps)
208+
209+ def build(self):
210+ if self.options.branch is not None:
211+ self.run_build_command([
212+ "bzr", "branch", self.options.branch, self.name])
213+ else:
214+ assert self.options.git_repository is not None
215+ assert self.options.git_path is not None
216+ if not self.ssl_verify:
217+ env = {"GIT_SSL_NO_VERIFY": "1"}
218+ else:
219+ env = None
220+ self.run_build_command([
221+ "git", "clone", "-b", self.options.git_path,
222+ self.options.git_repository, self.name],
223+ env=env)
224+ self.run_build_command(
225+ ["snapcraft", "assemble"], path=os.path.join("/build", self.name))
226+
227+
228+def main():
229+ parser = OptionParser("%prog [options] NAME")
230+ parser.add_option("--build-id", help="build identifier")
231+ parser.add_option(
232+ "--arch", metavar="ARCH", help="build for architecture ARCH")
233+ parser.add_option(
234+ "--branch", metavar="BRANCH", help="build from this Bazaar branch")
235+ parser.add_option(
236+ "--git-repository", metavar="REPOSITORY",
237+ help="build from this Git repository")
238+ parser.add_option(
239+ "--git-path", metavar="REF-PATH",
240+ help="build from this ref path in REPOSITORY")
241+ options, args = parser.parse_args()
242+ if (options.git_repository is None) != (options.git_path is None):
243+ parser.error(
244+ "must provide both --git-repository and --git-path or neither")
245+ if (options.branch is None) == (options.git_repository is None):
246+ parser.error(
247+ "must provide exactly one of --branch and --git-repository")
248+ if len(args) != 1:
249+ parser.error(
250+ "must provide a package name and no other positional arguments")
251+ [name] = args
252+ builder = SnapBuilder(options, name)
253+ try:
254+ builder.install()
255+ except Exception:
256+ traceback.print_exc()
257+ return RETCODE_FAILURE_INSTALL
258+ try:
259+ builder.build()
260+ except Exception:
261+ traceback.print_exc()
262+ return RETCODE_FAILURE_BUILD
263+ return RETCODE_SUCCESS
264+
265+
266+if __name__ == "__main__":
267+ sys.exit(main())
268
269=== modified file 'debian/changelog'
270--- debian/changelog 2015-07-23 12:23:33 +0000
271+++ debian/changelog 2015-07-31 11:57:12 +0000
272@@ -7,6 +7,7 @@
273 the situation rather than trusting just the definite information.
274 * Handle architecture restrictions, architecture qualifications, and
275 restriction formulas (build profiles) in build-dependencies.
276+ * Add support for building snaps (LP: #1476405).
277
278 -- Colin Watson <cjwatson@ubuntu.com> Thu, 16 Jul 2015 14:00:16 +0100
279
280
281=== modified file 'debian/rules'
282--- debian/rules 2015-07-05 12:43:54 +0000
283+++ debian/rules 2015-07-31 11:57:12 +0000
284@@ -19,7 +19,7 @@
285 slavebins = unpack-chroot mount-chroot update-debian-chroot sbuild-package \
286 scan-for-processes umount-chroot remove-build override-sources-list \
287 buildrecipe generate-translation-templates slave-prep buildlivefs \
288- sudo-wrapper
289+ sudo-wrapper buildsnap
290
291 BUILDDUID=65500
292 BUILDDGID=65500
293
294=== added file 'lpbuildd/snap.py'
295--- lpbuildd/snap.py 1970-01-01 00:00:00 +0000
296+++ lpbuildd/snap.py 2015-07-31 11:57:12 +0000
297@@ -0,0 +1,95 @@
298+# Copyright 2015 Canonical Ltd. This software is licensed under the
299+# GNU Affero General Public License version 3 (see the file LICENSE).
300+
301+__metaclass__ = type
302+
303+import os
304+import shutil
305+
306+from lpbuildd.debian import (
307+ DebianBuildManager,
308+ DebianBuildState,
309+ get_build_path,
310+ )
311+
312+
313+RETCODE_SUCCESS = 0
314+RETCODE_FAILURE_INSTALL = 200
315+RETCODE_FAILURE_BUILD = 201
316+
317+
318+class SnapBuildState(DebianBuildState):
319+ BUILD_SNAP = "BUILD_SNAP"
320+
321+
322+class SnapBuildManager(DebianBuildManager):
323+ """Build a snap."""
324+
325+ initial_build_state = SnapBuildState.BUILD_SNAP
326+
327+ def __init__(self, slave, buildid, **kwargs):
328+ super(SnapBuildManager, self).__init__(slave, buildid, **kwargs)
329+ self.build_snap_path = os.path.join(self._slavebin, "buildsnap")
330+
331+ def initiate(self, files, chroot, extra_args):
332+ """Initiate a build with a given set of files and chroot."""
333+ self.build_path = get_build_path(
334+ self.home, self._buildid, "chroot-autobuild", "build")
335+ if os.path.isdir(self.build_path):
336+ shutil.rmtree(self.build_path)
337+
338+ self.name = extra_args["name"]
339+ self.branch = extra_args.get("branch")
340+ self.git_repository = extra_args.get("git_repository")
341+ self.git_path = extra_args.get("git_path")
342+
343+ super(SnapBuildManager, self).initiate(files, chroot, extra_args)
344+
345+ def doRunBuild(self):
346+ """Run the process to build the snap."""
347+ args = [
348+ "buildsnap",
349+ "--build-id", self._buildid,
350+ "--arch", self.arch_tag,
351+ ]
352+ if self.branch is not None:
353+ args.extend(["--branch", self.branch])
354+ if self.git_repository is not None:
355+ args.extend(["--git-repository", self.git_repository])
356+ if self.git_path is not None:
357+ args.extend(["--git-path", self.git_path])
358+ args.append(self.name)
359+ self.runSubProcess(self.build_snap_path, args)
360+
361+ def iterate_BUILD_SNAP(self, retcode):
362+ """Finished building the snap."""
363+ if retcode == RETCODE_SUCCESS:
364+ self.gatherResults()
365+ print("Returning build status: OK")
366+ elif (retcode >= RETCODE_FAILURE_INSTALL and
367+ retcode <= RETCODE_FAILURE_BUILD):
368+ if not self.alreadyfailed:
369+ self._slave.buildFail()
370+ print("Returning build status: Build failed.")
371+ self.alreadyfailed = True
372+ else:
373+ if not self.alreadyfailed:
374+ self._slave.builderFail()
375+ print("Returning build status: Builder failed.")
376+ self.alreadyfailed = True
377+ self.doReapProcesses(self._state)
378+
379+ def iterateReap_BUILD_SNAP(self, retcode):
380+ """Finished reaping after building the snap."""
381+ self._state = DebianBuildState.UMOUNT
382+ self.doUnmounting()
383+
384+ def gatherResults(self):
385+ """Gather the results of the build and add them to the file cache."""
386+ output_path = os.path.join(self.build_path, self.name)
387+ if not os.path.exists(output_path):
388+ return
389+ for entry in sorted(os.listdir(output_path)):
390+ path = os.path.join(output_path, entry)
391+ if entry.endswith(".snap") and not os.path.islink(path):
392+ self._slave.addWaitingFile(path)
393
394=== added file 'lpbuildd/tests/test_snap.py'
395--- lpbuildd/tests/test_snap.py 1970-01-01 00:00:00 +0000
396+++ lpbuildd/tests/test_snap.py 2015-07-31 11:57:12 +0000
397@@ -0,0 +1,121 @@
398+# Copyright 2015 Canonical Ltd. This software is licensed under the
399+# GNU Affero General Public License version 3 (see the file LICENSE).
400+
401+__metaclass__ = type
402+
403+import os
404+import shutil
405+import tempfile
406+
407+from testtools import TestCase
408+
409+from lpbuildd.snap import (
410+ SnapBuildManager,
411+ SnapBuildState,
412+ )
413+from lpbuildd.tests.fakeslave import FakeSlave
414+
415+
416+class MockBuildManager(SnapBuildManager):
417+ def __init__(self, *args, **kwargs):
418+ super(MockBuildManager, self).__init__(*args, **kwargs)
419+ self.commands = []
420+ self.iterators = []
421+
422+ def runSubProcess(self, path, command, iterate=None):
423+ self.commands.append([path] + command)
424+ if iterate is None:
425+ iterate = self.iterate
426+ self.iterators.append(iterate)
427+ return 0
428+
429+
430+class TestSnapBuildManagerIteration(TestCase):
431+ """Run SnapBuildManager through its iteration steps."""
432+ def setUp(self):
433+ super(TestSnapBuildManagerIteration, self).setUp()
434+ self.working_dir = tempfile.mkdtemp()
435+ self.addCleanup(lambda: shutil.rmtree(self.working_dir))
436+ slave_dir = os.path.join(self.working_dir, "slave")
437+ home_dir = os.path.join(self.working_dir, "home")
438+ for dir in (slave_dir, home_dir):
439+ os.mkdir(dir)
440+ self.slave = FakeSlave(slave_dir)
441+ self.buildid = "123"
442+ self.buildmanager = MockBuildManager(self.slave, self.buildid)
443+ self.buildmanager.home = home_dir
444+ self.buildmanager._cachepath = self.slave._cachepath
445+ self.build_dir = os.path.join(
446+ home_dir, "build-%s" % self.buildid, "chroot-autobuild", "build")
447+
448+ def getState(self):
449+ """Retrieve build manager's state."""
450+ return self.buildmanager._state
451+
452+ def startBuild(self):
453+ # The build manager's iterate() kicks off the consecutive states
454+ # after INIT.
455+ extra_args = {
456+ "arch_tag": "i386",
457+ "name": "test-snap",
458+ "git_repository": "https://git.launchpad.dev/~example/+git/snap",
459+ "git_path": "master",
460+ }
461+ self.buildmanager.initiate({}, "chroot.tar.gz", extra_args)
462+
463+ # Skip states that are done in DebianBuildManager to the state
464+ # directly before BUILD_SNAP.
465+ self.buildmanager._state = SnapBuildState.UPDATE
466+
467+ # BUILD_SNAP: Run the slave's payload to build the snap package.
468+ self.buildmanager.iterate(0)
469+ self.assertEqual(SnapBuildState.BUILD_SNAP, self.getState())
470+ expected_command = [
471+ "sharepath/slavebin/buildsnap", "buildsnap",
472+ "--build-id", self.buildid, "--arch", "i386",
473+ "--git-repository", "https://git.launchpad.dev/~example/+git/snap",
474+ "--git-path", "master",
475+ "test-snap",
476+ ]
477+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
478+ self.assertEqual(
479+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
480+ self.assertFalse(self.slave.wasCalled("chrootFail"))
481+
482+ def test_iterate(self):
483+ # The build manager iterates a normal build from start to finish.
484+ self.startBuild()
485+
486+ log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
487+ log = open(log_path, "w")
488+ log.write("I am a build log.")
489+ log.close()
490+
491+ output_dir = os.path.join(self.build_dir, "test-snap")
492+ os.makedirs(output_dir)
493+ snap_path = os.path.join(output_dir, "test-snap_0_all.snap")
494+ with open(snap_path, "w") as snap:
495+ snap.write("I am a snap package.")
496+
497+ # After building the package, reap processes.
498+ self.buildmanager.iterate(0)
499+ expected_command = [
500+ "sharepath/slavebin/scan-for-processes", "scan-for-processes",
501+ self.buildid,
502+ ]
503+ self.assertEqual(SnapBuildState.BUILD_SNAP, self.getState())
504+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
505+ self.assertNotEqual(
506+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
507+ self.assertFalse(self.slave.wasCalled("buildFail"))
508+ self.assertEqual([((snap_path,), {})], self.slave.addWaitingFile.calls)
509+
510+ # Control returns to the DebianBuildManager in the UMOUNT state.
511+ self.buildmanager.iterateReap(self.getState(), 0)
512+ expected_command = [
513+ "sharepath/slavebin/umount-chroot", "umount-chroot", self.buildid]
514+ self.assertEqual(SnapBuildState.UMOUNT, self.getState())
515+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
516+ self.assertEqual(
517+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
518+ self.assertFalse(self.slave.wasCalled("buildFail"))
519
520=== added file 'lpbuildd/util.py'
521--- lpbuildd/util.py 1970-01-01 00:00:00 +0000
522+++ lpbuildd/util.py 2015-07-31 11:57:12 +0000
523@@ -0,0 +1,50 @@
524+# Copyright 2015 Canonical Ltd. This software is licensed under the
525+# GNU Affero General Public License version 3 (see the file LICENSE).
526+
527+__metaclass__ = type
528+
529+import re
530+
531+
532+non_meta_re = re.compile(r'^[a-zA-Z0-9+,./:=@_-]+$')
533+
534+def shell_escape(arg):
535+ if non_meta_re.match(arg):
536+ return arg
537+ else:
538+ return "'%s'" % arg.replace("'", "'\\''")
539+
540+
541+linux32_arches = [
542+ "armel",
543+ "armhf",
544+ "hppa",
545+ "i386",
546+ "lpia",
547+ "mips",
548+ "mipsel",
549+ "powerpc",
550+ "s390",
551+ "sparc",
552+ ]
553+linux64_arches = [
554+ "alpha",
555+ "amd64",
556+ "arm64",
557+ "hppa64",
558+ "ia64",
559+ "ppc64",
560+ "ppc64el",
561+ "s390x",
562+ "sparc64",
563+ "x32",
564+ ]
565+
566+
567+def set_personality(arch, args):
568+ if arch in linux32_arches:
569+ return ["linux32"] + args
570+ elif arch in linux64_arches:
571+ return ["linux64"] + args
572+ else:
573+ return args

Subscribers

People subscribed via source and target branches

to all changes: