Bug 1162191 - Add |mach artifact| for installing downloaded Fennec binaries. r=gps

DONTBUILD ON A CLOSED TREE: Android-only and the build changes are cosmetic.

Very much a first cut, but I'd like to get some Fennec early adopters testing.

This adds:

* |mach artifact install| to fetch and install Fennec binaries;
* |mach artifact last| to print details about what was last installed;
* |mach artifact {print,clear}-caches|, for debugging.

This code is exposed as a new mozbuild.artifacts Python but it's not
particularly general.  My intention was to get things out of the mach command
more than produce a general artifact fetching API.  We can leave that bike
shed to Bug 1124378.

I've been testing this with --disable-compile-environment and it works well
locally, although there's no reason a knowledgeable developer couldn't use
this in conjunction with a fully-built tree.  (I don't know when such a
situation would arise, but I know of no technical impediment.)

--HG--
extra : commitid : 1T28aVfArqF
extra : rebase_source : b8c11244de8be0a14d605853f30cd47312d0a4ba
extra : histedit_source : 78a224501cd3cf0f86707c9c9549b61b4b248ba7
This commit is contained in:
Nick Alexander 2015-06-24 23:12:00 -07:00
parent 15c38ccf08
commit 0a6017a1af
5 changed files with 526 additions and 4 deletions

View File

@ -23,6 +23,7 @@ from mach.decorators import (
CommandArgument,
CommandProvider,
Command,
SubCommand,
)
SUCCESS = '''
@ -35,6 +36,7 @@ and in IntelliJ select File > Import project... and choose
{topobjdir}/mobile/android/gradle
'''
@CommandProvider
class MachCommands(MachCommandBase):
@Command('gradle', category='devenv',
@ -54,7 +56,6 @@ class MachCommands(MachCommandBase):
ensure_exit_code=False, # Don't throw on non-zero exit code.
cwd=mozpath.join(self.topobjdir, 'mobile', 'android', 'gradle'))
@Command('gradle-install', category='devenv',
description='Install gradle environment.',
conditions=[conditions.is_android])
@ -162,3 +163,104 @@ class MachCommands(MachCommandBase):
print(SUCCESS.format(topobjdir=self.topobjdir))
return code
@CommandProvider
class PackageFrontend(MachCommandBase):
"""Fetch and install binary artifacts from Mozilla automation."""
@Command('artifact', category='post-build',
description='Use pre-built artifacts to build Fennec.',
conditions=[
conditions.is_android, # mobile/android only for now.
conditions.is_hg, # mercurial only for now.
])
def artifact(self):
'''Download, cache, and install pre-built binary artifacts to build Fennec.
Invoke |mach artifact| before each |mach package| to freshen your installed
binary libraries. That is, package using
mach artifact install && mach package
to download, cache, and install binary artifacts from Mozilla automation,
replacing whatever may be in your object directory. Use |mach artifact last|
to see what binary artifacts were last used.
Never build libxul again!
'''
pass
def _make_artifacts(self, tree=None, job=None):
self.log_manager.terminal_handler.setLevel(logging.INFO)
self._activate_virtualenv()
self.virtualenv_manager.install_pip_package('pylru==1.0.9')
self.virtualenv_manager.install_pip_package('taskcluster==0.0.16')
state_dir = self._mach_context.state_dir
cache_dir = os.path.join(state_dir, 'package-frontend')
import which
hg = which.which('hg')
# Absolutely must come after the virtualenv is populated!
from mozbuild.artifacts import Artifacts
artifacts = Artifacts(tree, job, log=self.log, cache_dir=cache_dir, hg=hg)
return artifacts
@SubCommand('artifact', 'install',
'Install a good pre-built artifact.')
@CommandArgument('--tree', metavar='TREE', type=str,
help='Firefox tree.',
default='fx-team') # TODO: switch to central as this stabilizes.
@CommandArgument('--job', metavar='JOB', choices=['android-api-11'],
help='Build job.',
default='android-api-11') # TODO: fish job from build configuration.
@CommandArgument('source', metavar='SRC', nargs='?', type=str,
help='Where to fetch and install artifacts from. Can be omitted, in '
'which case the current hg repository is inspected; an hg revision; '
'a remote URL; or a local file.',
default=None)
def artifact_install(self, source=None, tree=None, job=None):
artifacts = self._make_artifacts(tree=tree, job=job)
return artifacts.install_from(source, self.distdir)
@SubCommand('artifact', 'last',
'Print the last pre-built artifact installed.')
@CommandArgument('--tree', metavar='TREE', type=str,
help='Firefox tree.',
default='fx-team')
@CommandArgument('--job', metavar='JOB', type=str,
help='Build job.',
default='android-api-11')
def artifact_print_last(self, tree=None, job=None):
artifacts = self._make_artifacts(tree=tree, job=job)
artifacts.print_last()
return 0
@SubCommand('artifact', 'print-cache',
'Print local artifact cache for debugging.')
@CommandArgument('--tree', metavar='TREE', type=str,
help='Firefox tree.',
default='fx-team')
@CommandArgument('--job', metavar='JOB', type=str,
help='Build job.',
default='android-api-11')
def artifact_print_cache(self, tree=None, job=None):
artifacts = self._make_artifacts(tree=tree, job=job)
artifacts.print_cache()
return 0
@SubCommand('artifact', 'clear-cache',
'Delete local artifacts and reset local artifact cache.')
@CommandArgument('--tree', metavar='TREE', type=str,
help='Firefox tree.',
default='fx-team')
@CommandArgument('--job', metavar='JOB', type=str,
help='Build job.',
default='android-api-11')
def artifact_clear_cache(self, tree=None, job=None):
artifacts = self._make_artifacts(tree=tree, job=job)
artifacts.clear_cache()
return 0

View File

@ -0,0 +1,398 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
'''
Fetch build artifacts from a Firefox tree.
This provides an (at-the-moment special purpose) interface to download Android
artifacts from Mozilla's Task Cluster.
This module performs the following steps:
* find a candidate hg parent revision using the local pushlog. The local
pushlog is maintained by mozext locally and updated on every pull.
* map the candidate parent to candidate Task Cluster tasks and artifact
locations. Pushlog entries might not correspond to tasks (yet), and those
tasks might not produce the desired class of artifacts.
* fetch fresh Task Cluster artifacts and purge old artifacts, using a simple
Least Recently Used cache.
The bulk of the complexity is in managing and persisting several caches. If
we found a Python LRU cache that pickled cleanly, we could remove a lot of
this code! Sadly, I found no such candidate implementations, so we pickle
pylru caches manually.
None of the instances (or the underlying caches) are safe for concurrent use.
A future need, perhaps.
This module requires certain modules be importable from the ambient Python
environment. |mach artifact| ensures these modules are available, but other
consumers will need to arrange this themselves.
'''
from __future__ import absolute_import, print_function, unicode_literals
import functools
import logging
import operator
import os
import pickle
import re
import shutil
import subprocess
import urlparse
import zipfile
import pylru
import taskcluster
from mozbuild.util import (
ensureParentDir,
FileAvoidWrite,
)
MAX_CACHED_PARENTS = 100 # Number of parent changesets to cache candidate pushheads for.
NUM_PUSHHEADS_TO_QUERY_PER_PARENT = 50 # Number of candidate pushheads to cache per parent changeset.
MAX_CACHED_TASKS = 400 # Number of pushheads to cache Task Cluster task data for.
# Number of downloaded artifacts to cache. Each artifact can be very large,
# so don't make this to large! TODO: make this a size (like 500 megs) rather than an artifact count.
MAX_CACHED_ARTIFACTS = 6
# TODO: handle multiple artifacts with the same filename.
# TODO: handle installing binaries from different types of artifacts (.tar.bz2, .dmg, etc).
# Keep the keys of this map in sync with the |mach artifact| --job options.
JOB_DETAILS = {
# 'android-api-9': {'re': re.compile('public/build/fennec-(.*)\.android-arm\.apk')},
'android-api-11': {'re': re.compile('public/build/geckolibs-(.*)\.aar')},
# 'linux': {'re': re.compile('public/build/firefox-(.*)\.linux-i686\.tar\.bz2')},
# 'linux64': {'re': re.compile('public/build/firefox-(.*)\.linux-x86_64\.tar\.bz2')},
# 'macosx64': {'re': re.compile('public/build/firefox-(.*)\.mac\.dmg')},
}
def cachedmethod(cachefunc):
'''Decorator to wrap a class or instance method with a memoizing callable that
saves results in a (possibly shared) cache.
'''
def decorator(method):
def wrapper(self, *args, **kwargs):
mapping = cachefunc(self)
if mapping is None:
return method(self, *args, **kwargs)
key = (method.__name__, args, tuple(sorted(kwargs.items())))
try:
value = mapping[key]
return value
except KeyError:
pass
result = method(self, *args, **kwargs)
mapping[key] = result
return result
return functools.update_wrapper(wrapper, method)
return decorator
class CacheManager(object):
'''Maintain an LRU cache. Provide simple persistence, including support for
loading and saving the state using a "with" block. Allow clearing the cache
and printing the cache for debugging.
Provide simple logging.
'''
def __init__(self, cache_dir, cache_name, cache_size, cache_callback=None, log=None):
self._cache = pylru.lrucache(cache_size, callback=cache_callback)
self._cache_filename = os.path.join(cache_dir, cache_name + '-cache.pickle')
self._log = log
def log(self, *args, **kwargs):
if self._log:
self._log(*args, **kwargs)
def load_cache(self):
try:
items = pickle.load(open(self._cache_filename, 'rb'))
for key, value in items:
self._cache[key] = value
except Exception as e:
# Corrupt cache, perhaps? Sadly, pickle raises many different
# exceptions, so it's not worth trying to be fine grained here.
# We ignore any exception, so the cache is effectively dropped.
self.log(logging.INFO, 'artifact',
{'filename': self._cache_filename, 'exception': repr(e)},
'Ignoring exception unpickling cache file {filename}: {exception}')
pass
def dump_cache(self):
ensureParentDir(self._cache_filename)
pickle.dump(list(reversed(list(self._cache.items()))), open(self._cache_filename, 'wb'), -1)
def clear_cache(self):
with self:
self._cache.clear()
def print_cache(self):
with self:
for item in self._cache.items():
self.log(logging.INFO, 'artifact',
{'item': item},
'{item}')
def print_last_item(self, args, sorted_kwargs, result):
# By default, show nothing.
pass
def print_last(self):
# We use the persisted LRU caches to our advantage. The first item is
# most recent.
with self:
item = next(self._cache.items(), None)
if item is not None:
(name, args, sorted_kwargs), result = item
self.print_last_item(args, sorted_kwargs, result)
else:
self.log(logging.WARN, 'artifact',
{},
'No last cached item found.')
def __enter__(self):
self.load_cache()
return self
def __exit__(self, type, value, traceback):
self.dump_cache()
class PushHeadCache(CacheManager):
'''Map parent hg revisions to candidate pushheads.'''
def __init__(self, hg, cache_dir, log=None):
# It's not unusual to pull hundreds of changesets at once, and perhaps
# |hg up| back and forth a few times.
CacheManager.__init__(self, cache_dir, 'pushheads', MAX_CACHED_PARENTS, log=log)
self._hg = hg
@cachedmethod(operator.attrgetter('_cache'))
def pushheads(self, tree, parent):
pushheads = subprocess.check_output([self._hg, 'log',
'--template', '{node}\n',
'-r', 'last(pushhead("{tree}") & ::"{parent}", {num})'.format(
tree=tree, parent=parent, num=NUM_PUSHHEADS_TO_QUERY_PER_PARENT)])
pushheads = pushheads.strip().split('\n')
return pushheads
class TaskCache(CacheManager):
'''Map candidate pushheads to Task Cluster task IDs and artifact URLs.'''
def __init__(self, cache_dir, log=None):
CacheManager.__init__(self, cache_dir, 'artifact_url', MAX_CACHED_TASKS, log=log)
self._index = taskcluster.Index()
self._queue = taskcluster.Queue()
@cachedmethod(operator.attrgetter('_cache'))
def artifact_url(self, tree, job, rev):
try:
artifact_re = JOB_DETAILS[job]['re']
except KeyError:
self.log(logging.INFO, 'artifact',
{'job': job},
'Unknown job {job}')
raise KeyError("Unknown job")
# Bug 1175655: it appears that the Task Cluster index only takes
# 12-char hex hashes.
key = '{rev}.{tree}.{job}'.format(rev=rev[:12], tree=tree, job=job)
try:
namespace = 'buildbot.revisions.{key}'.format(key=key)
task = self._index.findTask(namespace)
except Exception:
# Not all revisions correspond to pushes that produce the job we
# care about; and even those that do may not have completed yet.
raise ValueError('Task for {key} does not exist (yet)!'.format(key=key))
taskId = task['taskId']
# TODO: Make this not Android-only by matching a regular expression.
artifacts = self._queue.listLatestArtifacts(taskId)['artifacts']
def names():
for artifact in artifacts:
name = artifact['name']
if artifact_re.match(name):
yield name
# TODO: Handle multiple artifacts, taking the latest one.
for name in names():
# We can easily extract the task ID and the build ID from the URL.
url = self._queue.buildUrl('getLatestArtifact', taskId, name)
return url
raise ValueError('Task for {key} existed, but no artifacts found!'.format(key=key))
def print_last_item(self, args, sorted_kwargs, result):
tree, job, rev = args
self.log(logging.INFO, 'artifact',
{'rev': rev},
'Last installed binaries from hg parent revision {rev}')
class ArtifactCache(CacheManager):
'''Fetch Task Cluster artifact URLs and purge least recently used artifacts from disk.'''
def __init__(self, cache_dir, log=None):
# TODO: instead of storing N artifact packages, store M megabytes.
CacheManager.__init__(self, cache_dir, 'fetch', MAX_CACHED_ARTIFACTS, cache_callback=self.delete_file, log=log)
self._cache_dir = cache_dir
def delete_file(self, key, value):
try:
os.remove(value)
self.log(logging.INFO, 'artifact',
{'filename': value},
'Purged artifact {filename}')
except IOError:
pass
@cachedmethod(operator.attrgetter('_cache'))
def fetch(self, url, force=False):
args = ['wget', url]
if not force:
args[1:1] = ['--timestamping']
proc = subprocess.Popen(args, cwd=self._cache_dir)
status = None
# Leave it to the subprocess to handle Ctrl+C. If it terminates as
# a result of Ctrl+C, proc.wait() will return a status code, and,
# we get out of the loop. If it doesn't, like e.g. gdb, we continue
# waiting.
while status is None:
try:
status = proc.wait()
except KeyboardInterrupt:
pass
if status != 0:
raise Exception('Process executed with non-0 exit code: %s' % args)
return os.path.abspath(os.path.join(self._cache_dir, os.path.basename(url)))
def print_last_item(self, args, sorted_kwargs, result):
url, = args
self.log(logging.INFO, 'artifact',
{'url': url},
'Last installed binaries from url {url}')
self.log(logging.INFO, 'artifact',
{'filename': result},
'Last installed binaries from local file {filename}')
class Artifacts(object):
'''Maintain state to efficiently fetch build artifacts from a Firefox tree.'''
def __init__(self, tree, job, log=None, cache_dir='.', hg='hg'):
self._tree = tree
self._job = job
self._log = log
self._hg = hg
self._cache_dir = cache_dir
self._pushhead_cache = PushHeadCache(self._hg, self._cache_dir, log=self._log)
self._task_cache = TaskCache(self._cache_dir, log=self._log)
self._artifact_cache = ArtifactCache(self._cache_dir, log=self._log)
def log(self, *args, **kwargs):
if self._log:
self._log(*args, **kwargs)
def install_from_file(self, filename, distdir):
self.log(logging.INFO, 'artifact',
{'filename': filename},
'Installing from {filename}')
# Copy all .so files to dist/bin, avoiding modification where possible.
ensureParentDir(os.path.join(distdir, 'bin', '.dummy'))
with zipfile.ZipFile(filename) as zf:
for info in zf.infolist():
if not info.filename.endswith('.so'):
continue
n = os.path.join(distdir, 'bin', os.path.basename(info.filename))
fh = FileAvoidWrite(n, mode='r')
shutil.copyfileobj(zf.open(info), fh)
fh.write(zf.open(info).read())
file_existed, file_updated = fh.close()
self.log(logging.INFO, 'artifact',
{'updating': 'Updating' if file_updated else 'Not updating', 'filename': n},
'{updating} {filename}')
return 0
def install_from_url(self, url, distdir):
self.log(logging.INFO, 'artifact',
{'url': url},
'Installing from {url}')
with self._artifact_cache as artifact_cache: # The with block handles persistence.
filename = artifact_cache.fetch(url)
return self.install_from_file(filename, distdir)
def install_from_hg(self, revset, distdir):
if not revset:
revset = '.'
if len(revset) != 40:
revset = subprocess.check_output([self._hg, 'log', '--template', '{node}\n', '-r', revset]).strip()
if len(revset.split('\n')) != 1:
raise ValueError('hg revision specification must resolve to exactly one commit')
self.log(logging.INFO, 'artifact',
{'revset': revset},
'Installing from {revset}')
url = None
with self._task_cache as task_cache, self._pushhead_cache as pushhead_cache:
# with blocks handle handle persistence.
for pushhead in pushhead_cache.pushheads(self._tree, revset):
try:
url = task_cache.artifact_url(self._tree, self._job, pushhead)
break
except ValueError:
pass
if url:
return self.install_from_url(url, distdir)
return 1
def install_from(self, source, distdir):
if source and os.path.isfile(source):
return self.install_from_file(source, distdir)
elif source and urlparse.urlparse(source).scheme:
return self.install_from_url(source, distdir)
else:
return self.install_from_hg(source, distdir)
def print_last(self):
self.log(logging.INFO, 'artifact',
{},
'Printing last used artifact details.')
self._pushhead_cache.print_last()
self._task_cache.print_last()
self._artifact_cache.print_last()
def clear_cache(self):
self.log(logging.INFO, 'artifact',
{},
'Deleting cached artifacts and caches.')
self._pushhead_cache.clear_cache()
self._task_cache.clear_cache()
self._artifact_cache.clear_cache()
def print_cache(self):
self.log(logging.INFO, 'artifact',
{},
'Printing cached artifacts and caches.')
self._pushhead_cache.print_cache()
self._task_cache.print_cache()
self._artifact_cache.print_cache()

View File

@ -764,6 +764,22 @@ class MachCommandConditions(object):
return cls.substs.get('MOZ_WIDGET_TOOLKIT') == 'android'
return False
@staticmethod
def is_hg(cls):
"""Must have a mercurial source checkout."""
if hasattr(cls, 'substs'):
top_srcdir = cls.substs.get('top_srcdir')
return top_srcdir and os.path.isdir(os.path.join(top_srcdir, '.hg'))
return False
@staticmethod
def is_git(cls):
"""Must have a git source checkout."""
if hasattr(cls, 'substs'):
top_srcdir = cls.substs.get('top_srcdir')
return top_srcdir and os.path.isdir(os.path.join(top_srcdir, '.git'))
return False
class PathArgument(object):
"""Parse a filesystem path argument and transform it in various ways."""

View File

@ -119,11 +119,12 @@ class FileAvoidWrite(StringIO):
enabled by default because it a) doesn't make sense for binary files b)
could add unwanted overhead to calls.
"""
def __init__(self, filename, capture_diff=False):
def __init__(self, filename, capture_diff=False, mode='rU'):
StringIO.__init__(self)
self.name = filename
self._capture_diff = capture_diff
self.diff = None
self.mode = mode
def close(self):
"""Stop accepting writes, compare file contents, and rewrite if needed.
@ -142,7 +143,7 @@ class FileAvoidWrite(StringIO):
old_content = None
try:
existing = open(self.name, 'rU')
existing = open(self.name, self.mode)
existed = True
except IOError:
pass

View File

@ -413,6 +413,11 @@ ifdef MOZ_ENABLE_SZIP
SZIP_LIBRARIES := $(ASSET_SO_LIBRARIES)
endif
ifndef COMPILE_ENVIRONMENT
# Any Fennec binary libraries we download are already szipped.
ALREADY_SZIPPED=1
endif
# Fennec's OMNIJAR_NAME can include a directory; for example, it might
# be "assets/omni.ja". This path specifies where the omni.ja file
# lives in the APK, but should not root the resources it contains
@ -459,7 +464,7 @@ INNER_MAKE_PACKAGE = \
( cd $(STAGEPATH)$(MOZ_PKG_DIR)$(_BINPATH) && \
unzip -o $(_ABS_DIST)/gecko.ap_ && \
rm $(_ABS_DIST)/gecko.ap_ && \
$(ZIP) $(if $(MOZ_ENABLE_SZIP),-0 )$(_ABS_DIST)/gecko.ap_ $(ASSET_SO_LIBRARIES) && \
$(ZIP) $(if $(ALREADY_SZIPPED),-0 ,$(if $(MOZ_ENABLE_SZIP),-0 ))$(_ABS_DIST)/gecko.ap_ $(ASSET_SO_LIBRARIES) && \
$(ZIP) -r9D $(_ABS_DIST)/gecko.ap_ $(DIST_FILES) -x $(NON_DIST_FILES) $(SZIP_LIBRARIES) && \
$(if $(filter-out ./,$(OMNIJAR_DIR)), \
mkdir -p $(OMNIJAR_DIR) && mv $(OMNIJAR_NAME) $(OMNIJAR_DIR) && ) \