Merge ~cjwatson/launchpad-buildd:lpcraft into launchpad-buildd:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 469953cec37e42e0fc0bf6c2ab00559955d2d555
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad-buildd:lpcraft
Merge into: launchpad-buildd:master
Diff against target: 1139 lines (+946/-17)
11 files modified
debian/changelog (+1/-0)
lpbuildd/buildd-slave.tac (+2/-0)
lpbuildd/builder.py (+14/-6)
lpbuildd/ci.py (+187/-0)
lpbuildd/debian.py (+7/-4)
lpbuildd/target/cli.py (+6/-0)
lpbuildd/target/run_ci.py (+123/-0)
lpbuildd/target/tests/matchers.py (+2/-2)
lpbuildd/target/tests/test_run_ci.py (+373/-0)
lpbuildd/tests/fakebuilder.py (+8/-5)
lpbuildd/tests/test_ci.py (+223/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+413751@code.launchpad.net

Commit message

Add CI job support

Description of the change

Running jobs is handled via lpcraft.

The main complexity here is that we need to be able to return the output of each individual lpcraft job separately, and so we need a novel mechanism for returning the status of parts of a build farm job. I built this on top of the extra status file mechanism, previously used to return VCS revision IDs. This also means that we need a slightly more complicated state machine than usual, since we have an initial preparation step followed by iterating over a "run job" state for each job. Most of the rest of this is fairly typical for new build types.

At present there's a fair amount of overhead, since we're using standard buildd containers and installing lpcraft in them, and then lpcraft itself creates containers and sets them up according to `.launchpad.yaml`. We should be able to optimize this later by providing pre-built containers.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

LGTM!

review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Tough one :-) Two comments, which don't need to be addressed, just wondering.

When trying to setup a dev environment to run the tests I got a:

```
$ lxc list
+---------------+---------+----------------------+-----------------------------------------------+-----------------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+---------------+---------+----------------------+-----------------------------------------------+-----------------+-----------+
| lp-builddev | RUNNING | | fd42:dbc9:151f:7e68:216:3eff:fe2d:e662 (eth0) | VIRTUAL-MACHINE | 0 |
+---------------+---------+----------------------+-----------------------------------------------+-----------------+-----------+
| lpdev | RUNNING | 10.67.244.209 (eth0) | fd42:dbc9:151f:7e68:216:3eff:fe8f:4b1d (eth0) | CONTAINER | 0 |
+---------------+---------+----------------------+-----------------------------------------------+-----------------+-----------+
| turnip-bionic | RUNNING | 10.67.244.191 (eth0) | fd42:dbc9:151f:7e68:216:3eff:fe56:ea7a (eth0) | CONTAINER | 0 |
+---------------+---------+----------------------+-----------------------------------------------+-----------------+-----------+
$ lxc shell lp-builddev
Error: Failed to connect to lxd-agent
```

Any clue what is going on?

Revision history for this message
Colin Watson (cjwatson) wrote :

You'd probably be best off asking LXD folks about your environment woes. It rings a faint bell somewhere but I don't remember the details - they'll probably be more on top of this sort of thing.

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
William Grant (wgrant) :
Revision history for this message
Colin Watson (cjwatson) :

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 cf63e39..5a758a6 100644
3--- a/debian/changelog
4+++ b/debian/changelog
5@@ -3,6 +3,7 @@ launchpad-buildd (206) UNRELEASED; urgency=medium
6 * Fix flake8 violations.
7 * Refactor extra status handling to be common to all build types.
8 * Fix handling of empty output in Backend.find.
9+ * Add CI job support.
10
11 -- Colin Watson <cjwatson@ubuntu.com> Wed, 08 Dec 2021 15:42:26 +0000
12
13diff --git a/lpbuildd/buildd-slave.tac b/lpbuildd/buildd-slave.tac
14index 7c1947e..24d60ef 100644
15--- a/lpbuildd/buildd-slave.tac
16+++ b/lpbuildd/buildd-slave.tac
17@@ -24,6 +24,7 @@ from twisted.web import (
18 from lpbuildd.binarypackage import BinaryPackageBuildManager
19 from lpbuildd.builder import XMLRPCBuilder
20 from lpbuildd.charm import CharmBuildManager
21+from lpbuildd.ci import CIBuildManager
22 from lpbuildd.oci import OCIBuildManager
23 from lpbuildd.livefs import LiveFilesystemBuildManager
24 from lpbuildd.log import RotatableFileLogObserver
25@@ -49,6 +50,7 @@ builder.registerManager(LiveFilesystemBuildManager, "livefs")
26 builder.registerManager(SnapBuildManager, "snap")
27 builder.registerManager(OCIBuildManager, "oci")
28 builder.registerManager(CharmBuildManager, "charm")
29+builder.registerManager(CIBuildManager, "ci")
30
31 application = service.Application('Builder')
32 application.addComponent(
33diff --git a/lpbuildd/builder.py b/lpbuildd/builder.py
34index 194f749..1257fce 100644
35--- a/lpbuildd/builder.py
36+++ b/lpbuildd/builder.py
37@@ -151,6 +151,8 @@ class BuildManager(object):
38 self.is_archive_private = False
39 self.home = os.environ['HOME']
40 self.abort_timeout = 120
41+ self.status_path = get_build_path(self.home, self._buildid, "status")
42+ self._final_extra_status = None
43
44 @property
45 def needs_sanitized_logs(self):
46@@ -214,6 +216,9 @@ class BuildManager(object):
47
48 def doCleanup(self):
49 """Remove the build tree etc."""
50+ # Fetch a final snapshot of manager-specific extra status.
51+ self._final_extra_status = self.status()
52+
53 if not self.fast_cleanup:
54 self.runTargetSubProcess("remove-build")
55
56@@ -276,9 +281,10 @@ class BuildManager(object):
57 This may be used to return manager-specific information from the
58 XML-RPC status call.
59 """
60- status_path = get_build_path(self.home, self._buildid, "status")
61+ if self._final_extra_status is not None:
62+ return self._final_extra_status
63 try:
64- with open(status_path) as status_file:
65+ with open(self.status_path) as status_file:
66 return json.load(status_file)
67 except IOError:
68 pass
69@@ -359,12 +365,12 @@ class BuildManager(object):
70 self._subprocess.ignore = True
71 self._subprocess.transport.loseConnection()
72
73- def addWaitingFileFromBackend(self, path):
74+ def addWaitingFileFromBackend(self, path, name=None):
75 fetched_dir = tempfile.mkdtemp()
76 try:
77 fetched_path = os.path.join(fetched_dir, os.path.basename(path))
78 self.backend.copy_out(path, fetched_path)
79- self._builder.addWaitingFile(fetched_path)
80+ self._builder.addWaitingFile(fetched_path, name=name)
81 finally:
82 shutil.rmtree(fetched_dir)
83
84@@ -501,9 +507,11 @@ class Builder(object):
85 os.rename(tmppath, self.cachePath(sha1sum))
86 return sha1sum
87
88- def addWaitingFile(self, path):
89+ def addWaitingFile(self, path, name=None):
90 """Add a file to the cache and store its details for reporting."""
91- self.waitingfiles[os.path.basename(path)] = self.storeFile(path)
92+ if name is None:
93+ name = os.path.basename(path)
94+ self.waitingfiles[name] = self.storeFile(path)
95
96 def abort(self):
97 """Abort the current build."""
98diff --git a/lpbuildd/ci.py b/lpbuildd/ci.py
99new file mode 100644
100index 0000000..9c70451
101--- /dev/null
102+++ b/lpbuildd/ci.py
103@@ -0,0 +1,187 @@
104+# Copyright 2022 Canonical Ltd. This software is licensed under the
105+# GNU Affero General Public License version 3 (see the file LICENSE).
106+
107+from __future__ import print_function
108+
109+__metaclass__ = type
110+
111+import os
112+
113+from six.moves.configparser import (
114+ NoOptionError,
115+ NoSectionError,
116+ )
117+from twisted.internet import defer
118+
119+from lpbuildd.debian import (
120+ DebianBuildManager,
121+ DebianBuildState,
122+ )
123+from lpbuildd.proxy import BuildManagerProxyMixin
124+
125+
126+RETCODE_SUCCESS = 0
127+RETCODE_FAILURE_INSTALL = 200
128+RETCODE_FAILURE_BUILD = 201
129+
130+
131+class CIBuildState(DebianBuildState):
132+ PREPARE = "PREPARE"
133+ RUN_JOB = "RUN_JOB"
134+
135+
136+class CIBuildManager(BuildManagerProxyMixin, DebianBuildManager):
137+ """Run CI jobs."""
138+
139+ backend_name = "lxd"
140+ initial_build_state = CIBuildState.PREPARE
141+
142+ @property
143+ def needs_sanitized_logs(self):
144+ return True
145+
146+ def initiate(self, files, chroot, extra_args):
147+ """Initiate a build with a given set of files and chroot."""
148+ self.jobs = extra_args["jobs"]
149+ if not self.jobs:
150+ raise ValueError("Must request at least one job")
151+ self.branch = extra_args.get("branch")
152+ self.git_repository = extra_args.get("git_repository")
153+ self.git_path = extra_args.get("git_path")
154+ self.channels = extra_args.get("channels", {})
155+ self.proxy_url = extra_args.get("proxy_url")
156+ self.revocation_endpoint = extra_args.get("revocation_endpoint")
157+ self.proxy_service = None
158+ self.job_status = {}
159+
160+ super(CIBuildManager, self).initiate(files, chroot, extra_args)
161+
162+ def doRunBuild(self):
163+ """Start running CI jobs."""
164+ self.proxy_args = self.startProxy()
165+ if self.revocation_endpoint:
166+ self.proxy_args.extend(
167+ ["--revocation-endpoint", self.revocation_endpoint])
168+ args = list(self.proxy_args)
169+ for snap, channel in sorted(self.channels.items()):
170+ args.extend(["--channel", "%s=%s" % (snap, channel)])
171+ if self.branch is not None:
172+ args.extend(["--branch", self.branch])
173+ if self.git_repository is not None:
174+ args.extend(["--git-repository", self.git_repository])
175+ if self.git_path is not None:
176+ args.extend(["--git-path", self.git_path])
177+ try:
178+ snap_store_proxy_url = self._builder._config.get(
179+ "proxy", "snapstore")
180+ args.extend(["--snap-store-proxy-url", snap_store_proxy_url])
181+ except (NoSectionError, NoOptionError):
182+ pass
183+ self.runTargetSubProcess("run-ci-prepare", *args)
184+
185+ def iterate_PREPARE(self, retcode):
186+ """Finished preparing for running CI jobs."""
187+ self.remaining_jobs = list(self.jobs)
188+ if retcode == RETCODE_SUCCESS:
189+ pass
190+ elif (retcode >= RETCODE_FAILURE_INSTALL and
191+ retcode <= RETCODE_FAILURE_BUILD):
192+ if not self.alreadyfailed:
193+ self._builder.log("Preparation failed.")
194+ self._builder.buildFail()
195+ self.alreadyfailed = True
196+ else:
197+ if not self.alreadyfailed:
198+ self._builder.builderFail()
199+ self.alreadyfailed = True
200+ if self.remaining_jobs and not self.alreadyfailed:
201+ self._state = CIBuildState.RUN_JOB
202+ self.runNextJob()
203+ else:
204+ self.stopProxy()
205+ self.revokeProxyToken()
206+ self.doReapProcesses(self._state)
207+
208+ def iterateReap_PREPARE(self, retcode):
209+ """Finished reaping after preparing for running CI jobs.
210+
211+ This only happens if preparation failed or there were no jobs to run.
212+ """
213+ self._state = DebianBuildState.UMOUNT
214+ self.doUnmounting()
215+
216+ def runNextJob(self):
217+ """Run the next CI job."""
218+ args = list(self.proxy_args)
219+ job_name, job_index = self.remaining_jobs.pop(0)
220+ self.current_job_id = "%s:%s" % (job_name, job_index)
221+ args.extend([job_name, str(job_index)])
222+ self.runTargetSubProcess("run-ci", *args)
223+
224+ @defer.inlineCallbacks
225+ def iterate_RUN_JOB(self, retcode):
226+ """Finished running a CI job.
227+
228+ This state is repeated for each CI job in the pipeline.
229+ """
230+ if retcode == RETCODE_SUCCESS:
231+ pass
232+ elif (retcode >= RETCODE_FAILURE_INSTALL and
233+ retcode <= RETCODE_FAILURE_BUILD):
234+ if not self.alreadyfailed:
235+ self._builder.log("Job %s failed." % self.current_job_id)
236+ self._builder.buildFail()
237+ self.alreadyfailed = True
238+ else:
239+ if not self.alreadyfailed:
240+ self._builder.builderFail()
241+ self.alreadyfailed = True
242+ yield self.deferGatherResults(reap=False)
243+ if self.remaining_jobs and not self.alreadyfailed:
244+ self.runNextJob()
245+ else:
246+ self.stopProxy()
247+ self.revokeProxyToken()
248+ self.doReapProcesses(self._state)
249+
250+ def iterateReap_RUN_JOB(self, retcode):
251+ """Finished reaping after running a CI job.
252+
253+ This only happens if the job failed or there were no more jobs to run.
254+ """
255+ self.iterateReap_PREPARE(retcode)
256+
257+ def status(self):
258+ """See `BuildManager.status`."""
259+ status = super(CIBuildManager, self).status()
260+ status["jobs"] = dict(self.job_status)
261+ return status
262+
263+ def gatherResults(self):
264+ """Gather the results of the CI job that just completed.
265+
266+ This is called once for each CI job in the pipeline.
267+ """
268+ job_status = {}
269+ output_path = os.path.join("/build", "output", self.current_job_id)
270+ log_path = "%s.log" % output_path
271+ if self.backend.path_exists(log_path):
272+ log_name = "%s.log" % self.current_job_id
273+ self.addWaitingFileFromBackend(log_path, log_name)
274+ job_status["log"] = self._builder.waitingfiles[log_name]
275+ if self.backend.path_exists(output_path):
276+ for entry in sorted(self.backend.find(
277+ output_path, include_directories=False)):
278+ path = os.path.join(output_path, entry)
279+ if self.backend.islink(path):
280+ continue
281+ entry_base = os.path.basename(entry)
282+ name = os.path.join(self.current_job_id, entry_base)
283+ self.addWaitingFileFromBackend(path, name=name)
284+ job_status.setdefault("output", {})[entry_base] = (
285+ self._builder.waitingfiles[name])
286+
287+ # Save a file map for this job in the extra status file. This
288+ # allows buildd-manager to fetch job logs/output incrementally
289+ # rather than having to wait for the entire CI job to finish.
290+ self.job_status[self.current_job_id] = job_status
291diff --git a/lpbuildd/debian.py b/lpbuildd/debian.py
292index a3ac2ef..da37eb1 100644
293--- a/lpbuildd/debian.py
294+++ b/lpbuildd/debian.py
295@@ -128,7 +128,7 @@ class DebianBuildManager(BuildManager):
296 self._builder.addWaitingFile(
297 get_build_path(self.home, self._buildid, fn))
298
299- def deferGatherResults(self):
300+ def deferGatherResults(self, reap=True):
301 """Gather the results of the build in a thread."""
302 # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once we're
303 # on Twisted >= 18.7.0 (https://twistedmatrix.com/trac/ticket/4632).
304@@ -143,11 +143,14 @@ class DebianBuildManager(BuildManager):
305 self._builder.buildFail()
306 self.alreadyfailed = True
307
308- def reap(ignored):
309+ def reap_processes(ignored):
310 self.doReapProcesses(self._state)
311
312- return threads.deferToThread(self.gatherResults).addErrback(
313- failed_to_gather).addCallback(reap)
314+ d = threads.deferToThread(self.gatherResults).addErrback(
315+ failed_to_gather)
316+ if reap:
317+ d.addCallback(reap_processes)
318+ return d
319
320 @defer.inlineCallbacks
321 def iterate(self, success, quiet=False):
322diff --git a/lpbuildd/target/cli.py b/lpbuildd/target/cli.py
323index 85e5b26..e114f35 100644
324--- a/lpbuildd/target/cli.py
325+++ b/lpbuildd/target/cli.py
326@@ -28,6 +28,10 @@ from lpbuildd.target.lifecycle import (
327 Start,
328 Stop,
329 )
330+from lpbuildd.target.run_ci import (
331+ RunCI,
332+ RunCIPrepare,
333+ )
334
335
336 def configure_logging():
337@@ -59,6 +63,8 @@ operations = {
338 "override-sources-list": OverrideSourcesList,
339 "mount-chroot": Start,
340 "remove-build": Remove,
341+ "run-ci": RunCI,
342+ "run-ci-prepare": RunCIPrepare,
343 "scan-for-processes": KillProcesses,
344 "umount-chroot": Stop,
345 "unpack-chroot": Create,
346diff --git a/lpbuildd/target/run_ci.py b/lpbuildd/target/run_ci.py
347new file mode 100644
348index 0000000..e74cbc7
349--- /dev/null
350+++ b/lpbuildd/target/run_ci.py
351@@ -0,0 +1,123 @@
352+# Copyright 2022 Canonical Ltd. This software is licensed under the
353+# GNU Affero General Public License version 3 (see the file LICENSE).
354+
355+__metaclass__ = type
356+
357+import logging
358+import os
359+
360+from lpbuildd.target.build_snap import SnapChannelsAction
361+from lpbuildd.target.operation import Operation
362+from lpbuildd.target.proxy import BuilderProxyOperationMixin
363+from lpbuildd.target.snapstore import SnapStoreOperationMixin
364+from lpbuildd.target.vcs import VCSOperationMixin
365+from lpbuildd.util import shell_escape
366+
367+
368+RETCODE_FAILURE_INSTALL = 200
369+RETCODE_FAILURE_BUILD = 201
370+
371+
372+logger = logging.getLogger(__name__)
373+
374+
375+class RunCIPrepare(BuilderProxyOperationMixin, VCSOperationMixin,
376+ SnapStoreOperationMixin, Operation):
377+
378+ description = "Prepare for running CI jobs."
379+ buildd_path = "/build/tree"
380+
381+ @classmethod
382+ def add_arguments(cls, parser):
383+ super(RunCIPrepare, cls).add_arguments(parser)
384+ parser.add_argument(
385+ "--channel", action=SnapChannelsAction, metavar="SNAP=CHANNEL",
386+ dest="channels", default={}, help="install SNAP from CHANNEL")
387+
388+ def install(self):
389+ logger.info("Running install phase...")
390+ deps = []
391+ if self.args.proxy_url:
392+ deps.extend(self.proxy_deps)
393+ self.install_git_proxy()
394+ if self.args.backend == "lxd":
395+ for dep in "snapd", "fuse", "squashfuse":
396+ if self.backend.is_package_available(dep):
397+ deps.append(dep)
398+ deps.extend(self.vcs_deps)
399+ self.backend.run(["apt-get", "-y", "install"] + deps)
400+ if self.args.backend in ("lxd", "fake"):
401+ self.snap_store_set_proxy()
402+ for snap_name, channel in sorted(self.args.channels.items()):
403+ if snap_name not in ("lxd", "lpcraft"):
404+ self.backend.run(
405+ ["snap", "install", "--channel=%s" % channel, snap_name])
406+ for snap_name, classic in (("lxd", False), ("lpcraft", True)):
407+ cmd = ["snap", "install"]
408+ if classic:
409+ cmd.append("--classic")
410+ if snap_name in self.args.channels:
411+ cmd.append("--channel=%s" % self.args.channels[snap_name])
412+ cmd.append(snap_name)
413+ self.backend.run(cmd)
414+ self.backend.run(["lxd", "init", "--auto"])
415+
416+ def repo(self):
417+ """Collect VCS branch."""
418+ logger.info("Running repo phase...")
419+ env = self.build_proxy_environment(proxy_url=self.args.proxy_url)
420+ self.vcs_fetch("tree", cwd="/build", env=env)
421+ self.vcs_update_status(self.buildd_path)
422+
423+ def run(self):
424+ try:
425+ self.install()
426+ except Exception:
427+ logger.exception("Install failed")
428+ return RETCODE_FAILURE_INSTALL
429+ try:
430+ self.repo()
431+ except Exception:
432+ logger.exception("VCS setup failed")
433+ return RETCODE_FAILURE_BUILD
434+ return 0
435+
436+
437+class RunCI(BuilderProxyOperationMixin, Operation):
438+
439+ description = "Run a CI job."
440+ buildd_path = "/build/tree"
441+
442+ @classmethod
443+ def add_arguments(cls, parser):
444+ super(RunCI, cls).add_arguments(parser)
445+ parser.add_argument("job_name", help="job name to run")
446+ parser.add_argument(
447+ "job_index", type=int, help="index within job name to run")
448+
449+ def run_job(self):
450+ logger.info("Running job phase...")
451+ env = self.build_proxy_environment(proxy_url=self.args.proxy_url)
452+ job_id = "%s:%s" % (self.args.job_name, self.args.job_index)
453+ logger.info("Running %s" % job_id)
454+ output_path = os.path.join("/build", "output", job_id)
455+ self.backend.run(["mkdir", "-p", output_path])
456+ lpcraft_args = [
457+ "lpcraft", "-v", "run-one", "--output", output_path,
458+ self.args.job_name, str(self.args.job_index),
459+ ]
460+ tee_args = ["tee", "%s.log" % output_path]
461+ args = [
462+ "/bin/bash", "-o", "pipefail", "-c", "%s 2>&1 | %s" % (
463+ " ".join(shell_escape(arg) for arg in lpcraft_args),
464+ " ".join(shell_escape(arg) for arg in tee_args)),
465+ ]
466+ self.run_build_command(args, env=env)
467+
468+ def run(self):
469+ try:
470+ self.run_job()
471+ except Exception:
472+ logger.exception("Job failed")
473+ return RETCODE_FAILURE_BUILD
474+ return 0
475diff --git a/lpbuildd/target/tests/matchers.py b/lpbuildd/target/tests/matchers.py
476index fbecb05..e8e01a1 100644
477--- a/lpbuildd/target/tests/matchers.py
478+++ b/lpbuildd/target/tests/matchers.py
479@@ -46,8 +46,8 @@ class RanAptGet(RanCommand):
480
481 class RanSnap(RanCommand):
482
483- def __init__(self, *args):
484- super(RanSnap, self).__init__(["snap"] + list(args))
485+ def __init__(self, *args, **kwargs):
486+ super(RanSnap, self).__init__(["snap"] + list(args), **kwargs)
487
488
489 class RanBuildCommand(RanCommand):
490diff --git a/lpbuildd/target/tests/test_run_ci.py b/lpbuildd/target/tests/test_run_ci.py
491new file mode 100644
492index 0000000..aad9e54
493--- /dev/null
494+++ b/lpbuildd/target/tests/test_run_ci.py
495@@ -0,0 +1,373 @@
496+# Copyright 2022 Canonical Ltd. This software is licensed under the
497+# GNU Affero General Public License version 3 (see the file LICENSE).
498+
499+__metaclass__ = type
500+
501+import json
502+import os
503+import stat
504+import subprocess
505+from textwrap import dedent
506+
507+from fixtures import (
508+ FakeLogger,
509+ TempDir,
510+ )
511+import responses
512+from systemfixtures import FakeFilesystem
513+from testtools import TestCase
514+from testtools.matchers import (
515+ AnyMatch,
516+ MatchesAll,
517+ MatchesListwise,
518+ )
519+
520+from lpbuildd.target.cli import parse_args
521+from lpbuildd.target.run_ci import (
522+ RETCODE_FAILURE_BUILD,
523+ RETCODE_FAILURE_INSTALL,
524+ )
525+from lpbuildd.target.tests.matchers import (
526+ RanAptGet,
527+ RanBuildCommand,
528+ RanCommand,
529+ RanSnap,
530+ )
531+from lpbuildd.tests.fakebuilder import FakeMethod
532+
533+
534+class FakeRevisionID(FakeMethod):
535+
536+ def __init__(self, revision_id):
537+ super(FakeRevisionID, self).__init__()
538+ self.revision_id = revision_id
539+
540+ def __call__(self, run_args, *args, **kwargs):
541+ super(FakeRevisionID, self).__call__(run_args, *args, **kwargs)
542+ if run_args[0] == "git" and "rev-parse" in run_args:
543+ return "%s\n" % self.revision_id
544+
545+
546+class TestRunCIPrepare(TestCase):
547+
548+ def test_install_git(self):
549+ args = [
550+ "run-ci-prepare",
551+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
552+ "--git-repository", "lp:foo",
553+ ]
554+ run_ci_prepare = parse_args(args=args).operation
555+ run_ci_prepare.install()
556+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
557+ RanAptGet("install", "git"),
558+ RanSnap("install", "lxd"),
559+ RanSnap("install", "--classic", "lpcraft"),
560+ RanCommand(["lxd", "init", "--auto"]),
561+ ]))
562+
563+ @responses.activate
564+ def test_install_snap_store_proxy(self):
565+ store_assertion = dedent("""\
566+ type: store
567+ store: store-id
568+ url: http://snap-store-proxy.example
569+
570+ body
571+ """)
572+
573+ def respond(request):
574+ return 200, {"X-Assertion-Store-Id": "store-id"}, store_assertion
575+
576+ responses.add_callback(
577+ "GET", "http://snap-store-proxy.example/v2/auth/store/assertions",
578+ callback=respond)
579+ args = [
580+ "run-ci-prepare",
581+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
582+ "--git-repository", "lp:foo",
583+ "--snap-store-proxy-url", "http://snap-store-proxy.example/",
584+ ]
585+ run_ci_prepare = parse_args(args=args).operation
586+ run_ci_prepare.install()
587+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
588+ RanAptGet("install", "git"),
589+ RanSnap("ack", "/dev/stdin", input_text=store_assertion),
590+ RanSnap("set", "core", "proxy.store=store-id"),
591+ RanSnap("install", "lxd"),
592+ RanSnap("install", "--classic", "lpcraft"),
593+ RanCommand(["lxd", "init", "--auto"]),
594+ ]))
595+
596+ def test_install_proxy(self):
597+ args = [
598+ "run-ci-prepare",
599+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
600+ "--git-repository", "lp:foo",
601+ "--proxy-url", "http://proxy.example:3128/",
602+ ]
603+ run_ci_prepare = parse_args(args=args).operation
604+ run_ci_prepare.bin = "/builderbin"
605+ self.useFixture(FakeFilesystem()).add("/builderbin")
606+ os.mkdir("/builderbin")
607+ with open("/builderbin/lpbuildd-git-proxy", "w") as proxy_script:
608+ proxy_script.write("proxy script\n")
609+ os.fchmod(proxy_script.fileno(), 0o755)
610+ run_ci_prepare.install()
611+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
612+ RanAptGet("install", "python3", "socat", "git"),
613+ RanSnap("install", "lxd"),
614+ RanSnap("install", "--classic", "lpcraft"),
615+ RanCommand(["lxd", "init", "--auto"]),
616+ ]))
617+ self.assertEqual(
618+ (b"proxy script\n", stat.S_IFREG | 0o755),
619+ run_ci_prepare.backend.backend_fs[
620+ "/usr/local/bin/lpbuildd-git-proxy"])
621+
622+ def test_install_channels(self):
623+ args = [
624+ "run-ci-prepare",
625+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
626+ "--channel=core=candidate", "--channel=core20=beta",
627+ "--channel=lxd=beta", "--channel=lpcraft=edge",
628+ "--git-repository", "lp:foo",
629+ ]
630+ run_ci_prepare = parse_args(args=args).operation
631+ run_ci_prepare.install()
632+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
633+ RanAptGet("install", "git"),
634+ RanSnap("install", "--channel=candidate", "core"),
635+ RanSnap("install", "--channel=beta", "core20"),
636+ RanSnap("install", "--channel=beta", "lxd"),
637+ RanSnap("install", "--classic", "--channel=edge", "lpcraft"),
638+ RanCommand(["lxd", "init", "--auto"]),
639+ ]))
640+
641+ def test_repo_git(self):
642+ args = [
643+ "run-ci-prepare",
644+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
645+ "--git-repository", "lp:foo",
646+ ]
647+ run_ci_prepare = parse_args(args=args).operation
648+ run_ci_prepare.backend.build_path = self.useFixture(TempDir()).path
649+ run_ci_prepare.backend.run = FakeRevisionID("0" * 40)
650+ run_ci_prepare.repo()
651+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
652+ RanBuildCommand(["git", "clone", "lp:foo", "tree"], cwd="/build"),
653+ RanBuildCommand(
654+ ["git", "submodule", "update", "--init", "--recursive"],
655+ cwd="/build/tree"),
656+ RanBuildCommand(
657+ ["git", "rev-parse", "HEAD^{}"],
658+ cwd="/build/tree", get_output=True, universal_newlines=True),
659+ ]))
660+ status_path = os.path.join(run_ci_prepare.backend.build_path, "status")
661+ with open(status_path) as status:
662+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
663+
664+ def test_repo_git_with_path(self):
665+ args = [
666+ "run-ci-prepare",
667+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
668+ "--git-repository", "lp:foo", "--git-path", "next",
669+ ]
670+ run_ci_prepare = parse_args(args=args).operation
671+ run_ci_prepare.backend.build_path = self.useFixture(TempDir()).path
672+ run_ci_prepare.backend.run = FakeRevisionID("0" * 40)
673+ run_ci_prepare.repo()
674+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
675+ RanBuildCommand(
676+ ["git", "clone", "-b", "next", "lp:foo", "tree"],
677+ cwd="/build"),
678+ RanBuildCommand(
679+ ["git", "submodule", "update", "--init", "--recursive"],
680+ cwd="/build/tree"),
681+ RanBuildCommand(
682+ ["git", "rev-parse", "next^{}"],
683+ cwd="/build/tree", get_output=True, universal_newlines=True),
684+ ]))
685+ status_path = os.path.join(run_ci_prepare.backend.build_path, "status")
686+ with open(status_path) as status:
687+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
688+
689+ def test_repo_git_with_tag_path(self):
690+ args = [
691+ "run-ci-prepare",
692+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
693+ "--git-repository", "lp:foo", "--git-path", "refs/tags/1.0",
694+ ]
695+ run_ci_prepare = parse_args(args=args).operation
696+ run_ci_prepare.backend.build_path = self.useFixture(TempDir()).path
697+ run_ci_prepare.backend.run = FakeRevisionID("0" * 40)
698+ run_ci_prepare.repo()
699+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
700+ RanBuildCommand(
701+ ["git", "clone", "-b", "1.0", "lp:foo", "tree"], cwd="/build"),
702+ RanBuildCommand(
703+ ["git", "submodule", "update", "--init", "--recursive"],
704+ cwd="/build/tree"),
705+ RanBuildCommand(
706+ ["git", "rev-parse", "refs/tags/1.0^{}"],
707+ cwd="/build/tree", get_output=True, universal_newlines=True),
708+ ]))
709+ status_path = os.path.join(run_ci_prepare.backend.build_path, "status")
710+ with open(status_path) as status:
711+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
712+
713+ def test_repo_proxy(self):
714+ args = [
715+ "run-ci-prepare",
716+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
717+ "--git-repository", "lp:foo",
718+ "--proxy-url", "http://proxy.example:3128/",
719+ ]
720+ run_ci_prepare = parse_args(args=args).operation
721+ run_ci_prepare.backend.build_path = self.useFixture(TempDir()).path
722+ run_ci_prepare.backend.run = FakeRevisionID("0" * 40)
723+ run_ci_prepare.repo()
724+ env = {
725+ "http_proxy": "http://proxy.example:3128/",
726+ "https_proxy": "http://proxy.example:3128/",
727+ "GIT_PROXY_COMMAND": "/usr/local/bin/lpbuildd-git-proxy",
728+ "SNAPPY_STORE_NO_CDN": "1",
729+ }
730+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesListwise([
731+ RanBuildCommand(
732+ ["git", "clone", "lp:foo", "tree"], cwd="/build", **env),
733+ RanBuildCommand(
734+ ["git", "submodule", "update", "--init", "--recursive"],
735+ cwd="/build/tree", **env),
736+ RanBuildCommand(
737+ ["git", "rev-parse", "HEAD^{}"],
738+ cwd="/build/tree", get_output=True, universal_newlines=True),
739+ ]))
740+ status_path = os.path.join(run_ci_prepare.backend.build_path, "status")
741+ with open(status_path) as status:
742+ self.assertEqual({"revision_id": "0" * 40}, json.load(status))
743+
744+ def test_run_succeeds(self):
745+ args = [
746+ "run-ci-prepare",
747+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
748+ "--git-repository", "lp:foo",
749+ ]
750+ run_ci_prepare = parse_args(args=args).operation
751+ run_ci_prepare.backend.build_path = self.useFixture(TempDir()).path
752+ run_ci_prepare.backend.run = FakeRevisionID("0" * 40)
753+ self.assertEqual(0, run_ci_prepare.run())
754+ # Just check that it did something in each step, not every detail.
755+ self.assertThat(run_ci_prepare.backend.run.calls, MatchesAll(
756+ AnyMatch(RanSnap("install", "--classic", "lpcraft")),
757+ AnyMatch(
758+ RanBuildCommand(
759+ ["git", "clone", "lp:foo", "tree"], cwd="/build")),
760+ ))
761+
762+ def test_run_install_fails(self):
763+ class FailInstall(FakeMethod):
764+ def __call__(self, run_args, *args, **kwargs):
765+ super(FailInstall, self).__call__(run_args, *args, **kwargs)
766+ if run_args[0] == "apt-get":
767+ raise subprocess.CalledProcessError(1, run_args)
768+
769+ self.useFixture(FakeLogger())
770+ args = [
771+ "run-ci-prepare",
772+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
773+ "--git-repository", "lp:foo",
774+ ]
775+ run_ci_prepare = parse_args(args=args).operation
776+ run_ci_prepare.backend.run = FailInstall()
777+ self.assertEqual(RETCODE_FAILURE_INSTALL, run_ci_prepare.run())
778+
779+ def test_run_repo_fails(self):
780+ class FailRepo(FakeMethod):
781+ def __call__(self, run_args, *args, **kwargs):
782+ super(FailRepo, self).__call__(run_args, *args, **kwargs)
783+ if run_args[0] == "git":
784+ raise subprocess.CalledProcessError(1, run_args)
785+
786+ self.useFixture(FakeLogger())
787+ args = [
788+ "run-ci-prepare",
789+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
790+ "--git-repository", "lp:foo",
791+ ]
792+ run_ci_prepare = parse_args(args=args).operation
793+ run_ci_prepare.backend.run = FailRepo()
794+ self.assertEqual(RETCODE_FAILURE_BUILD, run_ci_prepare.run())
795+
796+
797+class TestRunCI(TestCase):
798+
799+ def test_run_job(self):
800+ args = [
801+ "run-ci",
802+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
803+ "test", "0",
804+ ]
805+ run_ci = parse_args(args=args).operation
806+ run_ci.run_job()
807+ self.assertThat(run_ci.backend.run.calls, MatchesListwise([
808+ RanCommand(["mkdir", "-p", "/build/output/test:0"]),
809+ RanBuildCommand([
810+ "/bin/bash", "-o", "pipefail", "-c",
811+ "lpcraft -v run-one --output /build/output/test:0 test 0 2>&1 "
812+ "| tee /build/output/test:0.log",
813+ ], cwd="/build/tree"),
814+ ]))
815+
816+ def test_run_job_proxy(self):
817+ args = [
818+ "run-ci",
819+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
820+ "--proxy-url", "http://proxy.example:3128/",
821+ "test", "0",
822+ ]
823+ run_ci = parse_args(args=args).operation
824+ run_ci.run_job()
825+ env = {
826+ "http_proxy": "http://proxy.example:3128/",
827+ "https_proxy": "http://proxy.example:3128/",
828+ "GIT_PROXY_COMMAND": "/usr/local/bin/lpbuildd-git-proxy",
829+ "SNAPPY_STORE_NO_CDN": "1",
830+ }
831+ self.assertThat(run_ci.backend.run.calls, MatchesListwise([
832+ RanCommand(["mkdir", "-p", "/build/output/test:0"]),
833+ RanBuildCommand([
834+ "/bin/bash", "-o", "pipefail", "-c",
835+ "lpcraft -v run-one --output /build/output/test:0 test 0 2>&1 "
836+ "| tee /build/output/test:0.log",
837+ ], cwd="/build/tree", **env),
838+ ]))
839+
840+ def test_run_succeeds(self):
841+ args = [
842+ "run-ci",
843+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
844+ "test", "0",
845+ ]
846+ run_ci = parse_args(args=args).operation
847+ self.assertEqual(0, run_ci.run())
848+ # Just check that it did something in each step, not every detail.
849+ self.assertThat(
850+ run_ci.backend.run.calls,
851+ AnyMatch(RanCommand(["mkdir", "-p", "/build/output/test:0"])))
852+
853+ def test_run_install_fails(self):
854+ class FailInstall(FakeMethod):
855+ def __call__(self, run_args, *args, **kwargs):
856+ super(FailInstall, self).__call__(run_args, *args, **kwargs)
857+ if run_args[0] == "/bin/bash":
858+ raise subprocess.CalledProcessError(1, run_args)
859+
860+ self.useFixture(FakeLogger())
861+ args = [
862+ "run-ci",
863+ "--backend=fake", "--series=focal", "--arch=amd64", "1",
864+ "test", "0",
865+ ]
866+ run_ci = parse_args(args=args).operation
867+ run_ci.backend.run = FailInstall()
868+ self.assertEqual(RETCODE_FAILURE_BUILD, run_ci.run())
869diff --git a/lpbuildd/tests/fakebuilder.py b/lpbuildd/tests/fakebuilder.py
870index ef20810..4013c4b 100644
871--- a/lpbuildd/tests/fakebuilder.py
872+++ b/lpbuildd/tests/fakebuilder.py
873@@ -110,19 +110,21 @@ class FakeBuilder:
874 for fake_method in (
875 "emptyLog", "log",
876 "chrootFail", "buildFail", "builderFail", "depFail", "buildOK",
877- "buildComplete",
878+ "buildComplete", "sanitizeBuildlog",
879 ):
880 setattr(self, fake_method, FakeMethod())
881
882 def cachePath(self, file):
883 return os.path.join(self._cachepath, file)
884
885- def addWaitingFile(self, path):
886+ def addWaitingFile(self, path, name=None):
887+ if name is None:
888+ name = os.path.basename(path)
889 with open(path, "rb") as f:
890 contents = f.read()
891 sha1sum = hashlib.sha1(contents).hexdigest()
892 shutil.copy(path, self.cachePath(sha1sum))
893- self.waitingfiles[os.path.basename(path)] = sha1sum
894+ self.waitingfiles[name] = sha1sum
895
896 def anyMethod(self, *args, **kwargs):
897 pass
898@@ -199,9 +201,10 @@ class FakeBackend(Backend):
899
900 def find(self, path, max_depth=None, include_directories=True, name=None):
901 def match(backend_path, mode):
902- rel_path = os.path.relpath(backend_path, path)
903- if rel_path == os.sep or os.path.dirname(rel_path) == os.pardir:
904+ prefix = path + os.sep
905+ if os.path.commonprefix((backend_path, prefix)) != prefix:
906 return False
907+ rel_path = os.path.relpath(backend_path, path)
908 if max_depth is not None:
909 if rel_path.count(os.sep) + 1 > max_depth:
910 return False
911diff --git a/lpbuildd/tests/test_ci.py b/lpbuildd/tests/test_ci.py
912new file mode 100644
913index 0000000..1009a36
914--- /dev/null
915+++ b/lpbuildd/tests/test_ci.py
916@@ -0,0 +1,223 @@
917+# Copyright 2022 Canonical Ltd. This software is licensed under the
918+# GNU Affero General Public License version 3 (see the file LICENSE).
919+
920+__metaclass__ = type
921+
922+import os
923+import shutil
924+
925+from fixtures import (
926+ EnvironmentVariable,
927+ TempDir,
928+ )
929+from testtools import TestCase
930+from testtools.deferredruntest import AsynchronousDeferredRunTest
931+from twisted.internet import defer
932+
933+from lpbuildd.builder import get_build_path
934+from lpbuildd.ci import (
935+ CIBuildManager,
936+ CIBuildState,
937+ )
938+from lpbuildd.tests.fakebuilder import FakeBuilder
939+from lpbuildd.tests.matchers import HasWaitingFiles
940+
941+
942+class MockBuildManager(CIBuildManager):
943+ def __init__(self, *args, **kwargs):
944+ super(MockBuildManager, self).__init__(*args, **kwargs)
945+ self.commands = []
946+ self.iterators = []
947+
948+ def runSubProcess(self, path, command, iterate=None, env=None):
949+ self.commands.append([path] + command)
950+ if iterate is None:
951+ iterate = self.iterate
952+ self.iterators.append(iterate)
953+ return 0
954+
955+
956+class TestCIBuildManagerIteration(TestCase):
957+ """Run CIBuildManager through its iteration steps."""
958+
959+ run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
960+
961+ def setUp(self):
962+ super(TestCIBuildManagerIteration, self).setUp()
963+ self.working_dir = self.useFixture(TempDir()).path
964+ builder_dir = os.path.join(self.working_dir, "builder")
965+ home_dir = os.path.join(self.working_dir, "home")
966+ for dir in (builder_dir, home_dir):
967+ os.mkdir(dir)
968+ self.useFixture(EnvironmentVariable("HOME", home_dir))
969+ self.builder = FakeBuilder(builder_dir)
970+ self.buildid = "123"
971+ self.buildmanager = MockBuildManager(self.builder, self.buildid)
972+ self.buildmanager._cachepath = self.builder._cachepath
973+
974+ def getState(self):
975+ """Retrieve build manager's state."""
976+ return self.buildmanager._state
977+
978+ @defer.inlineCallbacks
979+ def startBuild(self, args=None, options=None):
980+ # The build manager's iterate() kicks off the consecutive states
981+ # after INIT.
982+ extra_args = {
983+ "series": "focal",
984+ "arch_tag": "amd64",
985+ "name": "test",
986+ }
987+ if args is not None:
988+ extra_args.update(args)
989+ original_backend_name = self.buildmanager.backend_name
990+ self.buildmanager.backend_name = "fake"
991+ self.buildmanager.initiate({}, "chroot.tar.gz", extra_args)
992+ self.buildmanager.backend_name = original_backend_name
993+
994+ # Skip states that are done in DebianBuildManager to the state
995+ # directly before PREPARE.
996+ self.buildmanager._state = CIBuildState.UPDATE
997+
998+ # PREPARE: Run the builder's payload to prepare for running CI jobs.
999+ yield self.buildmanager.iterate(0)
1000+ self.assertEqual(CIBuildState.PREPARE, self.getState())
1001+ expected_command = [
1002+ "sharepath/bin/in-target", "in-target", "run-ci-prepare",
1003+ "--backend=lxd", "--series=focal", "--arch=amd64", self.buildid,
1004+ ]
1005+ if options is not None:
1006+ expected_command.extend(options)
1007+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1008+ self.assertEqual(
1009+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1010+ self.assertFalse(self.builder.wasCalled("chrootFail"))
1011+
1012+ @defer.inlineCallbacks
1013+ def expectRunJob(self, job_name, job_index, options=None):
1014+ yield self.buildmanager.iterate(0)
1015+ self.assertEqual(CIBuildState.RUN_JOB, self.getState())
1016+ expected_command = [
1017+ "sharepath/bin/in-target", "in-target", "run-ci",
1018+ "--backend=lxd", "--series=focal", "--arch=amd64", self.buildid,
1019+ ]
1020+ if options is not None:
1021+ expected_command.extend(options)
1022+ expected_command.extend([job_name, job_index])
1023+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1024+ self.assertEqual(
1025+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1026+ self.assertFalse(self.builder.wasCalled("chrootFail"))
1027+
1028+ @defer.inlineCallbacks
1029+ def test_iterate(self):
1030+ # The build manager iterates multiple CI jobs from start to finish.
1031+ args = {
1032+ "git_repository": "https://git.launchpad.test/~example/+git/ci",
1033+ "git_path": "main",
1034+ "jobs": [("build", "0"), ("test", "0")],
1035+ }
1036+ expected_options = [
1037+ "--git-repository", "https://git.launchpad.test/~example/+git/ci",
1038+ "--git-path", "main",
1039+ ]
1040+ yield self.startBuild(args, expected_options)
1041+
1042+ # After preparation, start running the first job.
1043+ yield self.expectRunJob("build", "0")
1044+ self.buildmanager.backend.add_file(
1045+ "/build/output/build:0.log", b"I am a CI build job log.")
1046+ self.buildmanager.backend.add_file(
1047+ "/build/output/build:0/ci.whl",
1048+ b"I am output from a CI build job.")
1049+
1050+ # Collect the output of the first job and start running the second.
1051+ yield self.expectRunJob("test", "0")
1052+ self.buildmanager.backend.add_file(
1053+ "/build/output/test:0.log", b"I am a CI test job log.")
1054+ self.buildmanager.backend.add_file(
1055+ "/build/output/test:0/ci.tar.gz",
1056+ b"I am output from a CI test job.")
1057+
1058+ # Output from the first job is visible in the status response.
1059+ extra_status = self.buildmanager.status()
1060+ self.assertEqual(
1061+ {
1062+ "build:0": {
1063+ "log": self.builder.waitingfiles["build:0.log"],
1064+ "output": {
1065+ "ci.whl": self.builder.waitingfiles["build:0/ci.whl"],
1066+ },
1067+ },
1068+ },
1069+ extra_status["jobs"])
1070+
1071+ # After running the final job, reap processes.
1072+ yield self.buildmanager.iterate(0)
1073+ expected_command = [
1074+ "sharepath/bin/in-target", "in-target", "scan-for-processes",
1075+ "--backend=lxd", "--series=focal", "--arch=amd64", self.buildid,
1076+ ]
1077+ self.assertEqual(CIBuildState.RUN_JOB, self.getState())
1078+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1079+ self.assertNotEqual(
1080+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1081+ self.assertFalse(self.builder.wasCalled("buildFail"))
1082+ self.assertThat(self.builder, HasWaitingFiles.byEquality({
1083+ "build:0.log": b"I am a CI build job log.",
1084+ "build:0/ci.whl": b"I am output from a CI build job.",
1085+ "test:0.log": b"I am a CI test job log.",
1086+ "test:0/ci.tar.gz": b"I am output from a CI test job.",
1087+ }))
1088+
1089+ # Output from both jobs is visible in the status response.
1090+ extra_status = self.buildmanager.status()
1091+ self.assertEqual(
1092+ {
1093+ "build:0": {
1094+ "log": self.builder.waitingfiles["build:0.log"],
1095+ "output": {
1096+ "ci.whl": self.builder.waitingfiles["build:0/ci.whl"],
1097+ },
1098+ },
1099+ "test:0": {
1100+ "log": self.builder.waitingfiles["test:0.log"],
1101+ "output": {
1102+ "ci.tar.gz":
1103+ self.builder.waitingfiles["test:0/ci.tar.gz"],
1104+ },
1105+ },
1106+ },
1107+ extra_status["jobs"])
1108+
1109+ # Control returns to the DebianBuildManager in the UMOUNT state.
1110+ self.buildmanager.iterateReap(self.getState(), 0)
1111+ expected_command = [
1112+ "sharepath/bin/in-target", "in-target", "umount-chroot",
1113+ "--backend=lxd", "--series=focal", "--arch=amd64", self.buildid,
1114+ ]
1115+ self.assertEqual(CIBuildState.UMOUNT, self.getState())
1116+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1117+ self.assertEqual(
1118+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1119+ self.assertFalse(self.builder.wasCalled("buildFail"))
1120+
1121+ # If we iterate to the end of the build, then the extra status
1122+ # information is still present.
1123+ self.buildmanager.iterate(0)
1124+ expected_command = [
1125+ 'sharepath/bin/in-target', 'in-target', 'remove-build',
1126+ '--backend=lxd', '--series=focal', '--arch=amd64', self.buildid,
1127+ ]
1128+ self.assertEqual(CIBuildState.CLEANUP, self.getState())
1129+ self.assertEqual(expected_command, self.buildmanager.commands[-1])
1130+ self.assertEqual(
1131+ self.buildmanager.iterate, self.buildmanager.iterators[-1])
1132+
1133+ self.buildmanager.iterate(0)
1134+ self.assertTrue(self.builder.wasCalled('buildOK'))
1135+ self.assertTrue(self.builder.wasCalled('buildComplete'))
1136+ # remove-build would remove this in a non-test environment.
1137+ shutil.rmtree(get_build_path(
1138+ self.buildmanager.home, self.buildmanager._buildid))
1139+ self.assertIn("jobs", self.buildmanager.status())

Subscribers

People subscribed via source and target branches