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