Fixes to invirtibuilder for case of missing package in super-repo, missing
[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
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, '--arch', arch]
142     if arch_all:
143         args.append('-A')
144     args.append(getDscName(package, ref))
145     logAndRun(args, cwd=workdir, env=env)
146
147
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)
155
156
157 def tagSubmodule(pocket, package, commit, principal, version, env):
158     """Tag a new version of a submodule.
159
160     If this pocket does not allow_backtracking, then this will create
161     a new tag of the version at ref.
162
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.
168
169     I'm sure that long description gives you great confidence in the
170     legitimacy of my reasoning.
171     """
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,
176                                         package,
177                                         principal))
178
179         logAndRun(
180             ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
181              commit],
182             env=env,
183             cwd=b.getRepo(package))
184
185
186 def updateSubmoduleBranch(pocket, package, commit):
187     """Update the appropriately named branch in the submodule."""
188     branch = b.pocketToGit(pocket)
189     logAndRun(
190         ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
191
192
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]
200         try:
201             logAndRun(upload)
202         except subprocess.CalledProcessError, e:
203             if not force:
204                 raise
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])
210             logAndRun(upload)
211
212
213 def updateSuperproject(pocket, package, commit, principal, version, env):
214     """Update the superproject.
215
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.
218
219     Note that there's no locking issue here, because we disallow all
220     pushes to the superproject.
221     """
222     superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
223
224     branch = b.pocketToGit(pocket)
225
226     if not b.pocketExists(pocket, superproject):
227         gitmodules = "\n"
228         gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
229                                     cwd=superproject,
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'],
234                                 cwd=superproject,
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],
242                                cwd=superproject,
243                                env=env2,
244                                stdin_str="Create new pocket").strip()
245         logAndRun(['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
246                   cwd=superproject)
247
248     tree = logAndRun(['git', 'ls-tree', branch],
249                      cwd=superproject).strip()
250
251     tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
252
253     created = not (package in tree_items)
254
255     tree_items[package] = "160000 commit "+commit
256
257     # If "created" is true, we need to check if the package is
258     # mentioned in .gitmodules, and add it if not.
259     if created:
260         gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
261                                cwd=superproject)
262         if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
263             gitmodules += """[submodule "%s"]
264 \tpath = %s
265 \turl = ../packages/%s.git
266 """ % (package, package, package)
267             gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
268                                         cwd=superproject,
269                                         stdin_str=gitmodules).strip()
270             tree_items['.gitmodules'] = "100644 blob "+gitmodules_hash
271
272     new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
273
274     new_tree_id = logAndRun(['git', 'mktree', '--missing'],
275                             cwd=superproject,
276                             stdin_str=new_tree).strip()
277
278     if created:
279         commit_msg = 'Add %s at version %s'
280     else:
281         commit_msg = 'Update %s to version %s'
282     commit_msg = ((commit_msg + '\n\n'
283                    'Requested by %s') % (package,
284                                          version.full_version,
285                                          principal))
286     new_commit = logAndRun(
287         ['git', 'commit-tree', new_tree_id, '-p', branch],
288         cwd=superproject,
289         env=env,
290         stdin_str=commit_msg).strip()
291
292     logAndRun(
293         ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
294         cwd=superproject)
295
296
297 def makeReadable(workdir):
298     os.chmod(workdir, 0755)
299
300 @contextlib.contextmanager
301 def packageWorkdir(package, commit, build_id):
302     """Checkout the package in a temporary working directory.
303
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.
307
308     When the context wrapped with this context manager is exited, the
309     working directory is automatically deleted.
310     """
311     workdir = tempfile.mkdtemp(prefix=("b%d-" % build_id))
312     try:
313         p_archive = subprocess.Popen(
314             ['git', '--git-dir=%s' % (b.getRepo(package),),
315              'archive',
316              '--prefix=%s/' % package,
317              commit,
318              ],
319             stdout=subprocess.PIPE,
320             )
321         p_tar = subprocess.Popen(
322             ['tar', '-x'],
323             stdin=p_archive.stdout,
324             cwd=workdir,
325             )
326         p_archive.wait()
327         p_tar.wait()
328
329         yield workdir
330     finally:
331         shutil.rmtree(workdir)
332
333 def build():
334     """Deal with items in the build queue.
335
336     When triggered, iterate over build queue items one at a time,
337     until there are no more pending build jobs.
338     """
339     global logfile
340
341     while True:
342         stage = 'processing incoming job'
343         queue = os.listdir(b._QUEUE_DIR)
344         if not queue:
345             break
346
347         build = min(queue)
348         job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
349         pocket, package, commit, principal = job.split()
350
351         database.session.begin()
352         db = database.Build()
353         db.package = package
354         db.pocket = pocket
355         db.commit = commit
356         db.principal = principal
357         database.session.save_or_update(db)
358         database.session.commit()
359
360         database.session.begin()
361
362         logdir = os.path.join(b._LOG_DIR, str(db.build_id))
363         if not os.path.exists(logdir):
364             os.makedirs(logdir)
365
366         try:
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)
371
372             logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
373
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)])
379
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
385
386             # If validateBuild returns something other than True, then
387             # it means we should copy from that pocket to our pocket.
388             #
389             # (If the validation failed, validateBuild would have
390             # raised an exception)
391             if src != True:
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)
401                 
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
405             else:
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)
410
411                     # We should be more clever about dealing with
412                     # things like non-Debian-native packages than we
413                     # are.
414                     #
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'],
421                               cwd=packagedir)
422
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)
435
436                     db.failed_stage = 'cleaning up'
437         except:
438             db.traceback = traceback.format_exc()
439         else:
440             db.succeeded = True
441             db.failed_stage = None
442         finally:
443             if logfile is not None:
444                 logfile.close()
445
446             database.session.save_or_update(db)
447             database.session.commit()
448
449             # Finally, now that everything is done, remove the
450             # build queue item
451             os.unlink(os.path.join(b._QUEUE_DIR, build))
452
453             if db.succeeded:
454                 b.runHook('post-build', [str(db.build_id)])
455             else:
456                 b.runHook('failed-build', [str(db.build_id)])
457
458 class Invirtibuilder(pyinotify.ProcessEvent):
459     """Process inotify triggers to build new packages."""
460     def process_default(self, event):
461         """Handle an inotify event.
462
463         When an inotify event comes in, trigger the builder.
464         """
465         build()
466
467
468 def main():
469     """Initialize the inotifications and start the main loop."""
470     database.connect()
471
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'])
478
479     # Before inotifying, run any pending builds; otherwise we won't
480     # get notified for them.
481     build()
482
483     while True:
484         notifier.process_events()
485         if notifier.check_events():
486             notifier.read_events()
487
488
489 if __name__ == '__main__':
490     main()