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
136 maintainer = "Invirt Autobuilder <%s>" % (config.contact,)
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, '-m', maintainer,
145 args.append(getDscName(package, ref))
146 logAndRun(args, cwd=workdir, env=env)
149 def sbuildAll(package, ref, distro, workdir):
150 """Build a package for all architectures it supports."""
151 arches = getArches(package, ref)
152 if 'all' in arches or 'any' in arches or 'amd64' in arches:
153 sbuild(package, ref, distro, 'amd64', workdir, arch_all=True)
154 if 'any' in arches or 'i386' in arches:
155 sbuild(package, ref, distro, 'i386', workdir)
158 def tagSubmodule(pocket, package, commit, principal, version, env):
159 """Tag a new version of a submodule.
161 If this pocket does not allow_backtracking, then this will create
162 a new tag of the version at ref.
164 This function doesn't need to care about lock
165 contention. git-receive-pack updates one ref at a time, and only
166 takes out a lock for that ref after it's passed the update
167 hook. Because we reject pushes to tags in the update hook, no push
168 can ever take out a lock on any tags.
170 I'm sure that long description gives you great confidence in the
171 legitimacy of my reasoning.
173 if not config.build.pockets[pocket].get('allow_backtracking', False):
174 branch = b.pocketToGit(pocket)
175 tag_msg = ('Tag %s of %s\n\n'
176 'Requested by %s' % (version.full_version,
181 ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
184 cwd=b.getRepo(package))
187 def updateSubmoduleBranch(pocket, package, commit):
188 """Update the appropriately named branch in the submodule."""
189 branch = b.pocketToGit(pocket)
191 ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
194 def uploadBuild(pocket, workdir):
195 """Upload all build products in the work directory."""
196 force = config.build.pockets[pocket].get('allow_backtracking', False)
197 apt = b.pocketToApt(pocket)
198 for changes in glob.glob(os.path.join(workdir, '*.changes')):
199 upload = ['reprepro-env', '--ignore=wrongdistribution',
200 'include', apt, changes]
203 except subprocess.CalledProcessError, e:
206 changelog = deb822.Changes(open(changes).read())
207 packages = set(changelog['Binary'].split())
208 packages.add(changelog['Source'])
209 for package in packages:
210 logAndRun(['reprepro-env', 'remove', apt, package])
214 def updateSuperproject(pocket, package, commit, principal, version, env):
215 """Update the superproject.
217 This will create a new commit on the branch for the given pocket
218 that sets the commit for the package submodule to commit.
220 Note that there's no locking issue here, because we disallow all
221 pushes to the superproject.
223 superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
225 branch = b.pocketToGit(pocket)
227 if not b.pocketExists(pocket, superproject):
229 gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
231 stdin_str=gitmodules).strip()
232 tree_items = {'.gitmodules': "100644 blob "+gitmodules_hash}
233 new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
234 new_tree_id = logAndRun(['git', 'mktree', '--missing'],
236 stdin_str=new_tree).strip()
237 env2 = dict(os.environ)
238 env2['GIT_AUTHOR_NAME'] = config.build.tagger.name
239 env2['GIT_AUTHOR_EMAIL'] = config.build.tagger.email
240 env2['GIT_COMMITTER_NAME'] = config.build.tagger.name
241 env2['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
242 new_commit = logAndRun(['git', 'commit-tree', new_tree_id],
245 stdin_str="Create new pocket").strip()
246 logAndRun(['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
249 tree = logAndRun(['git', 'ls-tree', branch],
250 cwd=superproject).strip()
252 tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
254 created = not (package in tree_items)
256 tree_items[package] = "160000 commit "+commit
258 # If "created" is true, we need to check if the package is
259 # mentioned in .gitmodules, and add it if not.
261 gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
263 if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
264 gitmodules += """[submodule "%s"]
266 \turl = ../packages/%s.git
267 """ % (package, package, package)
268 gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
270 stdin_str=gitmodules).strip()
271 tree_items['.gitmodules'] = "100644 blob "+gitmodules_hash
273 new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
275 new_tree_id = logAndRun(['git', 'mktree', '--missing'],
277 stdin_str=new_tree).strip()
280 commit_msg = 'Add %s at version %s'
282 commit_msg = 'Update %s to version %s'
283 commit_msg = ((commit_msg + '\n\n'
284 'Requested by %s') % (package,
285 version.full_version,
287 new_commit = logAndRun(
288 ['git', 'commit-tree', new_tree_id, '-p', branch],
291 stdin_str=commit_msg).strip()
294 ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
298 def makeReadable(workdir):
299 os.chmod(workdir, 0755)
301 @contextlib.contextmanager
302 def packageWorkdir(package, commit, build_id):
303 """Checkout the package in a temporary working directory.
305 This context manager returns that working directory. The requested
306 package is checked out into a subdirectory of the working
307 directory with the same name as the package.
309 When the context wrapped with this context manager is exited, the
310 working directory is automatically deleted.
312 workdir = tempfile.mkdtemp(prefix=("b%d-" % build_id))
314 p_archive = subprocess.Popen(
315 ['git', '--git-dir=%s' % (b.getRepo(package),),
317 '--prefix=%s/' % package,
320 stdout=subprocess.PIPE,
322 p_tar = subprocess.Popen(
324 stdin=p_archive.stdout,
332 shutil.rmtree(workdir)
335 """Deal with items in the build queue.
337 When triggered, iterate over build queue items one at a time,
338 until there are no more pending build jobs.
343 stage = 'processing incoming job'
344 queue = os.listdir(b._QUEUE_DIR)
349 job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
350 pocket, package, commit, principal = job.split()
352 database.session.begin()
353 db = database.Build()
357 db.principal = principal
358 database.session.add(db)
359 database.session.commit()
361 database.session.begin()
363 logdir = os.path.join(b._LOG_DIR, str(db.build_id))
364 if not os.path.exists(logdir):
368 db.failed_stage = 'validating job'
369 # Don't expand the commit in the DB until we're sure the user
370 # isn't trying to be tricky.
371 b.ensureValidPackage(package)
373 logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
375 db.commit = commit = b.canonicalize_commit(package, commit)
376 src = b.validateBuild(pocket, package, commit)
377 version = b.getVersion(package, commit)
378 db.version = str(version)
379 b.runHook('pre-build', [str(db.build_id)])
381 env = dict(os.environ)
382 env['GIT_COMMITTER_NAME'] = config.build.tagger.name
383 env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
384 env['GIT_AUTHOR_NAME'] = principal.split('@')[0]
385 env['GIT_AUTHOR_EMAIL'] = principal
387 # If validateBuild returns something other than True, then
388 # it means we should copy from that pocket to our pocket.
390 # (If the validation failed, validateBuild would have
391 # raised an exception)
393 # TODO: cut out this code duplication
394 db.failed_stage = 'tagging submodule before copying package'
395 tagSubmodule(pocket, package, commit, principal, version, env)
396 db.failed_stage = 'updating submodule branches before copying package'
397 updateSubmoduleBranch(pocket, package, commit)
398 db.failed_stage = 'updating superproject before copying package'
399 updateSuperproject(pocket, package, commit, principal, version, env)
400 db.failed_stage = 'copying package from another pocket'
401 aptCopy(package, commit, pocket, src)
403 # If we can't copy the package from somewhere, but
404 # validateBuild didn't raise an exception, then we need to
405 # do the build ourselves
407 db.failed_stage = 'checking out package source'
408 with packageWorkdir(package, commit, db.build_id) as workdir:
409 db.failed_stage = 'preparing source package'
410 packagedir = os.path.join(workdir, package)
412 # We should be more clever about dealing with
413 # things like non-Debian-native packages than we
416 # If we were, we could use debuild and get nice
417 # environment scrubbing. Since we're not, debuild
418 # complains about not having an orig.tar.gz
419 logAndRun(['dpkg-buildpackage', '-us', '-uc', '-S'],
421 db.failed_stage = 'building binary packages'
422 sbuildAll(package, commit, b.pocketToDistro(pocket), workdir)
423 db.failed_stage = 'tagging submodule'
424 tagSubmodule(pocket, package, commit, principal, version, env)
425 db.failed_stage = 'updating submodule branches'
426 updateSubmoduleBranch(pocket, package, commit)
427 db.failed_stage = 'updating superproject'
428 updateSuperproject(pocket, package, commit, principal, version, env)
429 db.failed_stage = 'relaxing permissions on workdir'
430 makeReadable(workdir)
431 db.failed_stage = 'uploading packages to apt repo'
432 uploadBuild(pocket, workdir)
434 db.failed_stage = 'cleaning up'
436 db.traceback = traceback.format_exc()
439 db.failed_stage = None
441 if logfile is not None:
444 database.session.add(db)
445 database.session.commit()
447 # Finally, now that everything is done, remove the
449 os.unlink(os.path.join(b._QUEUE_DIR, build))
452 b.runHook('post-build', [str(db.build_id)])
454 b.runHook('failed-build', [str(db.build_id)])
456 class Invirtibuilder(pyinotify.ProcessEvent):
457 """Process inotify triggers to build new packages."""
458 def process_default(self, event):
459 """Handle an inotify event.
461 When an inotify event comes in, trigger the builder.
467 """Initialize the inotifications and start the main loop."""
470 watch_manager = pyinotify.WatchManager()
471 invirtibuilder = Invirtibuilder()
472 notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
473 watch_manager.add_watch(b._QUEUE_DIR,
474 pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
475 pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
477 # Before inotifying, run any pending builds; otherwise we won't
478 # get notified for them.
482 notifier.process_events()
483 if notifier.check_events():
484 notifier.read_events()
487 if __name__ == '__main__':