stop building source packages in a schroot; we don't need to anymore
[invirt/packages/invirt-dev.git] / invirtibuilder
1 #!/usr/bin/python
2
3 """Process the Invirt build queue.
4
5 The Invirtibuilder handles package builds and uploads. On demand, it
6 attempts to build a particular package.
7
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.
11
12 If the build fails, the Invirtibuilder sends mail with the build log.
13
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.
17
18 Each queue file contains a file of the form
19
20     pocket package hash principal
21
22 where pocket is one of the pockets globally configured in
23 build.pockets. For instance, the pockets in XVM are "prod" and "dev".
24
25 principal is the Kerberos principal that requested the build.
26 """
27
28
29 from __future__ import with_statement
30
31 import contextlib
32 import glob
33 import os
34 import re
35 import shutil
36 import subprocess
37 import tempfile
38 import traceback
39
40 import pyinotify
41
42 from debian_bundle import deb822
43
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
48
49
50 logfile = None
51
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:
56         del kwargs['stdout']
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')
65     logfile.write(output)
66     return output
67
68 def getControl(package, ref):
69     """Get the parsed debian/control file for a given package.
70
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.
74     """
75     return deb822.Deb822.iter_paragraphs(
76         b.getGitFile(package, ref, 'debian/control').split('\n'))
77
78
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)
82             if 'Package' in p]
83
84
85 def getArches(package, ref):
86     """Get the set of all architectures in any binary package."""
87     arches = set()
88     for section in getControl(package, ref):
89         if 'Architecture' in section:
90             arches.update(section['Architecture'].split())
91     return arches
92
93
94 def getDscName(package, ref):
95     """Return the .dsc file that will be generated for this package."""
96     v = b.getVersion(package, ref)
97     if v.debian_version:
98         v_str = '%s-%s' % (v.upstream_version,
99                            v.debian_version)
100     else:
101         v_str = v.upstream_version
102     return '%s_%s.dsc' % (
103         package,
104         v_str)
105
106
107 def sanitizeVersion(version):
108     """Sanitize a Debian package version for use as a git tag.
109
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)
115     else:
116         v = version.upstream_version
117     return v.replace('~', '_')
118
119
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),
126                package] + binaries)
127
128
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',
139             '/usr/bin/sbuild',
140             '--binNMU=171717', '--make-binNMU=Build with sbuild',
141             '-v', '-d', distro, '-m', maintainer, 
142             '--arch', arch]
143     if arch_all:
144         args.append('-A')
145     args.append(getDscName(package, ref))
146     logAndRun(args, cwd=workdir, env=env)
147
148
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)
156
157
158 def tagSubmodule(pocket, package, commit, principal, version, env):
159     """Tag a new version of a submodule.
160
161     If this pocket does not allow_backtracking, then this will create
162     a new tag of the version at ref.
163
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.
169
170     I'm sure that long description gives you great confidence in the
171     legitimacy of my reasoning.
172     """
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,
177                                         package,
178                                         principal))
179
180         logAndRun(
181             ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
182              commit],
183             env=env,
184             cwd=b.getRepo(package))
185
186
187 def updateSubmoduleBranch(pocket, package, commit):
188     """Update the appropriately named branch in the submodule."""
189     branch = b.pocketToGit(pocket)
190     logAndRun(
191         ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
192
193
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]
201         try:
202             logAndRun(upload)
203         except subprocess.CalledProcessError, e:
204             if not force:
205                 raise
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])
211             logAndRun(upload)
212
213
214 def updateSuperproject(pocket, package, commit, principal, version, env):
215     """Update the superproject.
216
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.
219
220     Note that there's no locking issue here, because we disallow all
221     pushes to the superproject.
222     """
223     superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
224
225     branch = b.pocketToGit(pocket)
226
227     if not b.pocketExists(pocket, superproject):
228         gitmodules = "\n"
229         gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
230                                     cwd=superproject,
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'],
235                                 cwd=superproject,
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],
243                                cwd=superproject,
244                                env=env2,
245                                stdin_str="Create new pocket").strip()
246         logAndRun(['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
247                   cwd=superproject)
248
249     tree = logAndRun(['git', 'ls-tree', branch],
250                      cwd=superproject).strip()
251
252     tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
253
254     created = not (package in tree_items)
255
256     tree_items[package] = "160000 commit "+commit
257
258     # If "created" is true, we need to check if the package is
259     # mentioned in .gitmodules, and add it if not.
260     if created:
261         gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
262                                cwd=superproject)
263         if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
264             gitmodules += """[submodule "%s"]
265 \tpath = %s
266 \turl = ../packages/%s.git
267 """ % (package, package, package)
268             gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
269                                         cwd=superproject,
270                                         stdin_str=gitmodules).strip()
271             tree_items['.gitmodules'] = "100644 blob "+gitmodules_hash
272
273     new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
274
275     new_tree_id = logAndRun(['git', 'mktree', '--missing'],
276                             cwd=superproject,
277                             stdin_str=new_tree).strip()
278
279     if created:
280         commit_msg = 'Add %s at version %s'
281     else:
282         commit_msg = 'Update %s to version %s'
283     commit_msg = ((commit_msg + '\n\n'
284                    'Requested by %s') % (package,
285                                          version.full_version,
286                                          principal))
287     new_commit = logAndRun(
288         ['git', 'commit-tree', new_tree_id, '-p', branch],
289         cwd=superproject,
290         env=env,
291         stdin_str=commit_msg).strip()
292
293     logAndRun(
294         ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
295         cwd=superproject)
296
297
298 def makeReadable(workdir):
299     os.chmod(workdir, 0755)
300
301 @contextlib.contextmanager
302 def packageWorkdir(package, commit, build_id):
303     """Checkout the package in a temporary working directory.
304
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.
308
309     When the context wrapped with this context manager is exited, the
310     working directory is automatically deleted.
311     """
312     workdir = tempfile.mkdtemp(prefix=("b%d-" % build_id))
313     try:
314         p_archive = subprocess.Popen(
315             ['git', '--git-dir=%s' % (b.getRepo(package),),
316              'archive',
317              '--prefix=%s/' % package,
318              commit,
319              ],
320             stdout=subprocess.PIPE,
321             )
322         p_tar = subprocess.Popen(
323             ['tar', '-x'],
324             stdin=p_archive.stdout,
325             cwd=workdir,
326             )
327         p_archive.wait()
328         p_tar.wait()
329
330         yield workdir
331     finally:
332         shutil.rmtree(workdir)
333
334 def build():
335     """Deal with items in the build queue.
336
337     When triggered, iterate over build queue items one at a time,
338     until there are no more pending build jobs.
339     """
340     global logfile
341
342     while True:
343         stage = 'processing incoming job'
344         queue = os.listdir(b._QUEUE_DIR)
345         if not queue:
346             break
347
348         build = min(queue)
349         job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
350         pocket, package, commit, principal = job.split()
351
352         database.session.begin()
353         db = database.Build()
354         db.package = package
355         db.pocket = pocket
356         db.commit = commit
357         db.principal = principal
358         database.session.add(db)
359         database.session.commit()
360
361         database.session.begin()
362
363         logdir = os.path.join(b._LOG_DIR, str(db.build_id))
364         if not os.path.exists(logdir):
365             os.makedirs(logdir)
366
367         try:
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)
372
373             logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
374
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)])
380
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
386
387             # If validateBuild returns something other than True, then
388             # it means we should copy from that pocket to our pocket.
389             #
390             # (If the validation failed, validateBuild would have
391             # raised an exception)
392             if src != True:
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)
402                 
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
406             else:
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)
411
412                     # We should be more clever about dealing with
413                     # things like non-Debian-native packages than we
414                     # are.
415                     #
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'],
420                               cwd=packagedir)
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)
433
434                     db.failed_stage = 'cleaning up'
435         except:
436             db.traceback = traceback.format_exc()
437         else:
438             db.succeeded = True
439             db.failed_stage = None
440         finally:
441             if logfile is not None:
442                 logfile.close()
443
444             database.session.add(db)
445             database.session.commit()
446
447             # Finally, now that everything is done, remove the
448             # build queue item
449             os.unlink(os.path.join(b._QUEUE_DIR, build))
450
451             if db.succeeded:
452                 b.runHook('post-build', [str(db.build_id)])
453             else:
454                 b.runHook('failed-build', [str(db.build_id)])
455
456 class Invirtibuilder(pyinotify.ProcessEvent):
457     """Process inotify triggers to build new packages."""
458     def process_default(self, event):
459         """Handle an inotify event.
460
461         When an inotify event comes in, trigger the builder.
462         """
463         build()
464
465
466 def main():
467     """Initialize the inotifications and start the main loop."""
468     database.connect()
469
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'])
476
477     # Before inotifying, run any pending builds; otherwise we won't
478     # get notified for them.
479     build()
480
481     while True:
482         notifier.process_events()
483         if notifier.check_events():
484             notifier.read_events()
485
486
487 if __name__ == '__main__':
488     main()