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