3 """Process the Invirt build queue.
5 The Invirtibuilder handles package builds and uploads. On demand, it
6 attempts to build a particular package.
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.
12 If the build fails, the Invirtibuilder sends mail with the build log.
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.
18 Each queue file contains a file of the form
20 pocket package hash principal
22 where pocket is one of the pockets globally configured in
23 build.pockets. For instance, the pockets in XVM are "prod" and "dev".
25 principal is the Kerberos principal that requested the build.
29 from __future__ import with_statement
42 from debian_bundle import deb822
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
50 DISTRIBUTION = 'hardy'
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:
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')
66 def getControl(package, ref):
67 """Get the parsed debian/control file for a given package.
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.
73 return deb822.Deb822.iter_paragraphs(
74 b.getGitFile(package, ref, 'debian/control').split('\n'))
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)
83 def getArches(package, ref):
84 """Get the set of all architectures in any binary package."""
86 for section in getControl(package, ref):
87 if 'Architecture' in section:
88 arches.update(section['Architecture'].split())
92 def getDscName(package, ref):
93 """Return the .dsc file that will be generated for this package."""
94 v = b.getVersion(package, ref)
96 v_str = '%s-%s' % (v.upstream_version,
99 v_str = v.upstream_version
100 return '%s_%s.dsc' % (
105 def sanitizeVersion(version):
106 """Sanitize a Debian package version for use as a git tag.
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)
114 v = version.upstream_version
115 return v.replace('~', '_')
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),
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]
132 args.append(getDscName(package, ref))
133 logAndRun(args, cwd=workdir)
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)
145 def tagSubmodule(pocket, package, commit, principal, version, env):
146 """Tag a new version of a submodule.
148 If this pocket does not allow_backtracking, then this will create
149 a new tag of the version at ref.
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.
157 I'm sure that long description gives you great confidence in the
158 legitimacy of my reasoning.
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,
168 ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
171 cwd=b.getRepo(package))
174 def updateSubmoduleBranch(pocket, package, commit):
175 """Update the appropriately named branch in the submodule."""
176 branch = b.pocketToGit(pocket)
178 ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
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]
190 except subprocess.CalledProcessError, e:
193 package = deb822.Changes(open(changes).read())['Binary']
194 logAndRun(['reprepro-env', 'remove', apt, package])
198 def updateSuperproject(pocket, package, commit, principal, version, env):
199 """Update the superproject.
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.
204 Note that there's no locking issue here, because we disallow all
205 pushes to the superproject.
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()
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,
217 new_tree_id = logAndRun(['git', 'mktree', '--missing'],
219 stdin_str=new_tree).strip()
221 commit_msg = ('Update %s to version %s\n\n'
222 'Requested by %s' % (package,
223 version.full_version,
225 new_commit = logAndRun(
226 ['git', 'commit-tree', new_tree_id, '-p', branch],
229 stdin_str=commit_msg).strip()
232 ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
236 def makeReadable(workdir):
237 os.chmod(workdir, 0755)
239 @contextlib.contextmanager
240 def packageWorkdir(package, commit):
241 """Checkout the package in a temporary working directory.
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.
247 When the context wrapped with this context manager is exited, the
248 working directory is automatically deleted.
250 workdir = tempfile.mkdtemp()
252 p_archive = subprocess.Popen(
254 '--remote=file://%s' % b.getRepo(package),
255 '--prefix=%s/' % package,
258 stdout=subprocess.PIPE,
260 p_tar = subprocess.Popen(
262 stdin=p_archive.stdout,
270 shutil.rmtree(workdir)
273 """Deal with items in the build queue.
275 When triggered, iterate over build queue items one at a time,
276 until there are no more pending build jobs.
281 stage = 'processing incoming job'
282 queue = os.listdir(b._QUEUE_DIR)
287 job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
288 pocket, package, commit, principal = job.split()
290 database.session.begin()
291 db = database.Build()
295 db.principal = principal
296 database.session.save_or_update(db)
297 database.session.commit()
299 database.session.begin()
301 logdir = os.path.join(b._LOG_DIR, str(db.build_id))
302 if not os.path.exists(logdir):
306 db.failed_stage = 'validating job'
307 # Don't expand the commit in the DB until we're sure the user
308 # isn't trying to be tricky.
309 b.ensureValidPackage(package)
311 logfile = open(os.path.join(logdir, '%s.log' % db.package), 'w')
313 db.commit = commit = b.canonicalize_commit(package, commit)
314 src = b.validateBuild(pocket, package, commit)
315 version = b.getVersion(package, commit)
316 db.version = str(version)
317 b.runHook('pre-build', [str(db.build_id)])
319 env = dict(os.environ)
320 env['GIT_COMMITTER_NAME'] = config.build.tagger.name
321 env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
323 # If validateBuild returns something other than True, then
324 # it means we should copy from that pocket to our pocket.
326 # (If the validation failed, validateBuild would have
327 # raised an exception)
329 # TODO: cut out this code duplication
330 db.failed_stage = 'tagging submodule before copying package'
331 tagSubmodule(pocket, package, commit, principal, version, env)
332 db.failed_stage = 'updating submodule branches before copying package'
333 updateSubmoduleBranch(pocket, package, commit)
334 db.failed_stage = 'updating superproject before copying package'
335 updateSuperproject(pocket, package, commit, principal, version, env)
336 db.failed_stage = 'copying package from another pocket'
337 aptCopy(package, commit, pocket, src)
339 # If we can't copy the package from somewhere, but
340 # validateBuild didn't raise an exception, then we need to
341 # do the build ourselves
343 db.failed_stage = 'checking out package source'
344 with packageWorkdir(package, commit) as workdir:
345 db.failed_stage = 'preparing source package'
346 packagedir = os.path.join(workdir, package)
348 # We should be more clever about dealing with
349 # things like non-Debian-native packages than we
352 # If we were, we could use debuild and get nice
353 # environment scrubbing. Since we're not, debuild
354 # complains about not having an orig.tar.gz
355 logAndRun(['dpkg-buildpackage', '-us', '-uc', '-S'],
358 db.failed_stage = 'building binary packages'
359 sbuildAll(package, commit, workdir)
360 db.failed_stage = 'tagging submodule'
361 tagSubmodule(pocket, package, commit, principal, version, env)
362 db.failed_stage = 'updating submodule branches'
363 updateSubmoduleBranch(pocket, package, commit)
364 db.failed_stage = 'updating superproject'
365 updateSuperproject(pocket, package, commit, principal, version, env)
366 db.failed_stage = 'relaxing permissions on workdir'
367 makeReadable(workdir)
368 db.failed_stage = 'uploading packages to apt repo'
369 uploadBuild(pocket, workdir)
371 db.failed_stage = 'cleaning up'
373 db.traceback = traceback.format_exc()
376 db.failed_stage = None
378 if logfile is not None:
381 database.session.save_or_update(db)
382 database.session.commit()
384 # Finally, now that everything is done, remove the
386 os.unlink(os.path.join(b._QUEUE_DIR, build))
389 b.runHook('post-build', [str(db.build_id)])
391 b.runHook('failed-build', [str(db.build_id)])
393 class Invirtibuilder(pyinotify.ProcessEvent):
394 """Process inotify triggers to build new packages."""
395 def process_default(self, event):
396 """Handle an inotify event.
398 When an inotify event comes in, trigger the builder.
404 """Initialize the inotifications and start the main loop."""
407 watch_manager = pyinotify.WatchManager()
408 invirtibuilder = Invirtibuilder()
409 notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
410 watch_manager.add_watch(b._QUEUE_DIR,
411 pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
412 pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
414 # Before inotifying, run any pending builds; otherwise we won't
415 # get notified for them.
419 notifier.process_events()
420 if notifier.check_events():
421 notifier.read_events()
424 if __name__ == '__main__':