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