Merge ~twom/launchpad-buildd:add-charm-build into launchpad-buildd:master

Proposed by Tom Wardill
Status: Merged
Approved by: Tom Wardill
Approved revision: 5ab0ec8cab110953120da8e090273dbe1a37c783
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~twom/launchpad-buildd:add-charm-build
Merge into: launchpad-buildd:master
Diff against target: 1098 lines (+849/-46)
15 files modified
debian/changelog (+6/-0)
lpbuildd/buildd-slave.tac (+2/-0)
lpbuildd/charm.py (+94/-0)
lpbuildd/oci.py (+1/-1)
lpbuildd/target/backend.py (+13/-0)
lpbuildd/target/build_charm.py (+128/-0)
lpbuildd/target/build_oci.py (+4/-15)
lpbuildd/target/build_snap.py (+1/-28)
lpbuildd/target/cli.py (+2/-0)
lpbuildd/target/operation.py (+2/-0)
lpbuildd/target/tests/test_build_charm.py (+427/-0)
lpbuildd/target/tests/test_build_oci.py (+1/-1)
lpbuildd/target/vcs.py (+28/-0)
lpbuildd/tests/test_charm.py (+139/-0)
lpbuildd/tests/test_oci.py (+1/-1)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+403811@code.launchpad.net

Commit message

Add charm building

Description of the change

Add building for charms using charmcraft with local network support.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Tom Wardill (twom) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/debian/changelog b/debian/changelog
2index 26c21ed..6543629 100644
3--- a/debian/changelog
4+++ b/debian/changelog
5@@ -1,3 +1,9 @@
6+launchpad-buildd (197) UNRELEASED; urgency=medium
7+
8+ * Add charm building support
9+
10+ -- Tom Wardill <tom.wardill@canonical.com> Mon, 07 Jun 2021 10:22:50 +0100
11+
12 launchpad-buildd (196) bionic; urgency=medium
13
14 * Handle symlinks in OCI image files
15diff --git a/lpbuildd/buildd-slave.tac b/lpbuildd/buildd-slave.tac
16index 3299b0d..7c1947e 100644
17--- a/lpbuildd/buildd-slave.tac
18+++ b/lpbuildd/buildd-slave.tac
19@@ -23,6 +23,7 @@ from twisted.web import (
20
21 from lpbuildd.binarypackage import BinaryPackageBuildManager
22 from lpbuildd.builder import XMLRPCBuilder
23+from lpbuildd.charm import CharmBuildManager
24 from lpbuildd.oci import OCIBuildManager
25 from lpbuildd.livefs import LiveFilesystemBuildManager
26 from lpbuildd.log import RotatableFileLogObserver
27@@ -47,6 +48,7 @@ builder.registerManager(
28 builder.registerManager(LiveFilesystemBuildManager, "livefs")
29 builder.registerManager(SnapBuildManager, "snap")
30 builder.registerManager(OCIBuildManager, "oci")
31+builder.registerManager(CharmBuildManager, "charm")
32
33 application = service.Application('Builder')
34 application.addComponent(
35diff --git a/lpbuildd/charm.py b/lpbuildd/charm.py
36new file mode 100644
37index 0000000..e5c5fe9
38--- /dev/null
39+++ b/lpbuildd/charm.py
40@@ -0,0 +1,94 @@
41+# Copyright 2021 Canonical Ltd. This software is licensed under the
42+# GNU Affero General Public License version 3 (see the file LICENSE).
43+
44+from __future__ import print_function
45+
46+__metaclass__ = type
47+
48+import os
49+
50+from lpbuildd.debian import (
51+ DebianBuildState,
52+ DebianBuildManager,
53+ )
54+
55+
56+RETCODE_SUCCESS = 0
57+RETCODE_FAILURE_INSTALL = 200
58+RETCODE_FAILURE_BUILD = 201
59+
60+
61+class CharmBuildState(DebianBuildState):
62+ BUILD_CHARM = "BUILD_CHARM"
63+
64+
65+class CharmBuildManager(DebianBuildManager):
66+ """Build a charm."""
67+
68+ backend_name = "lxd"
69+ initial_build_state = CharmBuildState.BUILD_CHARM
70+
71+ @property
72+ def needs_sanitized_logs(self):
73+ return True
74+
75+ def initiate(self, files, chroot, extra_args):
76+ """Initiate a build with a given set of files and chroot."""
77+ self.name = extra_args["name"]
78+ self.branch = extra_args.get("branch")
79+ self.git_repository = extra_args.get("git_repository")
80+ self.git_path = extra_args.get("git_path")
81+ self.build_path = extra_args.get("build_path")
82+ self.channels = extra_args.get("channels", {})
83+
84+ super(CharmBuildManager, self).initiate(files, chroot, extra_args)
85+
86+ def doRunBuild(self):
87+ """Run the process to build the charm."""
88+ args = []
89+ for snap, channel in sorted(self.channels.items()):
90+ args.extend(["--channel", "%s=%s" % (snap, channel)])
91+ if self.branch is not None:
92+ args.extend(["--branch", self.branch])
93+ if self.git_repository is not None:
94+ args.extend(["--git-repository", self.git_repository])
95+ if self.git_path is not None:
96+ args.extend(["--git-path", self.git_path])
97+ if self.build_path is not None:
98+ args.extend(["--build-path", self.build_path])
99+ args.append(self.name)
100+ self.runTargetSubProcess("build-charm", *args)
101+
102+ def iterate_BUILD_CHARM(self, retcode):
103+ """Finished building the charm."""
104+ if retcode == RETCODE_SUCCESS:
105+ print("Returning build status: OK")
106+ return self.deferGatherResults()
107+ elif (retcode >= RETCODE_FAILURE_INSTALL and
108+ retcode <= RETCODE_FAILURE_BUILD):
109+ if not self.alreadyfailed:
110+ self._builder.buildFail()
111+ print("Returning build status: Build failed.")
112+ self.alreadyfailed = True
113+ else:
114+ if not self.alreadyfailed:
115+ self._builder.builderFail()
116+ print("Returning build status: Builder failed.")
117+ self.alreadyfailed = True
118+ self.doReapProcesses(self._state)
119+
120+ def iterateReap_BUILD_CHARM(self, retcode):
121+ """Finished reaping after building the charm."""
122+ self._state = DebianBuildState.UMOUNT
123+ self.doUnmounting()
124+
125+ def gatherResults(self):
126+ """Gather the results of the build and add them to the file cache."""
127+ output_path = os.path.join("/home/buildd", self.name)
128+ if self.backend.path_exists(output_path):
129+ for entry in sorted(self.backend.listdir(output_path)):
130+ path = os.path.join(output_path, entry)
131+ if self.backend.islink(path):
132+ continue
133+ if entry.endswith(".charm") or entry.endswith(".manifest"):
134+ self.addWaitingFileFromBackend(path)
135diff --git a/lpbuildd/oci.py b/lpbuildd/oci.py
136index 7a17939..53198c8 100644
137--- a/lpbuildd/oci.py
138+++ b/lpbuildd/oci.py
139@@ -59,7 +59,7 @@ class OCIBuildManager(SnapBuildProxyMixin, DebianBuildManager):
140 super(OCIBuildManager, self).initiate(files, chroot, extra_args)
141
142 def doRunBuild(self):
143- """Run the process to build the snap."""
144+ """Run the process to build the OCI image."""
145 args = []
146 args.extend(self.startProxy())
147 if self.revocation_endpoint:
148diff --git a/lpbuildd/target/backend.py b/lpbuildd/target/backend.py
149index 8356542..a7e778e 100644
150--- a/lpbuildd/target/backend.py
151+++ b/lpbuildd/target/backend.py
152@@ -13,6 +13,19 @@ class BackendException(Exception):
153 pass
154
155
156+class InvalidBuildFilePath(Exception):
157+ pass
158+
159+
160+def check_path_escape(buildd_path, path_to_check):
161+ """Check the build file path doesn't escape the build directory."""
162+ build_file_path = os.path.realpath(
163+ os.path.join(buildd_path, path_to_check))
164+ common_path = os.path.commonprefix((build_file_path, buildd_path))
165+ if common_path != buildd_path:
166+ raise InvalidBuildFilePath("Invalid build file path.")
167+
168+
169 class Backend:
170 """A backend implementation for the environment where we run builds."""
171
172diff --git a/lpbuildd/target/build_charm.py b/lpbuildd/target/build_charm.py
173new file mode 100644
174index 0000000..6bd29cc
175--- /dev/null
176+++ b/lpbuildd/target/build_charm.py
177@@ -0,0 +1,128 @@
178+# Copyright 2021 Canonical Ltd. This software is licensed under the
179+# GNU Affero General Public License version 3 (see the file LICENSE).
180+
181+from __future__ import print_function
182+import functools
183+
184+__metaclass__ = type
185+
186+from collections import OrderedDict
187+import logging
188+import os
189+import sys
190+
191+from lpbuildd.target.backend import check_path_escape
192+from lpbuildd.target.build_snap import SnapChannelsAction
193+from lpbuildd.target.operation import Operation
194+from lpbuildd.target.snapstore import SnapStoreOperationMixin
195+from lpbuildd.target.vcs import VCSOperationMixin
196+
197+
198+RETCODE_FAILURE_INSTALL = 200
199+RETCODE_FAILURE_BUILD = 201
200+
201+
202+logger = logging.getLogger(__name__)
203+
204+
205+class BuildCharm(VCSOperationMixin, SnapStoreOperationMixin, Operation):
206+
207+ description = "Build a charm."
208+
209+ core_snap_names = ["core", "core16", "core18", "core20"]
210+
211+ @classmethod
212+ def add_arguments(cls, parser):
213+ super(BuildCharm, cls).add_arguments(parser)
214+ parser.add_argument(
215+ "--channel", action=SnapChannelsAction, metavar="SNAP=CHANNEL",
216+ dest="channels", default={}, help=(
217+ "install SNAP from CHANNEL "
218+ "(supported snaps: {}, charmcraft)".format(
219+ ", ".join(cls.core_snap_names))))
220+ parser.add_argument(
221+ "--build-path", default=".",
222+ help="location of charm to build.")
223+ parser.add_argument("name", help="name of charm to build")
224+
225+ def __init__(self, args, parser):
226+ super(BuildCharm, self).__init__(args, parser)
227+ self.bin = os.path.dirname(sys.argv[0])
228+ self.buildd_path = os.path.join("/home/buildd", self.args.name)
229+
230+ def run_build_command(self, args, env=None, build_path=None, **kwargs):
231+ """Run a build command in the target.
232+
233+ :param args: the command and arguments to run.
234+ :param env: dictionary of additional environment variables to set.
235+ :param kwargs: any other keyword arguments to pass to Backend.run.
236+ """
237+ full_env = OrderedDict()
238+ full_env["LANG"] = "C.UTF-8"
239+ full_env["SHELL"] = "/bin/sh"
240+ if env:
241+ full_env.update(env)
242+ cwd = kwargs.pop('cwd', self.buildd_path)
243+ return self.backend.run(
244+ args, cwd=cwd, env=full_env, **kwargs)
245+
246+ def install(self):
247+ logger.info("Running install phase")
248+ deps = []
249+ if self.args.backend == "lxd":
250+ # udev is installed explicitly to work around
251+ # https://bugs.launchpad.net/snapd/+bug/1731519.
252+ for dep in "snapd", "fuse", "squashfuse", "udev":
253+ if self.backend.is_package_available(dep):
254+ deps.append(dep)
255+ deps.extend(self.vcs_deps)
256+ self.backend.run(["apt-get", "-y", "install"] + deps)
257+ if self.args.backend in ("lxd", "fake"):
258+ self.snap_store_set_proxy()
259+ for snap_name in self.core_snap_names:
260+ if snap_name in self.args.channels:
261+ self.backend.run(
262+ ["snap", "install",
263+ "--channel=%s" % self.args.channels[snap_name],
264+ snap_name])
265+ if "charmcraft" in self.args.channels:
266+ self.backend.run(
267+ ["snap", "install",
268+ "--channel=%s" % self.args.channels["charmcraft"],
269+ "charmcraft"])
270+ else:
271+ self.backend.run(["snap", "install", "charmcraft"])
272+ # The charmcraft snap can't see /build, so we have to do our work under
273+ # /home/buildd instead. Make sure it exists.
274+ self.backend.run(["mkdir", "-p", "/home/buildd"])
275+
276+ def repo(self):
277+ """Collect git or bzr branch."""
278+ logger.info("Running repo phase...")
279+ self.vcs_fetch(self.args.name, cwd="/home/buildd")
280+ self.save_status(self.buildd_path)
281+
282+ def build(self):
283+ logger.info("Running build phase...")
284+ build_context_path = os.path.join(
285+ "/home/buildd",
286+ self.args.name,
287+ self.args.build_path)
288+ check_path_escape(self.buildd_path, build_context_path)
289+ args = ["charmcraft", "build", "-f", build_context_path]
290+ self.run_build_command(args)
291+
292+ def run(self):
293+ try:
294+ self.install()
295+ except Exception:
296+ logger.exception('Install failed')
297+ return RETCODE_FAILURE_INSTALL
298+ try:
299+ self.repo()
300+ self.build()
301+ except Exception:
302+ logger.exception('Build failed')
303+ return RETCODE_FAILURE_BUILD
304+ return 0
305+
306diff --git a/lpbuildd/target/build_oci.py b/lpbuildd/target/build_oci.py
307index af56671..5615325 100644
308--- a/lpbuildd/target/build_oci.py
309+++ b/lpbuildd/target/build_oci.py
310@@ -12,6 +12,7 @@ import sys
311 import tempfile
312 from textwrap import dedent
313
314+from lpbuildd.target.backend import check_path_escape
315 from lpbuildd.target.operation import Operation
316 from lpbuildd.target.snapbuildproxy import SnapBuildProxyOperationMixin
317 from lpbuildd.target.snapstore import SnapStoreOperationMixin
318@@ -25,10 +26,6 @@ RETCODE_FAILURE_BUILD = 201
319 logger = logging.getLogger(__name__)
320
321
322-class InvalidBuildFilePath(Exception):
323- pass
324-
325-
326 class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
327 SnapStoreOperationMixin, Operation):
328
329@@ -47,7 +44,7 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
330 help="A docker build ARG in the format of key=value. "
331 "This option can be repeated many times. For example: "
332 "--build-arg VAR1=A --build-arg VAR2=B")
333- parser.add_argument("name", help="name of snap to build")
334+ parser.add_argument("name", help="name of image to build")
335
336 def __init__(self, args, parser):
337 super(BuildOCI, self).__init__(args, parser)
338@@ -72,14 +69,6 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
339 systemd_file.flush()
340 self.backend.copy_in(systemd_file.name, file_path)
341
342- def _check_path_escape(self, path_to_check):
343- """Check the build file path doesn't escape the build directory."""
344- build_file_path = os.path.realpath(
345- os.path.join(self.buildd_path, path_to_check))
346- common_path = os.path.commonprefix((build_file_path, self.buildd_path))
347- if common_path != self.buildd_path:
348- raise InvalidBuildFilePath("Invalid build file path.")
349-
350 def run_build_command(self, args, env=None, **kwargs):
351 """Run a build command in the target.
352
353@@ -130,7 +119,7 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
354 if self.args.build_file is not None:
355 build_file_path = os.path.join(
356 self.args.build_path, self.args.build_file)
357- self._check_path_escape(build_file_path)
358+ check_path_escape(self.buildd_path, build_file_path)
359 args.extend(["--file", build_file_path])
360
361 # Keep this at the end, so we give the user a chance to override any
362@@ -140,7 +129,7 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
363
364 build_context_path = os.path.join(
365 self.buildd_path, self.args.build_path)
366- self._check_path_escape(build_context_path)
367+ check_path_escape(self.buildd_path, build_context_path)
368 args.append(build_context_path)
369 self.run_build_command(args)
370
371diff --git a/lpbuildd/target/build_snap.py b/lpbuildd/target/build_snap.py
372index 50fab7a..8b08cfa 100644
373--- a/lpbuildd/target/build_snap.py
374+++ b/lpbuildd/target/build_snap.py
375@@ -99,17 +99,6 @@ class BuildSnap(SnapBuildProxyOperationMixin, VCSOperationMixin,
376 full_env.update(env)
377 return self.backend.run(args, env=full_env, **kwargs)
378
379- def save_status(self, status):
380- """Save a dictionary of status information about this build.
381-
382- This will be picked up by the build manager and included in XML-RPC
383- status responses.
384- """
385- status_path = os.path.join(self.backend.build_path, "status")
386- with open("%s.tmp" % status_path, "w") as status_file:
387- json.dump(status, status_file)
388- os.rename("%s.tmp" % status_path, status_path)
389-
390 def install_svn_servers(self):
391 proxy = urlparse(self.args.proxy_url)
392 svn_servers = dedent("""\
393@@ -173,23 +162,7 @@ class BuildSnap(SnapBuildProxyOperationMixin, VCSOperationMixin,
394 logger.info("Running repo phase...")
395 env = self.build_proxy_environment(proxy_url=self.args.proxy_url)
396 self.vcs_fetch(self.args.name, cwd="/build", env=env)
397- status = {}
398- if self.args.branch is not None:
399- status["revision_id"] = self.run_build_command(
400- ["bzr", "revno"],
401- cwd=os.path.join("/build", self.args.name),
402- get_output=True, universal_newlines=True).rstrip("\n")
403- else:
404- rev = (
405- self.args.git_path
406- if self.args.git_path is not None else "HEAD")
407- status["revision_id"] = self.run_build_command(
408- # The ^{} suffix copes with tags: we want to peel them
409- # recursively until we get an actual commit.
410- ["git", "rev-parse", rev + "^{}"],
411- cwd=os.path.join("/build", self.args.name),
412- get_output=True, universal_newlines=True).rstrip("\n")
413- self.save_status(status)
414+ self.save_status(os.path.join("/build", self.args.name))
415
416 @property
417 def image_info(self):
418diff --git a/lpbuildd/target/cli.py b/lpbuildd/target/cli.py
419index 94b291b..85e5b26 100644
420--- a/lpbuildd/target/cli.py
421+++ b/lpbuildd/target/cli.py
422@@ -14,6 +14,7 @@ from lpbuildd.target.apt import (
423 OverrideSourcesList,
424 Update,
425 )
426+from lpbuildd.target.build_charm import BuildCharm
427 from lpbuildd.target.build_oci import BuildOCI
428 from lpbuildd.target.build_livefs import BuildLiveFS
429 from lpbuildd.target.build_snap import BuildSnap
430@@ -51,6 +52,7 @@ def configure_logging():
431 operations = {
432 "add-trusted-keys": AddTrustedKeys,
433 "build-oci": BuildOCI,
434+ "build-charm": BuildCharm,
435 "buildlivefs": BuildLiveFS,
436 "buildsnap": BuildSnap,
437 "generate-translation-templates": GenerateTranslationTemplates,
438diff --git a/lpbuildd/target/operation.py b/lpbuildd/target/operation.py
439index 2f9fe64..7020786 100644
440--- a/lpbuildd/target/operation.py
441+++ b/lpbuildd/target/operation.py
442@@ -5,6 +5,8 @@ from __future__ import print_function
443
444 __metaclass__ = type
445
446+import os
447+
448 from lpbuildd.target.backend import make_backend
449
450
451diff --git a/lpbuildd/target/tests/test_build_charm.py b/lpbuildd/target/tests/test_build_charm.py
452new file mode 100644
453index 0000000..2900879
454--- /dev/null
455+++ b/lpbuildd/target/tests/test_build_charm.py
456@@ -0,0 +1,427 @@
457+# Copyright 2019 Canonical Ltd. This software is licensed under the
458+# GNU Affero General Public License version 3 (see the file LICENSE).
459+
460+__metaclass__ = type
461+
462+import json
463+import os
464+import subprocess
465+from textwrap import dedent
466+
467+from fixtures import (
468+ FakeLogger,
469+ TempDir,
470+ )
471+import responses
472+from testtools.matchers import (
473+ AnyMatch,
474+ Equals,
475+ Is,
476+ MatchesAll,
477+ MatchesDict,
478+ MatchesListwise,
479+ )
480+from testtools.testcase import TestCase
481+
482+from lpbuildd.target.backend import InvalidBuildFilePath
483+from lpbuildd.target.build_charm import (
484+ RETCODE_FAILURE_BUILD,
485+ RETCODE_FAILURE_INSTALL,
486+ )
487+from lpbuildd.tests.fakebuilder import FakeMethod
488+from lpbuildd.target.tests.test_build_snap import (
489+ FakeRevisionID,
490+ RanSnap,
491+ )
492+from lpbuildd.target.cli import parse_args
493+
494+
495+class RanCommand(MatchesListwise):
496+
497+ def __init__(self, args, echo=None, cwd=None, input_text=None,
498+ get_output=None, universal_newlines=None, **env):
499+ kwargs_matcher = {}
500+ if echo is not None:
501+ kwargs_matcher["echo"] = Is(echo)
502+ if cwd:
503+ kwargs_matcher["cwd"] = Equals(cwd)
504+ if input_text:
505+ kwargs_matcher["input_text"] = Equals(input_text)
506+ if get_output is not None:
507+ kwargs_matcher["get_output"] = Is(get_output)
508+ if universal_newlines is not None:
509+ kwargs_matcher["universal_newlines"] = Is(universal_newlines)
510+ if env:
511+ kwargs_matcher["env"] = MatchesDict(
512+ {key: Equals(value) for key, value in env.items()})
513+ super(RanCommand, self).__init__(
514+ [Equals((args,)), MatchesDict(kwargs_matcher)])
515+
516+
517+class RanAptGet(RanCommand):
518+
519+ def __init__(self, *args):
520+ super(RanAptGet, self).__init__(["apt-get", "-y"] + list(args))
521+
522+
523+class RanBuildCommand(RanCommand):
524+
525+ def __init__(self, args, **kwargs):
526+ kwargs.setdefault("LANG", "C.UTF-8")
527+ kwargs.setdefault("SHELL", "/bin/sh")
528+ super(RanBuildCommand, self).__init__(args, **kwargs)
529+
530+
531+class TestBuildCharm(TestCase):
532+
533+ def test_run_build_command_no_env(self):
534+ args = [
535+ "build-charm",
536+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
537+ "--branch", "lp:foo", "test-image",
538+ ]
539+ build_charm = parse_args(args=args).operation
540+ build_charm.run_build_command(["echo", "hello world"])
541+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
542+ RanBuildCommand(
543+ ["echo", "hello world"],
544+ cwd="/home/buildd/test-image"),
545+ ]))
546+
547+ def test_run_build_command_env(self):
548+ args = [
549+ "build-charm",
550+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
551+ "--branch", "lp:foo", "test-image",
552+ ]
553+ build_charm = parse_args(args=args).operation
554+ build_charm.run_build_command(
555+ ["echo", "hello world"], env={"FOO": "bar baz"})
556+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
557+ RanBuildCommand(
558+ ["echo", "hello world"],
559+ FOO="bar baz",
560+ cwd="/home/buildd/test-image")
561+ ]))
562+
563+ def test_install_channels(self):
564+ args = [
565+ "build-charm",
566+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
567+ "--channel=core=candidate", "--channel=core18=beta",
568+ "--channel=charmcraft=edge",
569+ "--branch", "lp:foo", "test-snap",
570+ ]
571+ build_snap = parse_args(args=args).operation
572+ build_snap.install()
573+ self.assertThat(build_snap.backend.run.calls, MatchesListwise([
574+ RanAptGet("install", "bzr"),
575+ RanSnap("install", "--channel=candidate", "core"),
576+ RanSnap("install", "--channel=beta", "core18"),
577+ RanSnap("install", "--channel=edge", "charmcraft"),
578+ RanCommand(["mkdir", "-p", "/home/buildd"]),
579+ ]))
580+
581+ def test_install_bzr(self):
582+ args = [
583+ "build-charm",
584+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
585+ "--branch", "lp:foo", "test-image"
586+ ]
587+ build_charm = parse_args(args=args).operation
588+ build_charm.install()
589+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
590+ RanAptGet("install", "bzr"),
591+ RanCommand(["snap", "install", "charmcraft"]),
592+ RanCommand(["mkdir", "-p", "/home/buildd"]),
593+ ]))
594+
595+ def test_install_git(self):
596+ args = [
597+ "build-charm",
598+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
599+ "--git-repository", "lp:foo", "test-image"
600+ ]
601+ build_charm = parse_args(args=args).operation
602+ build_charm.install()
603+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
604+ RanAptGet("install", "git"),
605+ RanCommand(["snap", "install", "charmcraft"]),
606+ RanCommand(["mkdir", "-p", "/home/buildd"]),
607+ ]))
608+
609+ @responses.activate
610+ def test_install_snap_store_proxy(self):
611+ store_assertion = dedent("""\
612+ type: store
613+ store: store-id
614+ url: http://snap-store-proxy.example
615+
616+ body
617+ """)
618+
619+ def respond(request):
620+ return 200, {"X-Assertion-Store-Id": "store-id"}, store_assertion
621+
622+ responses.add_callback(
623+ "GET", "http://snap-store-proxy.example/v2/auth/store/assertions",
624+ callback=respond)
625+ args = [
626+ "build-charm",
627+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
628+ "--git-repository", "lp:foo",
629+ "--snap-store-proxy-url", "http://snap-store-proxy.example/",
630+ "test-snap",
631+ ]
632+ build_snap = parse_args(args=args).operation
633+ build_snap.install()
634+ self.assertThat(build_snap.backend.run.calls, MatchesListwise([
635+ RanAptGet("install", "git"),
636+ RanCommand(
637+ ["snap", "ack", "/dev/stdin"], input_text=store_assertion),
638+ RanCommand(["snap", "set", "core", "proxy.store=store-id"]),
639+ RanSnap("install", "charmcraft"),
640+ RanCommand(["mkdir", "-p", "/home/buildd"]),
641+ ]))
642+
643+ def test_repo_bzr(self):
644+ args = [
645+ "build-charm",
646+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
647+ "--branch", "lp:foo", "test-image",
648+ ]
649+ build_charm = parse_args(args=args).operation
650+ build_charm.backend.build_path = self.useFixture(TempDir()).path
651+ build_charm.backend.run = FakeRevisionID("42")
652+ build_charm.repo()
653+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
654+ RanBuildCommand(
655+ ["bzr", "branch", "lp:foo", "test-image"], cwd="/home/buildd"),
656+ RanBuildCommand(
657+ ["bzr", "revno"],
658+ cwd="/home/buildd/test-image", get_output=True,
659+ universal_newlines=True),
660+ ]))
661+ status_path = os.path.join(build_charm.backend.build_path, "status")
662+ with open(status_path) as status:
663+ self.assertEqual({"revision_id": "42"}, json.load(status))
664+
665+ def test_repo_git(self):
666+ args = [
667+ "build-charm",
668+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
669+ "--git-repository", "lp:foo", "test-image",
670+ ]
671+ build_charm = parse_args(args=args).operation
672+ build_charm.backend.build_path = self.useFixture(TempDir()).path
673+ build_charm.backend.run = FakeRevisionID("0" * 40)
674+ build_charm.repo()
675+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
676+ RanBuildCommand(
677+ ["git", "clone", "lp:foo", "test-image"], cwd="/home/buildd"),
678+ RanBuildCommand(
679+ ["git", "submodule", "update", "--init", "--recursive"],
680+ cwd="/home/buildd/test-image"),
681+ RanBuildCommand(
682+ ["git", "rev-parse", "HEAD^{}"],
683+ cwd="/home/buildd/test-image",
684+ get_output=True, universal_newlines=True),
685+ ]))
686+ status_path = os.path.join(build_charm.backend.build_path, "status")
687+ with open(status_path) as status:
688+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
689+
690+ def test_repo_git_with_path(self):
691+ args = [
692+ "build-charm",
693+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
694+ "--git-repository", "lp:foo", "--git-path", "next", "test-image",
695+ ]
696+ build_charm = parse_args(args=args).operation
697+ build_charm.backend.build_path = self.useFixture(TempDir()).path
698+ build_charm.backend.run = FakeRevisionID("0" * 40)
699+ build_charm.repo()
700+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
701+ RanBuildCommand(
702+ ["git", "clone", "-b", "next", "lp:foo", "test-image"],
703+ cwd="/home/buildd"),
704+ RanBuildCommand(
705+ ["git", "submodule", "update", "--init", "--recursive"],
706+ cwd="/home/buildd/test-image"),
707+ RanBuildCommand(
708+ ["git", "rev-parse", "next^{}"],
709+ cwd="/home/buildd/test-image", get_output=True,
710+ universal_newlines=True),
711+ ]))
712+ status_path = os.path.join(build_charm.backend.build_path, "status")
713+ with open(status_path) as status:
714+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
715+
716+ def test_repo_git_with_tag_path(self):
717+ args = [
718+ "build-charm",
719+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
720+ "--git-repository", "lp:foo", "--git-path", "refs/tags/1.0",
721+ "test-image",
722+ ]
723+ build_charm = parse_args(args=args).operation
724+ build_charm.backend.build_path = self.useFixture(TempDir()).path
725+ build_charm.backend.run = FakeRevisionID("0" * 40)
726+ build_charm.repo()
727+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
728+ RanBuildCommand(
729+ ["git", "clone", "-b", "1.0", "lp:foo", "test-image"],
730+ cwd="/home/buildd"),
731+ RanBuildCommand(
732+ ["git", "submodule", "update", "--init", "--recursive"],
733+ cwd="/home/buildd/test-image"),
734+ RanBuildCommand(
735+ ["git", "rev-parse", "refs/tags/1.0^{}"],
736+ cwd="/home/buildd/test-image", get_output=True,
737+ universal_newlines=True),
738+ ]))
739+ status_path = os.path.join(build_charm.backend.build_path, "status")
740+ with open(status_path) as status:
741+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
742+
743+ def test_build(self):
744+ args = [
745+ "build-charm",
746+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
747+ "--branch", "lp:foo", "test-image",
748+ ]
749+ build_charm = parse_args(args=args).operation
750+ build_charm.backend.add_dir('/build/test-directory')
751+ build_charm.build()
752+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
753+ RanBuildCommand(
754+ ["charmcraft", "build", "-f",
755+ "/home/buildd/test-image/."],
756+ cwd="/home/buildd/test-image"),
757+ ]))
758+
759+ def test_build_with_path(self):
760+ args = [
761+ "build-charm",
762+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
763+ "--branch", "lp:foo", "--build-path", "build-aux/",
764+ "test-image",
765+ ]
766+ build_charm = parse_args(args=args).operation
767+ build_charm.backend.add_dir('/build/test-directory')
768+ build_charm.build()
769+ self.assertThat(build_charm.backend.run.calls, MatchesListwise([
770+ RanBuildCommand(
771+ ["charmcraft", "build", "-f",
772+ "/home/buildd/test-image/build-aux/"],
773+ cwd="/home/buildd/test-image"),
774+ ]))
775+
776+ def test_run_succeeds(self):
777+ args = [
778+ "build-charm",
779+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
780+ "--branch", "lp:foo", "test-image",
781+ ]
782+ build_charm = parse_args(args=args).operation
783+ build_charm.backend.build_path = self.useFixture(TempDir()).path
784+ build_charm.backend.run = FakeRevisionID("42")
785+ self.assertEqual(0, build_charm.run())
786+ self.assertThat(build_charm.backend.run.calls, MatchesAll(
787+ AnyMatch(RanAptGet("install", "bzr"),),
788+ AnyMatch(RanBuildCommand(
789+ ["bzr", "branch", "lp:foo", "test-image"],
790+ cwd="/home/buildd")),
791+ AnyMatch(RanBuildCommand(
792+ ["charmcraft", "build", "-f",
793+ "/home/buildd/test-image/."],
794+ cwd="/home/buildd/test-image")),
795+ ))
796+
797+ def test_run_install_fails(self):
798+ class FailInstall(FakeMethod):
799+ def __call__(self, run_args, *args, **kwargs):
800+ super(FailInstall, self).__call__(run_args, *args, **kwargs)
801+ if run_args[0] == "apt-get":
802+ raise subprocess.CalledProcessError(1, run_args)
803+
804+ self.useFixture(FakeLogger())
805+ args = [
806+ "build-charm",
807+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
808+ "--branch", "lp:foo", "test-image",
809+ ]
810+ build_charm = parse_args(args=args).operation
811+ build_charm.backend.run = FailInstall()
812+ self.assertEqual(RETCODE_FAILURE_INSTALL, build_charm.run())
813+
814+ def test_run_repo_fails(self):
815+ class FailRepo(FakeMethod):
816+ def __call__(self, run_args, *args, **kwargs):
817+ super(FailRepo, self).__call__(run_args, *args, **kwargs)
818+ if run_args[:2] == ["bzr", "branch"]:
819+ raise subprocess.CalledProcessError(1, run_args)
820+
821+ self.useFixture(FakeLogger())
822+ args = [
823+ "build-charm",
824+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
825+ "--branch", "lp:foo", "test-image",
826+ ]
827+ build_charm = parse_args(args=args).operation
828+ build_charm.backend.run = FailRepo()
829+ self.assertEqual(RETCODE_FAILURE_BUILD, build_charm.run())
830+
831+ def test_run_build_fails(self):
832+ class FailBuild(FakeMethod):
833+ def __call__(self, run_args, *args, **kwargs):
834+ super(FailBuild, self).__call__(run_args, *args, **kwargs)
835+ if run_args[0] == "charmcraft":
836+ raise subprocess.CalledProcessError(1, run_args)
837+
838+ self.useFixture(FakeLogger())
839+ args = [
840+ "build-charm",
841+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
842+ "--branch", "lp:foo", "test-image",
843+ ]
844+ build_charm = parse_args(args=args).operation
845+ build_charm.backend.build_path = self.useFixture(TempDir()).path
846+ build_charm.backend.run = FailBuild()
847+ self.assertEqual(RETCODE_FAILURE_BUILD, build_charm.run())
848+
849+ def test_build_with_invalid_build_path_parent(self):
850+ args = [
851+ "build-charm",
852+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
853+ "--branch", "lp:foo", "--build-path", "../",
854+ "test-image",
855+ ]
856+ build_charm = parse_args(args=args).operation
857+ build_charm.backend.add_dir('/build/test-directory')
858+ self.assertRaises(InvalidBuildFilePath, build_charm.build)
859+
860+ def test_build_with_invalid_build_path_absolute(self):
861+ args = [
862+ "build-charm",
863+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
864+ "--branch", "lp:foo", "--build-path", "/etc",
865+ "test-image",
866+ ]
867+ build_charm = parse_args(args=args).operation
868+ build_charm.backend.add_dir('/build/test-directory')
869+ self.assertRaises(InvalidBuildFilePath, build_charm.build)
870+
871+ def test_build_with_invalid_build_path_symlink(self):
872+ args = [
873+ "build-charm",
874+ "--backend=fake", "--series=xenial", "--arch=amd64", "1",
875+ "--branch", "lp:foo", "--build-path", "build/",
876+ "test-image",
877+ ]
878+ build_charm = parse_args(args=args).operation
879+ build_charm.buildd_path = self.useFixture(TempDir()).path
880+ os.symlink(
881+ '/etc/hosts',
882+ os.path.join(build_charm.buildd_path, 'build'))
883+ self.assertRaises(InvalidBuildFilePath, build_charm.build)
884diff --git a/lpbuildd/target/tests/test_build_oci.py b/lpbuildd/target/tests/test_build_oci.py
885index e58344f..3b9966a 100644
886--- a/lpbuildd/target/tests/test_build_oci.py
887+++ b/lpbuildd/target/tests/test_build_oci.py
888@@ -24,8 +24,8 @@ from testtools.matchers import (
889 MatchesListwise,
890 )
891
892+from lpbuildd.target.backend import InvalidBuildFilePath
893 from lpbuildd.target.build_oci import (
894- InvalidBuildFilePath,
895 RETCODE_FAILURE_BUILD,
896 RETCODE_FAILURE_INSTALL,
897 )
898diff --git a/lpbuildd/target/vcs.py b/lpbuildd/target/vcs.py
899index cb9875d..d7c80c3 100644
900--- a/lpbuildd/target/vcs.py
901+++ b/lpbuildd/target/vcs.py
902@@ -6,6 +6,7 @@ from __future__ import print_function
903 __metaclass__ = type
904
905 from collections import OrderedDict
906+import json
907 import logging
908 import os.path
909 import subprocess
910@@ -101,3 +102,30 @@ class VCSOperationMixin:
911 logger.error(
912 "'git submodule update --init --recursive failed with "
913 "exit code %s (build may fail later)" % e.returncode)
914+
915+ def save_status(self, cwd):
916+ """Save a dictionary of status information about this build.
917+
918+ This will be picked up by the build manager and included in XML-RPC
919+ status responses.
920+ """
921+ status = {}
922+ if self.args.branch is not None:
923+ status["revision_id"] = self.run_build_command(
924+ ["bzr", "revno"],
925+ cwd=cwd,
926+ get_output=True, universal_newlines=True).rstrip("\n")
927+ else:
928+ rev = (
929+ self.args.git_path
930+ if self.args.git_path is not None else "HEAD")
931+ status["revision_id"] = self.run_build_command(
932+ # The ^{} suffix copes with tags: we want to peel them
933+ # recursively until we get an actual commit.
934+ ["git", "rev-parse", rev + "^{}"],
935+ cwd=cwd,
936+ get_output=True, universal_newlines=True).rstrip("\n")
937+ status_path = os.path.join(self.backend.build_path, "status")
938+ with open("%s.tmp" % status_path, "w") as status_file:
939+ json.dump(status, status_file)
940+ os.rename("%s.tmp" % status_path, status_path)
941diff --git a/lpbuildd/tests/test_charm.py b/lpbuildd/tests/test_charm.py
942new file mode 100644
943index 0000000..407f732
944--- /dev/null
945+++ b/lpbuildd/tests/test_charm.py
946@@ -0,0 +1,139 @@
947+# Copyright 2021 Canonical Ltd. This software is licensed under the
948+# GNU Affero General Public License version 3 (see the file LICENSE).
949+
950+__metaclass__ = type
951+
952+import os
953+
954+from fixtures import (
955+ EnvironmentVariable,
956+ TempDir,
957+ )
958+from testtools import TestCase
959+from testtools.deferredruntest import AsynchronousDeferredRunTest
960+from twisted.internet import defer
961+
962+from lpbuildd.charm import CharmBuildManager, CharmBuildState
963+from lpbuildd.tests.fakebuilder import FakeBuilder
964+from lpbuildd.tests.matchers import HasWaitingFiles
965+
966+
967+class MockBuildManager(CharmBuildManager):
968+ def __init__(self, *args, **kwargs):
969+ super(MockBuildManager, self).__init__(*args, **kwargs)
970+ self.commands = []
971+ self.iterators = []
972+
973+ def runSubProcess(self, path, command, iterate=None, env=None):
974+ self.commands.append([path] + command)
975+ if iterate is None:
976+ iterate = self.iterate
977+ self.iterators.append(iterate)
978+ return 0
979+
980+
981+class TestCharmBuildManagerIteration(TestCase):
982+ """Run CharmBuildManager through its iteration steps."""
983+
984+ run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
985+
986+ def setUp(self):
987+ super(TestCharmBuildManagerIteration, self).setUp()
988+ self.working_dir = self.useFixture(TempDir()).path
989+ builder_dir = os.path.join(self.working_dir, "builder")
990+ home_dir = os.path.join(self.working_dir, "home")
991+ for dir in (builder_dir, home_dir):
992+ os.mkdir(dir)
993+ self.useFixture(EnvironmentVariable("HOME", home_dir))
994+ self.builder = FakeBuilder(builder_dir)
995+ self.buildid = "123"
996+ self.buildmanager = MockBuildManager(self.builder, self.buildid)
997+ self.buildmanager._cachepath = self.builder._cachepath
998+
999+ def getState(self):
1000+ """Retrieve build manager's state."""
1001+ return self.buildmanager._state
1002+
1003+ @defer.inlineCallbacks
1004+ def startBuild(self, args=None, options=None):
1005+ # The build manager's iterate() kicks off the consecutive states
1006+ # after INIT.
1007+ extra_args = {
1008+ "series": "xenial",
1009+ "arch_tag": "i386",
1010+ "name": "test-charm",
1011+ }
1012+ if args is not None:
1013+ extra_args.update(args)
1014+ original_backend_name = self.buildmanager.backend_name
1015+ self.buildmanager.backend_name = "fake"
1016+ self.buildmanager.initiate({}, "chroot.tar.gz", extra_args)
1017+ self.buildmanager.backend_name = original_backend_name
1018+
1019+ # Skip states that are done in DebianBuildManager to the state
1020+ # directly before BUILD_CHARM.
1021+ self.buildmanager._state = CharmBuildState.UPDATE
1022+
1023+ # BUILD_CHARM: Run the builder's payload to build the charm.
1024+ yield self.buildmanager.iterate(0)
1025+ self.assertEqual(CharmBuildState.BUILD_CHARM, self.getState())
1026+ expected_command = [
1027+ "sharepath/bin/in-target", "in-target", "build-charm",
1028+ "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
1029+ ]
1030+ if options is not None:
1031+ expected_command.extend(options)
1032+ expected_command.append("test-charm")
1033+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1034+ self.assertEqual(
1035+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1036+ self.assertFalse(self.builder.wasCalled("chrootFail"))
1037+
1038+ @defer.inlineCallbacks
1039+ def test_iterate(self):
1040+ # The build manager iterates a normal build from start to finish.
1041+ args = {
1042+ "git_repository": "https://git.launchpad.dev/~example/+git/charm",
1043+ "git_path": "master",
1044+ }
1045+ expected_options = [
1046+ "--git-repository", "https://git.launchpad.dev/~example/+git/charm",
1047+ "--git-path", "master",
1048+ ]
1049+ yield self.startBuild(args, expected_options)
1050+
1051+ log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
1052+ with open(log_path, "w") as log:
1053+ log.write("I am a build log.")
1054+
1055+ self.buildmanager.backend.add_file(
1056+ "/home/buildd/test-charm/test-charm_0_all.charm",
1057+ b"I am charming.")
1058+
1059+ # After building the package, reap processes.
1060+ yield self.buildmanager.iterate(0)
1061+ expected_command = [
1062+ "sharepath/bin/in-target", "in-target", "scan-for-processes",
1063+ "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
1064+ ]
1065+
1066+ self.assertEqual(CharmBuildState.BUILD_CHARM, self.getState())
1067+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1068+ self.assertNotEqual(
1069+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1070+ self.assertFalse(self.builder.wasCalled("buildFail"))
1071+ self.assertThat(self.builder, HasWaitingFiles.byEquality({
1072+ "test-charm_0_all.charm": b"I am charming.",
1073+ }))
1074+
1075+ # Control returns to the DebianBuildManager in the UMOUNT state.
1076+ self.buildmanager.iterateReap(self.getState(), 0)
1077+ expected_command = [
1078+ "sharepath/bin/in-target", "in-target", "umount-chroot",
1079+ "--backend=lxd", "--series=xenial", "--arch=i386", self.buildid,
1080+ ]
1081+ self.assertEqual(CharmBuildState.UMOUNT, self.getState())
1082+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1083+ self.assertEqual(
1084+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1085+ self.assertFalse(self.builder.wasCalled("buildFail"))
1086diff --git a/lpbuildd/tests/test_oci.py b/lpbuildd/tests/test_oci.py
1087index 78b0dbb..7fc0b3a 100644
1088--- a/lpbuildd/tests/test_oci.py
1089+++ b/lpbuildd/tests/test_oci.py
1090@@ -89,7 +89,7 @@ class TestOCIBuildManagerIteration(TestCase):
1091 # directly before BUILD_OCI.
1092 self.buildmanager._state = OCIBuildState.UPDATE
1093
1094- # BUILD_OCI: Run the builder's payload to build the snap package.
1095+ # BUILD_OCI: Run the builder's payload to build the OCI image.
1096 yield self.buildmanager.iterate(0)
1097 self.assertEqual(OCIBuildState.BUILD_OCI, self.getState())
1098 expected_command = [

Subscribers

People subscribed via source and target branches