3 """Process the Invirt build queue.
5 The Invirtibuilder handles package builds and uploads. On demand, it
6 attempts to build a particular package.
8 If the build succeeds, the new version of the package is uploaded to
9 the apt repository, tagged in its git repository, and the Invirt
10 superproject is updated to point at the new version.
12 If the build fails, the Invirtibuilder sends mail with the build log.
14 The build queue is tracked via files in /var/lib/invirt-dev/queue. In
15 order to maintain ordering, all filenames in that directory are the
16 timestamp of their creation time.
18 Each queue file contains a file of the form
20 pocket package hash principal
22 where pocket is one of the pockets globally configured in
23 build.pockets. For instance, the pockets in XVM are "prod" and "dev".
25 principal is the Kerberos principal that requested the build.
29 from __future__ import with_statement
42 from debian_bundle import deb822
44 import invirt.builder as b
45 import invirt.common as c
46 from invirt import database
47 from invirt.config import structs as config
52 def logAndRun(cmd, *args, **kwargs):
53 # Always grab stdout, even if the caller doesn't need it.
54 # TODO: don't slurp it all into memory in that case.
55 if 'stdout' in kwargs and kwargs['stdout'] is None:
57 kwargs['stderr'] = logfile
58 logfile.write('---> Ran %s\n' % (cmd, ))
59 if 'stdin_str' in kwargs:
60 logfile.write('STDIN:\n')
61 logfile.write(kwargs['stdin_str'])
62 logfile.write('STDERR:\n')
63 output = c.captureOutput(cmd, *args, **kwargs)
64 logfile.write('STDOUT:\n')
68 def getControl(package, ref):
69 """Get the parsed debian/control file for a given package.
71 This returns a list of debian_bundle.deb822.Deb822 objects, one
72 for each section of the debian/control file. Each Deb822 object
73 acts roughly like a dict.
75 return deb822.Deb822.iter_paragraphs(
76 b.getGitFile(package, ref, 'debian/control').split('\n'))
79 def getBinaries(package, ref):
80 """Get a list of binary packages in a package at a given ref."""
81 return [p['Package'] for p in getControl(package, ref)
85 def getArches(package, ref):
86 """Get the set of all architectures in any binary package."""
88 for section in getControl(package, ref):
89 if 'Architecture' in section:
90 arches.update(section['Architecture'].split())
94 def getDscName(package, ref):
95 """Return the .dsc file that will be generated for this package."""
96 v = b.getVersion(package, ref)
98 v_str = '%s-%s' % (v.upstream_version,
101 v_str = v.upstream_version
102 return '%s_%s.dsc' % (
107 def sanitizeVersion(version):
108 """Sanitize a Debian package version for use as a git tag.
110 This function strips the epoch from the version number and
111 replaces any tildes with underscores."""
112 if version.debian_version:
113 v = '%s-%s' % (version.upstream_version,
114 version.debian_version)
116 v = version.upstream_version
117 return v.replace('~', '_')
120 def aptCopy(package, commit, dst_pocket, src_pocket):
121 """Copy a package from one pocket to another."""
122 binaries = getBinaries(package, commit)
123 logAndRun(['reprepro-env', 'copy',
124 b.pocketToApt(dst_pocket),
125 b.pocketToApt(src_pocket),
129 def sbuild(package, ref, distro, arch, workdir, arch_all=False):
130 """Build a package for a particular architecture and distro."""
131 # We append a suffix like ~ubuntu8.04 to differentiate the same
132 # version built for multiple distros
133 nmutag = b.distroToSuffix(distro)
134 env = os.environ.copy()
135 env['NMUTAG'] = nmutag
137 # Run sbuild with a hack in place to append arbitrary versions
138 args = ['perl', '-I/usr/share/invirt-dev', '-MSbuildHack',
140 '--binNMU=171717', '--make-binNMU=Build with sbuild',
141 '-v', '-d', distro, '--arch', arch]
144 args.append(getDscName(package, ref))
145 logAndRun(args, cwd=workdir, env=env)
148 def sbuildAll(package, ref, distro, workdir):
149 """Build a package for all architectures it supports."""
150 arches = getArches(package, ref)
151 if 'all' in arches or 'any' in arches or 'amd64' in arches:
152 sbuild(package, ref, distro, 'amd64', workdir, arch_all=True)
153 if 'any' in arches or 'i386' in arches:
154 sbuild(package, ref, distro, 'i386', workdir)
157 def tagSubmodule(pocket, package, commit, principal, version, env):
158 """Tag a new version of a submodule.
160 If this pocket does not allow_backtracking, then this will create
161 a new tag of the version at ref.
163 This function doesn't need to care about lock
164 contention. git-receive-pack updates one ref at a time, and only
165 takes out a lock for that ref after it's passed the update
166 hook. Because we reject pushes to tags in the update hook, no push
167 can ever take out a lock on any tags.
169 I'm sure that long description gives you great confidence in the
170 legitimacy of my reasoning.
172 if not config.build.pockets[pocket].get('allow_backtracking', False):
173 branch = b.pocketToGit(pocket)
174 tag_msg = ('Tag %s of %s\n\n'
175 'Requested by %s' % (version.full_version,
180 ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
183 cwd=b.getRepo(package))
186 def updateSubmoduleBranch(pocket, package, commit):
187 """Update the appropriately named branch in the submodule."""
188 branch = b.pocketToGit(pocket)
190 ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
193 def uploadBuild(pocket, workdir):
194 """Upload all build products in the work directory."""
195 force = config.build.pockets[pocket].get('allow_backtracking', False)
196 apt = b.pocketToApt(pocket)
197 for changes in glob.glob(os.path.join(workdir, '*.changes')):
198 upload = ['reprepro-env', '--ignore=wrongdistribution',
199 'include', apt, changes]
202 except subprocess.CalledProcessError, e:
205 changelog = deb822.Changes(open(changes).read())
206 packages = set(changelog['Binary'].split())
207 packages.add(changelog['Source'])
208 for package in packages:
209 logAndRun(['reprepro-env', 'remove', apt, package])
213 def updateSuperproject(pocket, package, commit, principal, version, env):
214 """Update the superproject.
216 This will create a new commit on the branch for the given pocket
217 that sets the commit for the package submodule to commit.
219 Note that there's no locking issue here, because we disallow all
220 pushes to the superproject.
222 superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
224 branch = b.pocketToGit(pocket)
226 if not b.pocketExists(pocket, superproject):
228 gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
230 stdin_str=gitmodules).strip()
231 tree_items = {'.gitmodules': "100644 blob "+gitmodules_hash}
232 new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
233 new_tree_id = logAndRun(['git', 'mktree', '--missing'],
235 stdin_str=new_tree).strip()
236 env2 = dict(os.environ)
237 env2['GIT_AUTHOR_NAME'] = config.build.tagger.name
238 env2['GIT_AUTHOR_EMAIL'] = config.build.tagger.email
239 env2['GIT_COMMITTER_NAME'] = config.build.tagger.name
240 env2['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
241 new_commit = logAndRun(['git', 'commit-tree', new_tree_id],
244 stdin_str="Create new pocket").strip()
245 logAndRun(['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
248 tree = logAndRun(['git', 'ls-tree', branch],
249 cwd=superproject).strip()
251 tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
253 created = not (package in tree_items)
255 tree_items[package] = "160000 commit "+commit
257 # If "created" is true, we need to check if the package is
258 # mentioned in .gitmodules, and add it if not.
260 gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
262 if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
263 gitmodules += """[submodule "%s"]
265 \turl = ../packages/%s.git
266 """ % (package, package, package)
267 gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
269 stdin_str=gitmodules).strip()
270 tree_items['.gitmodules'] = "100644 blob "+gitmodules_hash
272 new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
274 new_tree_id = logAndRun(['git', 'mktree', '--missing'],
276 stdin_str=new_tree).strip()
279 commit_msg = 'Add %s at version %s'
281 commit_msg = 'Update %s to version %s'
282 commit_msg = ((commit_msg + '\n\n'
283 'Requested by %s') % (package,
284 version.full_version,
286 new_commit = logAndRun(
287 ['git', 'commit-tree', new_tree_id, '-p', branch],
290 stdin_str=commit_msg).strip()
293 ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
297 def makeReadable(workdir):
298 os.chmod(workdir, 0755)
300 @contextlib.contextmanager
301 def packageWorkdir(package, commit, build_id):
302 """Checkout the package in a temporary working directory.
304 This context manager returns that working directory. The requested
305 package is checked out into a subdirectory of the working
306 directory with the same name as the package.
308 When the context wrapped with this context manager is exited, the
309 working directory is automatically deleted.
311 workdir = tempfile.mkdtemp(prefix=("b%d-" % build_id))
313 p_archive = subprocess.Popen(
314 ['git', '--git-dir=%s' % (b.getRepo(package),),
316 '--prefix=%s/' % package,
319 stdout=subprocess.PIPE,
321 p_tar = subprocess.Popen(
323 stdin=p_archive.stdout,
331 shutil.rmtree(workdir)
334 """Deal with items in the build queue.
336 When triggered, iterate over build queue items one at a time,
337 until there are no more pending build jobs.
342 stage = 'processing incoming job'
343 queue = os.listdir(b._QUEUE_DIR)
348 job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
349 pocket, package, commit, principal = job.split()
351 database.session.begin()
352 db = database.Build()
356 db.principal = principal
357 database.session.save_or_update(db)
358 database.session.commit()
360 database.session.begin()
362 logdir = os.path.join(b._LOG_DIR, str(db.build_id))
363 if not os.path.exists(logdir):
367 db.failed_stage = 'validating job'
368 # Don't expand the commit in the DB until we're sure the user
369 # isn't trying to be tricky.
370 b.ensureValidPackage(package)
372 logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
374 db.commit = commit = b.canonicalize_commit(package, commit)
375 src = b.validateBuild(pocket, package, commit)
376 version = b.getVersion(package, commit)
377 db.version = str(version)
378 b.runHook('pre-build', [str(db.build_id)])
380 env = dict(os.environ)
381 env['GIT_COMMITTER_NAME'] = config.build.tagger.name
382 env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
383 env['GIT_AUTHOR_NAME'] = principal.split('@')[0]
384 env['GIT_AUTHOR_EMAIL'] = principal
386 # If validateBuild returns something other than True, then
387 # it means we should copy from that pocket to our pocket.
389 # (If the validation failed, validateBuild would have
390 # raised an exception)
392 # TODO: cut out this code duplication
393 db.failed_stage = 'tagging submodule before copying package'
394 tagSubmodule(pocket, package, commit, principal, version, env)
395 db.failed_stage = 'updating submodule branches before copying package'
396 updateSubmoduleBranch(pocket, package, commit)
397 db.failed_stage = 'updating superproject before copying package'
398 updateSuperproject(pocket, package, commit, principal, version, env)
399 db.failed_stage = 'copying package from another pocket'
400 aptCopy(package, commit, pocket, src)
402 # If we can't copy the package from somewhere, but
403 # validateBuild didn't raise an exception, then we need to
404 # do the build ourselves
406 db.failed_stage = 'checking out package source'
407 with packageWorkdir(package, commit, db.build_id) as workdir:
408 db.failed_stage = 'preparing source package'
409 packagedir = os.path.join(workdir, package)
411 # We should be more clever about dealing with
412 # things like non-Debian-native packages than we
415 # If we were, we could use debuild and get nice
416 # environment scrubbing. Since we're not, debuild
417 # complains about not having an orig.tar.gz
418 logAndRun(['schroot', '-c',
419 '%s-amd64-sbuild' % (b.pocketToDistro(pocket),),
420 '--', 'dpkg-buildpackage', '-us', '-uc', '-S'],
423 db.failed_stage = 'building binary packages'
424 sbuildAll(package, commit, b.pocketToDistro(pocket), workdir)
425 db.failed_stage = 'tagging submodule'
426 tagSubmodule(pocket, package, commit, principal, version, env)
427 db.failed_stage = 'updating submodule branches'
428 updateSubmoduleBranch(pocket, package, commit)
429 db.failed_stage = 'updating superproject'
430 updateSuperproject(pocket, package, commit, principal, version, env)
431 db.failed_stage = 'relaxing permissions on workdir'
432 makeReadable(workdir)
433 db.failed_stage = 'uploading packages to apt repo'
434 uploadBuild(pocket, workdir)
436 db.failed_stage = 'cleaning up'
438 db.traceback = traceback.format_exc()
441 db.failed_stage = None
443 if logfile is not None:
446 database.session.save_or_update(db)
447 database.session.commit()
449 # Finally, now that everything is done, remove the
451 os.unlink(os.path.join(b._QUEUE_DIR, build))
454 b.runHook('post-build', [str(db.build_id)])
456 b.runHook('failed-build', [str(db.build_id)])
458 class Invirtibuilder(pyinotify.ProcessEvent):
459 """Process inotify triggers to build new packages."""
460 def process_default(self, event):
461 """Handle an inotify event.
463 When an inotify event comes in, trigger the builder.
469 """Initialize the inotifications and start the main loop."""
472 watch_manager = pyinotify.WatchManager()
473 invirtibuilder = Invirtibuilder()
474 notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
475 watch_manager.add_watch(b._QUEUE_DIR,
476 pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
477 pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
479 # Before inotifying, run any pending builds; otherwise we won't
480 # get notified for them.
484 notifier.process_events()
485 if notifier.check_events():
486 notifier.read_events()
489 if __name__ == '__main__':