#!/usr/bin/python """Process the Invirt build queue. The Invirtibuilder handles package builds and uploads. On demand, it attempts to build a particular package. If the build succeeds, the new version of the package is uploaded to the apt repository, tagged in its git repository, and the Invirt superrepo is updated to point at the new version. If the build fails, the Invirtibuilder sends mail with the build log. The build queue is tracked via files in /var/lib/invirt-dev/queue. In order to maintain ordering, all filenames in that directory are the timestamp of their creation time. Each queue file contains a file of the form pocket package hash principal where pocket is one of the pockets globally configured in git.pockets. For instance, the pockets in XVM are "prod" and "dev". principal is the Kerberos principal that requested the build. """ import contextlib import os import re import shutil import subprocess import pyinotify from invirt.config import structs as config from invirt import database _QUEUE_DIR = '/var/lib/invirt-dev/queue' _REPO_DIR = '/srv/git' _LOG_DIR = '/var/log/invirt/builds' _HOOKS_DIR = '/usr/share/invirt-dev/build.d' DISTRIBUTION = 'hardy' class InvalidBuild(ValueError): pass def captureOutput(popen_args, stdin_str=None, *args, **kwargs): """Capture stdout from a command. This method will proxy the arguments to subprocess.Popen. It returns the output from the command if the call succeeded and raises an exception if the process returns a non-0 value. This is intended to be a variant on the subprocess.check_call function that also allows you access to the output from the command. """ if 'stdin' not in kwargs: kwargs['stdin'] = subprocess.PIPE if 'stdout' not in kwargs: kwargs['stdout'] = subprocess.PIPE if 'stderr' not in kwargs: kwargs['stderr'] = subprocess.STDOUT p = subprocess.Popen(popen_args, *args, **kwargs) out, _ = p.communicate(stdin_str) if p.returncode: raise subprocess.CalledProcessError(p.returncode, popen_args, out) return out def getRepo(package): """Return the path to the git repo for a given package.""" return os.path.join(_REPO_DIR, 'packages', '%s.git' % package) def pocketToGit(pocket): """Map a pocket in the configuration to a git branch.""" return config.git.pockets[pocket].get('git', pocket) def pocketToApt(pocket): """Map a pocket in the configuration to an apt repo pocket.""" return config.git.pockets[pocket].get('apt', pocket) def getGitFile(package, ref, path): """Return the contents of a path from a git ref in a package.""" return captureOutput(['git', 'cat-file', 'blob', '%s:%s' % (ref, path)], cwd=getRepo(package)) def getChangelog(package, ref): """Get a changelog object for a given ref in a given package. This returns a debian_bundle.changelog.Changelog object for a given ref of a given package. """ return changelog.Changelog(getGitFile(package, ref, 'debian/changelog')) def getVersion(package, ref): """Get the version of a given package at a particular ref.""" return getChangelog(package, ref).get_version() def getControl(package, ref): """Get the parsed debian/control file for a given package. This returns a list of debian_bundle.deb822.Deb822 objects, one for each section of the debian/control file. Each Deb822 object acts roughly like a dict. """ return deb822.Deb822.iter_paragraphs( getGitFile(package, ref, 'debian/control').split('\n')) def getBinaries(package, ref): """Get a list of binary packages in a package at a given ref.""" return [p['Package'] for p in getControl(package, ref) if 'Package' in p] def getArches(package, ref): """Get the set of all architectures in any binary package.""" arches = set() for section in getControl(package, ref): if 'Architecture' in section: arches.update(section['Architecture'].split()) return arches def getDscName(package, ref): """Return the .dsc file that will be generated for this package.""" v = getVersion(package, ref) return '%s_%s-%s.dsc' % ( package, version.upstream_version, version.debian_version) def validateBuild(pocket, package, commit): """Given the parameters of a new build, validate that build. The checks this function performs vary based on whether or not the pocket is configured with allow_backtracking. A build of a pocket without allow_backtracking set must be a fast-forward of the previous revision, and the most recent version in the changelog most be strictly greater than the version currently in the repository. In all cases, this revision of the package can only have the same version number as any other revision currently in the apt repository if they have the same commit ID. If it's unspecified, it is assumed that pocket do not allow_backtracking. If this build request fails validation, this function will raise a InvalidBuild exception, with information about why the validation failed. If this build request can be satisfied by copying the package from another pocket, then this function returns that pocket. Otherwise, it returns True. """ package_repo = getRepo(package) new_version = getVersion(package, commit) for p in config.git.pockets: if p == pocket: continue b = pocketToGit(p) current_commit = captureOutput(['git', 'rev-parse', b], cwd=package_repo) current_version = getVersion(package, b) if current_version == new_version: if current_commit == commit: return p else: raise InvalidBuild('Version %s of %s already available in ' 'pocket %s from commit %s' % (new_version, package, p, current_commit)) if config.git.pockets[pocket].get('allow_backtracking', False): branch = pocketToGit(pocket) current_version = getVersion(package, branch) if new_version <= current_version: raise InvalidBuild('New version %s of %s is not newer than ' 'version %s currently in pocket %s' % (new_version, package, current_version, pocket)) # Almost by definition, A is a fast-forward of B if B..A is # empty if not captureOutput(['git', 'rev-list', '%s..%s' % (commit, branch)]): raise InvalidBuild('New commit %s of %s is not a fast-forward of' 'commit currently in pocket %s' % (commit, package, pocket)) def sanitizeVersion(version): """Sanitize a Debian package version for use as a git tag. This function strips the epoch from the version number and replaces any tildes with periods.""" v = '%s-%s' % (version.upstream_version, version.debian_version) return v.replace('~', '.') def aptCopy(packages, dst_pocket, src_pocket): """Copy a package from one pocket to another.""" binaries = [] for line in getGitFile(package, commit, 'debian/control').split('\n'): m = re.match('Package: (.*)$') if m: binaries.append(m.group(1)) cpatureOutput(['reprepro-env', 'copy', pocketToApt(dst_pocket), pocketToApt(src_pocket), package] + binaries) def sbuild(package, ref, arch, workdir, arch_all=False): """Build a package for a particular architecture.""" args = ['sbuild', '-d', DISTRIBUTION, '--arch', arch] if arch_all: args.append('-A') args.append(getDscName(package, ref)) captureOutput(args, cwd=workdir, stdout=None) def sbuildAll(package, ref, workdir): """Build a package for all architectures it supports.""" arches = getArches(package, ref) if 'all' in arches or 'any' in arches or 'amd64' in arches: sbuild(package, ref, 'amd64', workdir, arch_all=True) if 'any' in arches or 'i386' in arches: sbuild(package, ref, 'i386', workdir) def tagSubmodule(pocket, package, ref, principal): """Tag a new version of a submodule. If this pocket does not allow_backtracking, then this will create a new tag of the version at ref. This function doesn't need to care about lock contention. git-receive-pack updates one ref at a time, and only takes out a lock for that ref after it's passed the update hook. Because we reject pushes to tags in the update hook, no push can ever take out a lock on any tags. I'm sure that long description gives you great confidence in teh legitimacy of my reasoning. """ if config.git.pockets[pocket].get('allow_backtracking', False): env = dict(os.environ) branch = pocketToGit(pocket) version = getVersion(package, ref) env['GIT_COMMITTER_NAME'] = config.git.tagger.name env['GIT_COMMITTER_EMAIL'] = config.git.tagger.email tag_msg = ('Tag %s of %s\n\n' 'Requested by %s' % (version.full_version, package, principal)) captureOutput( ['git', 'tag', '-m', tag_msg, commit], stdout=None, env=env) def updateSubmoduleBranch(pocket, package, ref): """Update the appropriately named branch in the submodule.""" branch = pocketToGit(pocket) captureOutput( ['git', 'update-ref', 'refs/heads/%s' % branch, ref]) def uploadBuild(pocket, workdir): """Upload all build products in the work directory.""" apt = pocketToApt(pocket) for changes in glob.glob(os.path.join(workdir, '*.changes')): captureOutput(['reprepro-env', 'include', '--ignore=wrongdistribution', apt, changes]) def updateSuperrepo(pocket, package, commit, principal): """Update the superrepo. This will create a new commit on the branch for the given pocket that sets the commit for the package submodule to commit. Note that there's no locking issue here, because we disallow all pushes to the superrepo. """ superrepo = os.path.join(_REPO_DIR, 'packages.git') branch = pocketToGit(pocket) tree = captureOutput(['git', 'ls-tree', branch], cwd=superrepo) new_tree = re.compile( r'^(160000 commit )[0-9a-f]*(\t%s)$' % package, re.M).sub( r'\1%s\2' % commit, tree) new_tree_id = captureOutput(['git', 'mktree'], cwd=superrepo, stdin_str=new_tree) commit_msg = ('Update %s to version %s\n\n' 'Requested by %s' % (package, version.full_version, principal)) new_commit = captureOutput( ['git', 'commit-tree', new_tree_hash, '-p', branch], cwd=superrepo, env=env, stdin_str=commit_msg) captureOutput( ['git', 'update-ref', 'refs/heads/%s' % branch, new_commit], cwd=superrepo) @contextlib.contextmanager def packageWorkdir(package): """Checkout the package in a temporary working directory. This context manager returns that working directory. The requested package is checked out into a subdirectory of the working directory with the same name as the package. When the context wrapped with this context manager is exited, the working directory is automatically deleted. """ workdir = tempfile.mkdtemp() try: p_archive = subprocess.Popen( ['git', 'archive', '--remote=file://%s' % getRepo(package), '--prefix=%s' % package, commit, ], stdout=subprocess.PIPE, ) p_tar = subprocess.Popen( ['tar', '-x'], stdin=p_archive.stdout, cwd=workdir, ) p_archive.wait() p_tar.wait() yield workdir finally: shutil.rmtree(workdir) def reportBuild(build): """Run hooks to report the results of a build attempt.""" captureOutput(['run-parts', '--arg=%s' % build.build_id, '--', _HOOKS_DIR]) def build(): """Deal with items in the build queue. When triggered, iterate over build queue items one at a time, until there are no more pending build jobs. """ while True: stage = 'processing incoming job' queue = os.listdir(_QUEUE_DIR) if not queue: break build = min(queue) job = open(os.path.join(_QUEUE_DIR, build)).read().strip() pocket, package, commit, principal = job.split() database.session.begin() db = database.Build() db.package = package db.pocket = pocket db.commit = commit db.principal = principal database.session.save_or_update(db) database.commit() database.begin() try: db.failed_stage = 'validating job' src = validateBuild(pocket, package, commit) db.version = str(getVersion(package, commit)) # If validateBuild returns something other than True, then # it means we should copy from that pocket to our pocket. # # (If the validation failed, validateBuild would have # raised an exception) if src != True: db.failed_stage = 'copying package from another pocket' aptCopy(packages, pocket, src) # If we can't copy the package from somewhere, but # validateBuild didn't raise an exception, then we need to # do the build ourselves else: db.failed_stage = 'checking out package source' with packageWorkdir(package) as workdir: db.failed_stage = 'preparing source package' packagedir = os.path.join(workdir, package) # We should be more clever about dealing with # things like non-Debian-native packages than we # are. # # If we were, we could use debuild and get nice # environment scrubbing. Since we're not, debuild # complains about not having an orig.tar.gz captureOutput(['dpkg-buildpackage', '-us', '-uc', '-S'], cwd=packagedir, stdout=None) try: db.failed_stage = 'building binary packages' sbuildAll(package, commit, workdir) finally: logdir = os.path.join(_LOG_DIR, db.build_id) if not os.path.exists(logdir): os.makedirs(logdir) for log in glob.glob(os.path.join(workdir, '*.build')): os.copy2(log, logdir) db.failed_stage = 'tagging submodule' tagSubmodule(pocket, package, commit, principal) db.failed_stage = 'updating submodule branches' updateSubmoduleBranch(pocket, package, commit) db.failed_stage = 'updating superrepo' updateSuperrepo(pocket, package, commit, principal) db.failed_stage = 'uploading packages to apt repo' uploadBuild(pocket, workdir) db.failed_stage = 'cleaning up' # Finally, now that everything is done, remove the # build queue item os.unlink(os.path.join(_QUEUE_DIR, build)) except: db.traceback = traceback.format_exc() else: db.succeeded = True db.failed_stage = None finally: database.session.save_or_update(db) database.session.commit() reportBuild(db) class Invirtibuilder(pyinotify.ProcessEvent): """Process inotify triggers to build new packages.""" def process_IN_CREATE(self, event): """Handle a created file or directory. When an IN_CREATE event comes in, trigger the builder. """ build() def main(): """Initialize the inotifications and start the main loop.""" database.connect() watch_manager = pyinotify.WatchManager() invirtibuilder = Invirtibuilder() notifier = pyinotify.Notifier(watch_manager, invirtibuilder) watch_manager.add_watch(_QUEUE_DIR, pyinotify.EventsCodes.ALL_FLAGS['IN_CREATE']) # Before inotifying, run any pending builds; otherwise we won't # get notified for them. build() while True: notifier.process_events() if notifier.check_events(): notifier.read_events() if __name__ == '__main__': main()