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