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