More bug fixes for multi-distro support
[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     logfile.write('STDERR:\n')
60     output = c.captureOutput(cmd, *args, **kwargs)
61     logfile.write('STDOUT:\n')
62     logfile.write(output)
63     return output
64
65 def getControl(package, ref):
66     """Get the parsed debian/control file for a given package.
67
68     This returns a list of debian_bundle.deb822.Deb822 objects, one
69     for each section of the debian/control file. Each Deb822 object
70     acts roughly like a dict.
71     """
72     return deb822.Deb822.iter_paragraphs(
73         b.getGitFile(package, ref, 'debian/control').split('\n'))
74
75
76 def getBinaries(package, ref):
77     """Get a list of binary packages in a package at a given ref."""
78     return [p['Package'] for p in getControl(package, ref)
79             if 'Package' in p]
80
81
82 def getArches(package, ref):
83     """Get the set of all architectures in any binary package."""
84     arches = set()
85     for section in getControl(package, ref):
86         if 'Architecture' in section:
87             arches.update(section['Architecture'].split())
88     return arches
89
90
91 def getDscName(package, ref):
92     """Return the .dsc file that will be generated for this package."""
93     v = b.getVersion(package, ref)
94     if v.debian_version:
95         v_str = '%s-%s' % (v.upstream_version,
96                            v.debian_version)
97     else:
98         v_str = v.upstream_version
99     return '%s_%s.dsc' % (
100         package,
101         v_str)
102
103
104 def sanitizeVersion(version):
105     """Sanitize a Debian package version for use as a git tag.
106
107     This function strips the epoch from the version number and
108     replaces any tildes with underscores."""
109     if version.debian_version:
110         v = '%s-%s' % (version.upstream_version,
111                        version.debian_version)
112     else:
113         v = version.upstream_version
114     return v.replace('~', '_')
115
116
117 def aptCopy(package, commit, dst_pocket, src_pocket):
118     """Copy a package from one pocket to another."""
119     binaries = getBinaries(package, commit)
120     logAndRun(['reprepro-env', 'copy',
121                b.pocketToApt(dst_pocket),
122                b.pocketToApt(src_pocket),
123                package] + binaries)
124
125
126 def sbuild(package, ref, distro, arch, workdir, arch_all=False):
127     """Build a package for a particular architecture and distro."""
128     # We append a suffix like ~ubuntu8.04 to differentiate the same
129     # version built for multiple distros
130     nmutag = b.distroToSuffix(distro)
131     env = os.environ.copy()
132     env['NMUTAG'] = nmutag
133
134     # Run sbuild with a hack in place to append arbitrary versions
135     args = ['perl', '-I/usr/share/invirt-dev', '-MSbuildHack',
136             '/usr/bin/sbuild',
137             '--binNMU=171717', '--make-binNMU=Build with sbuild',
138             '-v', '-d', distro, '--arch', arch]
139     if arch_all:
140         args.append('-A')
141     args.append(getDscName(package, ref))
142     logAndRun(args, cwd=workdir, env=env)
143
144
145 def sbuildAll(package, ref, distro, workdir):
146     """Build a package for all architectures it supports."""
147     arches = getArches(package, ref)
148     if 'all' in arches or 'any' in arches or 'amd64' in arches:
149         sbuild(package, ref, distro, 'amd64', workdir, arch_all=True)
150     if 'any' in arches or 'i386' in arches:
151         sbuild(package, ref, distro, 'i386', workdir)
152
153
154 def tagSubmodule(pocket, package, commit, principal, version, env):
155     """Tag a new version of a submodule.
156
157     If this pocket does not allow_backtracking, then this will create
158     a new tag of the version at ref.
159
160     This function doesn't need to care about lock
161     contention. git-receive-pack updates one ref at a time, and only
162     takes out a lock for that ref after it's passed the update
163     hook. Because we reject pushes to tags in the update hook, no push
164     can ever take out a lock on any tags.
165
166     I'm sure that long description gives you great confidence in the
167     legitimacy of my reasoning.
168     """
169     if not config.build.pockets[pocket].get('allow_backtracking', False):
170         branch = b.pocketToGit(pocket)
171         tag_msg = ('Tag %s of %s\n\n'
172                    'Requested by %s' % (version.full_version,
173                                         package,
174                                         principal))
175
176         logAndRun(
177             ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
178              commit],
179             env=env,
180             cwd=b.getRepo(package))
181
182
183 def updateSubmoduleBranch(pocket, package, commit):
184     """Update the appropriately named branch in the submodule."""
185     branch = b.pocketToGit(pocket)
186     logAndRun(
187         ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
188
189
190 def uploadBuild(pocket, workdir):
191     """Upload all build products in the work directory."""
192     force = config.build.pockets[pocket].get('allow_backtracking', False)
193     apt = b.pocketToApt(pocket)
194     for changes in glob.glob(os.path.join(workdir, '*.changes')):
195         upload = ['reprepro-env', '--ignore=wrongdistribution',
196                   'include', apt, changes]
197         try:
198             logAndRun(upload)
199         except subprocess.CalledProcessError, e:
200             if not force:
201                 raise
202             package = deb822.Changes(open(changes).read())['Binary']
203             logAndRun(['reprepro-env', 'remove', apt, package])
204             logAndRun(upload)
205
206
207 def updateSuperproject(pocket, package, commit, principal, version, env):
208     """Update the superproject.
209
210     This will create a new commit on the branch for the given pocket
211     that sets the commit for the package submodule to commit.
212
213     Note that there's no locking issue here, because we disallow all
214     pushes to the superproject.
215     """
216     superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
217     branch = b.pocketToGit(pocket)
218     tree = logAndRun(['git', 'ls-tree', branch],
219                      cwd=superproject).strip()
220
221     tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
222
223     created = not (package in tree_items)
224
225     tree_items[package] = "160000 commit "+commit
226
227     # If "created" is true, we need to check if the package is
228     # mentioned in .gitmodules, and add it if not.
229     if created:
230         gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
231                                cwd=superproject)
232         if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
233             gitmodules += """[submodule "%s"]
234 \tpath = %s
235 \turl = ../packages/%s.git
236 """ % (package, package, package)
237             gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
238                                         cwd=superproject).strip()
239             tree_items[package] = "100644 blob "+gitmodules_hash
240
241     new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
242
243     new_tree_id = logAndRun(['git', 'mktree', '--missing'],
244                             cwd=superproject,
245                             stdin_str=new_tree).strip()
246
247     if created:
248         commit_msg = 'Add %s at version %s'
249     else:
250         commit_msg = 'Update %s to version %s'
251     commit_msg = ((commit_msg + '\n\n'
252                    'Requested by %s') % (package,
253                                          version.full_version,
254                                          principal))
255     new_commit = logAndRun(
256         ['git', 'commit-tree', new_tree_id, '-p', branch],
257         cwd=superproject,
258         env=env,
259         stdin_str=commit_msg).strip()
260
261     logAndRun(
262         ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
263         cwd=superproject)
264
265
266 def makeReadable(workdir):
267     os.chmod(workdir, 0755)
268
269 @contextlib.contextmanager
270 def packageWorkdir(package, commit):
271     """Checkout the package in a temporary working directory.
272
273     This context manager returns that working directory. The requested
274     package is checked out into a subdirectory of the working
275     directory with the same name as the package.
276
277     When the context wrapped with this context manager is exited, the
278     working directory is automatically deleted.
279     """
280     workdir = tempfile.mkdtemp()
281     try:
282         p_archive = subprocess.Popen(
283             ['git', 'archive',
284              '--remote=file://%s' % b.getRepo(package),
285              '--prefix=%s/' % package,
286              commit,
287              ],
288             stdout=subprocess.PIPE,
289             )
290         p_tar = subprocess.Popen(
291             ['tar', '-x'],
292             stdin=p_archive.stdout,
293             cwd=workdir,
294             )
295         p_archive.wait()
296         p_tar.wait()
297
298         yield workdir
299     finally:
300         shutil.rmtree(workdir)
301
302 def build():
303     """Deal with items in the build queue.
304
305     When triggered, iterate over build queue items one at a time,
306     until there are no more pending build jobs.
307     """
308     global logfile
309
310     while True:
311         stage = 'processing incoming job'
312         queue = os.listdir(b._QUEUE_DIR)
313         if not queue:
314             break
315
316         build = min(queue)
317         job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
318         pocket, package, commit, principal = job.split()
319
320         database.session.begin()
321         db = database.Build()
322         db.package = package
323         db.pocket = pocket
324         db.commit = commit
325         db.principal = principal
326         database.session.save_or_update(db)
327         database.session.commit()
328
329         database.session.begin()
330
331         logdir = os.path.join(b._LOG_DIR, str(db.build_id))
332         if not os.path.exists(logdir):
333             os.makedirs(logdir)
334
335         try:
336             db.failed_stage = 'validating job'
337             # Don't expand the commit in the DB until we're sure the user
338             # isn't trying to be tricky.
339             b.ensureValidPackage(package)
340
341             logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
342
343             db.commit = commit = b.canonicalize_commit(package, commit)
344             src = b.validateBuild(pocket, package, commit)
345             version = b.getVersion(package, commit)
346             db.version = str(version)
347             b.runHook('pre-build', [str(db.build_id)])
348
349             env = dict(os.environ)
350             env['GIT_COMMITTER_NAME'] = config.build.tagger.name
351             env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
352
353             # If validateBuild returns something other than True, then
354             # it means we should copy from that pocket to our pocket.
355             #
356             # (If the validation failed, validateBuild would have
357             # raised an exception)
358             if src != True:
359                 # TODO: cut out this code duplication
360                 db.failed_stage = 'tagging submodule before copying package'
361                 tagSubmodule(pocket, package, commit, principal, version, env)
362                 db.failed_stage = 'updating submodule branches before copying package'
363                 updateSubmoduleBranch(pocket, package, commit)
364                 db.failed_stage = 'updating superproject before copying package'
365                 updateSuperproject(pocket, package, commit, principal, version, env)
366                 db.failed_stage = 'copying package from another pocket'
367                 aptCopy(package, commit, pocket, src)
368                 
369             # If we can't copy the package from somewhere, but
370             # validateBuild didn't raise an exception, then we need to
371             # do the build ourselves
372             else:
373                 db.failed_stage = 'checking out package source'
374                 with packageWorkdir(package, commit) as workdir:
375                     db.failed_stage = 'preparing source package'
376                     packagedir = os.path.join(workdir, package)
377
378                     # We should be more clever about dealing with
379                     # things like non-Debian-native packages than we
380                     # are.
381                     #
382                     # If we were, we could use debuild and get nice
383                     # environment scrubbing. Since we're not, debuild
384                     # complains about not having an orig.tar.gz
385                     logAndRun(['dpkg-buildpackage', '-us', '-uc', '-S'],
386                               cwd=packagedir)
387
388                     db.failed_stage = 'building binary packages'
389                     sbuildAll(package, commit, b.pocketToDistro(pocket), workdir)
390                     db.failed_stage = 'tagging submodule'
391                     tagSubmodule(pocket, package, commit, principal, version, env)
392                     db.failed_stage = 'updating submodule branches'
393                     updateSubmoduleBranch(pocket, package, commit)
394                     db.failed_stage = 'updating superproject'
395                     updateSuperproject(pocket, package, commit, principal, version, env)
396                     db.failed_stage = 'relaxing permissions on workdir'
397                     makeReadable(workdir)
398                     db.failed_stage = 'uploading packages to apt repo'
399                     uploadBuild(pocket, workdir)
400
401                     db.failed_stage = 'cleaning up'
402         except:
403             db.traceback = traceback.format_exc()
404         else:
405             db.succeeded = True
406             db.failed_stage = None
407         finally:
408             if logfile is not None:
409                 logfile.close()
410
411             database.session.save_or_update(db)
412             database.session.commit()
413
414             # Finally, now that everything is done, remove the
415             # build queue item
416             os.unlink(os.path.join(b._QUEUE_DIR, build))
417
418             if db.succeeded:
419                 b.runHook('post-build', [str(db.build_id)])
420             else:
421                 b.runHook('failed-build', [str(db.build_id)])
422
423 class Invirtibuilder(pyinotify.ProcessEvent):
424     """Process inotify triggers to build new packages."""
425     def process_default(self, event):
426         """Handle an inotify event.
427
428         When an inotify event comes in, trigger the builder.
429         """
430         build()
431
432
433 def main():
434     """Initialize the inotifications and start the main loop."""
435     database.connect()
436
437     watch_manager = pyinotify.WatchManager()
438     invirtibuilder = Invirtibuilder()
439     notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
440     watch_manager.add_watch(b._QUEUE_DIR,
441                             pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
442                             pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
443
444     # Before inotifying, run any pending builds; otherwise we won't
445     # get notified for them.
446     build()
447
448     while True:
449         notifier.process_events()
450         if notifier.check_events():
451             notifier.read_events()
452
453
454 if __name__ == '__main__':
455     main()