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 getControl(package, ref):
54 """Get the parsed debian/control file for a given package.
56 This returns a list of debian_bundle.deb822.Deb822 objects, one
57 for each section of the debian/control file. Each Deb822 object
58 acts roughly like a dict.
60 return deb822.Deb822.iter_paragraphs(
61 b.getGitFile(package, ref, 'debian/control').split('\n'))
64 def getBinaries(package, ref):
65 """Get a list of binary packages in a package at a given ref."""
66 return [p['Package'] for p in getControl(package, ref)
70 def getArches(package, ref):
71 """Get the set of all architectures in any binary package."""
73 for section in getControl(package, ref):
74 if 'Architecture' in section:
75 arches.update(section['Architecture'].split())
79 def getDscName(package, ref):
80 """Return the .dsc file that will be generated for this package."""
81 v = b.getVersion(package, ref)
83 v_str = '%s-%s' % (v.upstream_version,
86 v_str = v.upstream_version
87 return '%s_%s.dsc' % (
92 def sanitizeVersion(version):
93 """Sanitize a Debian package version for use as a git tag.
95 This function strips the epoch from the version number and
96 replaces any tildes with underscores."""
97 if version.debian_version:
98 v = '%s-%s' % (version.upstream_version,
99 version.debian_version)
101 v = version.upstream_version
102 return v.replace('~', '_')
105 def aptCopy(package, commit, dst_pocket, src_pocket):
106 """Copy a package from one pocket to another."""
107 binaries = getBinaries(package, commit)
108 c.captureOutput(['reprepro-env', 'copy',
109 b.pocketToApt(dst_pocket),
110 b.pocketToApt(src_pocket),
114 def sbuild(package, ref, arch, workdir, arch_all=False):
115 """Build a package for a particular architecture."""
116 args = ['sbuild', '-v', '-d', DISTRIBUTION, '--arch', arch]
119 args.append(getDscName(package, ref))
120 c.captureOutput(args, cwd=workdir)
123 def sbuildAll(package, ref, workdir):
124 """Build a package for all architectures it supports."""
125 arches = getArches(package, ref)
126 if 'all' in arches or 'any' in arches or 'amd64' in arches:
127 sbuild(package, ref, 'amd64', workdir, arch_all=True)
128 if 'any' in arches or 'i386' in arches:
129 sbuild(package, ref, 'i386', workdir)
132 def tagSubmodule(pocket, package, commit, principal, version, env):
133 """Tag a new version of a submodule.
135 If this pocket does not allow_backtracking, then this will create
136 a new tag of the version at ref.
138 This function doesn't need to care about lock
139 contention. git-receive-pack updates one ref at a time, and only
140 takes out a lock for that ref after it's passed the update
141 hook. Because we reject pushes to tags in the update hook, no push
142 can ever take out a lock on any tags.
144 I'm sure that long description gives you great confidence in the
145 legitimacy of my reasoning.
147 if not config.build.pockets[pocket].get('allow_backtracking', False):
148 branch = b.pocketToGit(pocket)
149 tag_msg = ('Tag %s of %s\n\n'
150 'Requested by %s' % (version.full_version,
155 ['git', 'tag', '-m', tag_msg, '--', sanitizeVersion(version),
158 cwd=b.getRepo(package))
161 def updateSubmoduleBranch(pocket, package, commit):
162 """Update the appropriately named branch in the submodule."""
163 branch = b.pocketToGit(pocket)
165 ['git', 'update-ref', 'refs/heads/%s' % branch, commit], cwd=b.getRepo(package))
168 def uploadBuild(pocket, workdir):
169 """Upload all build products in the work directory."""
170 force = config.build.pockets[pocket].get('allow_backtracking', False)
171 apt = b.pocketToApt(pocket)
172 for changes in glob.glob(os.path.join(workdir, '*.changes')):
173 upload = ['reprepro-env', '--ignore=wrongdistribution',
174 'include', apt, changes]
176 c.captureOutput(upload)
177 except subprocess.CalledProcessError, e:
180 package = deb822.Changes(open(changes).read())['Binary']
181 c.captureOutput(['reprepro-env', 'remove', apt, package])
182 c.captureOutput(upload)
185 def updateSuperproject(pocket, package, commit, principal, version, env):
186 """Update the superproject.
188 This will create a new commit on the branch for the given pocket
189 that sets the commit for the package submodule to commit.
191 Note that there's no locking issue here, because we disallow all
192 pushes to the superproject.
194 superproject = os.path.join(b._REPO_DIR, 'invirt/packages.git')
195 branch = b.pocketToGit(pocket)
196 tree = c.captureOutput(['git', 'ls-tree', branch],
197 cwd=superproject).strip()
199 new_tree = re.compile(
200 r'^(160000 commit )[0-9a-f]*(\t%s)$' % package, re.M).sub(
201 r'\g<1>%s\g<2>' % commit,
204 new_tree_id = c.captureOutput(['git', 'mktree', '--missing'],
206 stdin_str=new_tree).strip()
208 commit_msg = ('Update %s to version %s\n\n'
209 'Requested by %s' % (package,
210 version.full_version,
212 new_commit = c.captureOutput(
213 ['git', 'commit-tree', new_tree_id, '-p', branch],
216 stdin_str=commit_msg).strip()
219 ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit],
223 def makeReadable(workdir):
224 os.chmod(workdir, 0755)
226 @contextlib.contextmanager
227 def packageWorkdir(package, commit):
228 """Checkout the package in a temporary working directory.
230 This context manager returns that working directory. The requested
231 package is checked out into a subdirectory of the working
232 directory with the same name as the package.
234 When the context wrapped with this context manager is exited, the
235 working directory is automatically deleted.
237 workdir = tempfile.mkdtemp()
239 p_archive = subprocess.Popen(
241 '--remote=file://%s' % b.getRepo(package),
242 '--prefix=%s/' % package,
245 stdout=subprocess.PIPE,
247 p_tar = subprocess.Popen(
249 stdin=p_archive.stdout,
257 shutil.rmtree(workdir)
260 """Deal with items in the build queue.
262 When triggered, iterate over build queue items one at a time,
263 until there are no more pending build jobs.
266 stage = 'processing incoming job'
267 queue = os.listdir(b._QUEUE_DIR)
272 job = open(os.path.join(b._QUEUE_DIR, build)).read().strip()
273 pocket, package, commit, principal = job.split()
275 database.session.begin()
276 db = database.Build()
280 db.principal = principal
281 database.session.save_or_update(db)
282 database.session.commit()
284 database.session.begin()
287 db.failed_stage = 'validating job'
288 # Don't expand the commit in the DB until we're sure the user
289 # isn't trying to be tricky.
290 b.ensureValidPackage(package)
291 db.commit = commit = b.canonicalize_commit(package, commit)
292 src = b.validateBuild(pocket, package, commit)
293 version = b.getVersion(package, commit)
294 db.version = str(version)
295 b.runHook('pre-build', [str(db.build_id), db.pocket, db.package,
296 db.commit, db.principal, db.version, str(db.inserted_at)])
298 env = dict(os.environ)
299 env['GIT_COMMITTER_NAME'] = config.build.tagger.name
300 env['GIT_COMMITTER_EMAIL'] = config.build.tagger.email
302 # If validateBuild returns something other than True, then
303 # it means we should copy from that pocket to our pocket.
305 # (If the validation failed, validateBuild would have
306 # raised an exception)
308 # TODO: cut out this code duplication
309 db.failed_stage = 'tagging submodule before copying package'
310 tagSubmodule(pocket, package, commit, principal, version, env)
311 db.failed_stage = 'updating submodule branches before copying package'
312 updateSubmoduleBranch(pocket, package, commit)
313 db.failed_stage = 'updating superproject before copying package'
314 updateSuperproject(pocket, package, commit, principal, version, env)
315 db.failed_stage = 'copying package from another pocket'
316 aptCopy(package, commit, pocket, src)
318 # If we can't copy the package from somewhere, but
319 # validateBuild didn't raise an exception, then we need to
320 # do the build ourselves
322 db.failed_stage = 'checking out package source'
323 with packageWorkdir(package, commit) as workdir:
324 db.failed_stage = 'preparing source package'
325 packagedir = os.path.join(workdir, package)
327 # We should be more clever about dealing with
328 # things like non-Debian-native packages than we
331 # If we were, we could use debuild and get nice
332 # environment scrubbing. Since we're not, debuild
333 # complains about not having an orig.tar.gz
334 c.captureOutput(['dpkg-buildpackage', '-us', '-uc', '-S'],
339 db.failed_stage = 'building binary packages'
340 sbuildAll(package, commit, workdir)
342 logdir = os.path.join(b._LOG_DIR, str(db.build_id))
343 if not os.path.exists(logdir):
346 for log in glob.glob(os.path.join(workdir, 'build-*.log')):
349 db.failed_stage = 'tagging submodule'
350 tagSubmodule(pocket, package, commit, principal, version, env)
351 db.failed_stage = 'updating submodule branches'
352 updateSubmoduleBranch(pocket, package, commit)
353 db.failed_stage = 'updating superproject'
354 updateSuperproject(pocket, package, commit, principal, version, env)
355 db.failed_stage = 'relaxing permissions on workdir'
356 makeReadable(workdir)
357 db.failed_stage = 'uploading packages to apt repo'
358 uploadBuild(pocket, workdir)
360 db.failed_stage = 'cleaning up'
362 db.traceback = traceback.format_exc()
365 db.failed_stage = None
367 database.session.save_or_update(db)
368 database.session.commit()
370 # Finally, now that everything is done, remove the
372 os.unlink(os.path.join(b._QUEUE_DIR, build))
375 b.runHook('post-build', [str(db.build_id)])
377 b.runHook('failed-build', [str(db.build_id)])
379 class Invirtibuilder(pyinotify.ProcessEvent):
380 """Process inotify triggers to build new packages."""
381 def process_default(self, event):
382 """Handle an inotify event.
384 When an inotify event comes in, trigger the builder.
390 """Initialize the inotifications and start the main loop."""
393 watch_manager = pyinotify.WatchManager()
394 invirtibuilder = Invirtibuilder()
395 notifier = pyinotify.Notifier(watch_manager, invirtibuilder)
396 watch_manager.add_watch(b._QUEUE_DIR,
397 pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE'] |
398 pyinotify.EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
400 # Before inotifying, run any pending builds; otherwise we won't
401 # get notified for them.
405 notifier.process_events()
406 if notifier.check_events():
407 notifier.read_events()
410 if __name__ == '__main__':