Properly update the superrepo's .gitmodules file
[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             package = deb822.Changes(open(changes).read())['Binary']
206             logAndRun(['reprepro-env', 'remove', apt, package])
207             logAndRun(upload)
208
209
210 def updateSuperproject(pocket, package, commit, principal, version, env):
211     """Update the superproject.
212
213     This will create a new commit on the branch for the given pocket
214     that sets the commit for the package submodule to commit.
215
216     Note that there's no locking issue here, because we disallow all
217     pushes to the superproject.
218     """
219     superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
220     branch = b.pocketToGit(pocket)
221     tree = logAndRun(['git', 'ls-tree', branch],
222                      cwd=superproject).strip()
223
224     tree_items = dict((k, v) for (v, k) in (x.split("\t") for x in tree.split("\n")))
225
226     created = not (package in tree_items)
227
228     tree_items[package] = "160000 commit "+commit
229
230     # If "created" is true, we need to check if the package is
231     # mentioned in .gitmodules, and add it if not.
232     if created:
233         gitmodules = logAndRun(['git', 'cat-file', 'blob', '%s:.gitmodules' % (branch)],
234                                cwd=superproject)
235         if ('[submodule "%s"]' % (package)) not in gitmodules.split("\n"):
236             gitmodules += """[submodule "%s"]
237 \tpath = %s
238 \turl = ../packages/%s.git
239 """ % (package, package, package)
240             gitmodules_hash = logAndRun(['git', 'hash-object', '-w', '--stdin'],
241                                         cwd=superproject,
242                                         stdin_str=gitmodules).strip()
243             tree_items[package] = "100644 blob "+gitmodules_hash
244
245     new_tree = "\n".join("%s\t%s" % (v, k) for (k, v) in tree_items.iteritems())
246
247     new_tree_id = logAndRun(['git', 'mktree', '--missing'],
248                             cwd=superproject,
249                             stdin_str=new_tree).strip()
250
251     if created:
252         commit_msg = 'Add %s at version %s'
253     else:
254         commit_msg = 'Update %s to version %s'
255     commit_msg = ((commit_msg + '\n\n'
256                    'Requested by %s') % (package,
257                                          version.full_version,
258                                          principal))
259     new_commit = logAndRun(
260         ['git', 'commit-tree', new_tree_id, '-p', branch],
261         cwd=superproject,
262         env=env,
263         stdin_str=commit_msg).strip()
264
265     logAndRun(
266         ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
267         cwd=superproject)
268
269
270 def makeReadable(workdir):
271     os.chmod(workdir, 0755)
272
273 @contextlib.contextmanager
274 def packageWorkdir(package, commit, build_id):
275     """Checkout the package in a temporary working directory.
276
277     This context manager returns that working directory. The requested
278     package is checked out into a subdirectory of the working
279     directory with the same name as the package.
280
281     When the context wrapped with this context manager is exited, the
282     working directory is automatically deleted.
283     """
284     workdir = tempfile.mkdtemp(prefix=("b%d-" % build_id))
285     try:
286         p_archive = subprocess.Popen(
287             ['git', 'archive',
288              '--remote=file://%s' % b.getRepo(package),
289              '--prefix=%s/' % package,
290              commit,
291              ],
292             stdout=subprocess.PIPE,
293             )
294         p_tar = subprocess.Popen(
295             ['tar', '-x'],
296             stdin=p_archive.stdout,
297             cwd=workdir,
298             )
299         p_archive.wait()
300         p_tar.wait()
301
302         yield workdir
303     finally:
304         shutil.rmtree(workdir)
305
306 def build():
307     """Deal with items in the build queue.
308
309     When triggered, iterate over build queue items one at a time,
310     until there are no more pending build jobs.
311     """
312     global logfile
313
314     while True:
315         stage = 'processing incoming job'
316         queue = os.listdir(b._QUEUE_DIR)
317         if not queue:
318             break
319
320         build = min(queue)
321         job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
322         pocket, package, commit, principal = job.split()
323
324         database.session.begin()
325         db = database.Build()
326         db.package = package
327         db.pocket = pocket
328         db.commit = commit
329         db.principal = principal
330         database.session.save_or_update(db)
331         database.session.commit()
332
333         database.session.begin()
334
335         logdir = os.path.join(b._LOG_DIR, str(db.build_id))
336         if not os.path.exists(logdir):
337             os.makedirs(logdir)
338
339         try:
340             db.failed_stage = 'validating job'
341             # Don't expand the commit in the DB until we're sure the user
342             # isn't trying to be tricky.
343             b.ensureValidPackage(package)
344
345             logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
346
347             db.commit = commit = b.canonicalize_commit(package, commit)
348             src = b.validateBuild(pocket, package, commit)
349             version = b.getVersion(package, commit)
350             db.version = str(version)
351             b.runHook('pre-build', [str(db.build_id)])
352
353             env = dict(os.environ)
354             env['GIT_COMMITTER_NAME'] = config.build.tagger.name
355             env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
356
357             # If validateBuild returns something other than True, then
358             # it means we should copy from that pocket to our pocket.
359             #
360             # (If the validation failed, validateBuild would have
361             # raised an exception)
362             if src != True:
363                 # TODO: cut out this code duplication
364                 db.failed_stage = 'tagging submodule before copying package'
365                 tagSubmodule(pocket, package, commit, principal, version, env)
366                 db.failed_stage = 'updating submodule branches before copying package'
367                 updateSubmoduleBranch(pocket, package, commit)
368                 db.failed_stage = 'updating superproject before copying package'
369                 updateSuperproject(pocket, package, commit, principal, version, env)
370                 db.failed_stage = 'copying package from another pocket'
371                 aptCopy(package, commit, pocket, src)
372                 
373             # If we can't copy the package from somewhere, but
374             # validateBuild didn't raise an exception, then we need to
375             # do the build ourselves
376             else:
377                 db.failed_stage = 'checking out package source'
378                 with packageWorkdir(package, commit, db.build_id) as workdir:
379                     db.failed_stage = 'preparing source package'
380                     packagedir = os.path.join(workdir, package)
381
382                     # We should be more clever about dealing with
383                     # things like non-Debian-native packages than we
384                     # are.
385                     #
386                     # If we were, we could use debuild and get nice
387                     # environment scrubbing. Since we're not, debuild
388                     # complains about not having an orig.tar.gz
389                     logAndRun(['dpkg-buildpackage', '-us', '-uc', '-S'],
390                               cwd=packagedir)
391
392                     db.failed_stage = 'building binary packages'
393                     sbuildAll(package, commit, b.pocketToDistro(pocket), workdir)
394                     db.failed_stage = 'tagging submodule'
395                     tagSubmodule(pocket, package, commit, principal, version, env)
396                     db.failed_stage = 'updating submodule branches'
397                     updateSubmoduleBranch(pocket, package, commit)
398                     db.failed_stage = 'updating superproject'
399                     updateSuperproject(pocket, package, commit, principal, version, env)
400                     db.failed_stage = 'relaxing permissions on workdir'
401                     makeReadable(workdir)
402                     db.failed_stage = 'uploading packages to apt repo'
403                     uploadBuild(pocket, workdir)
404
405                     db.failed_stage = 'cleaning up'
406         except:
407             db.traceback = traceback.format_exc()
408         else:
409             db.succeeded = True
410             db.failed_stage = None
411         finally:
412             if logfile is not None:
413                 logfile.close()
414
415             database.session.save_or_update(db)
416             database.session.commit()
417
418             # Finally, now that everything is done, remove the
419             # build queue item
420             os.unlink(os.path.join(b._QUEUE_DIR, build))
421
422             if db.succeeded:
423                 b.runHook('post-build', [str(db.build_id)])
424             else:
425                 b.runHook('failed-build', [str(db.build_id)])
426
427 class Invirtibuilder(pyinotify.ProcessEvent):
428     """Process inotify triggers to build new packages."""
429     def process_default(self, event):
430         """Handle an inotify event.
431
432         When an inotify event comes in, trigger the builder.
433         """
434         build()
435
436
437 def main():
438     """Initialize the inotifications and start the main loop."""
439     database.connect()
440
441     watch_manager = pyinotify.WatchManager()
442     invirtibuilder = Invirtibuilder()
443     notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
444     watch_manager.add_watch(b._QUEUE_DIR,
445                             pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
446                             pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
447
448     # Before inotifying, run any pending builds; otherwise we won't
449     # get notified for them.
450     build()
451
452     while True:
453         notifier.process_events()
454         if notifier.check_events():
455             notifier.read_events()
456
457
458 if __name__ == '__main__':
459     main()