blob: 167d0ee62741752839e0195940d6d148cd994cbb [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import collections
import imp
import json
import os
import re
import sys
import tempfile
import threading
import unittest
from subprocess import Popen, PIPE
from textwrap import dedent
BUILD_DIR = os.path.realpath(os.path.join(
os.path.dirname(__file__), '..'))
BOT_UPDATE_PATH = os.path.join(BUILD_DIR, 'scripts', 'slave', 'bot_update.py')
SLAVE_DIR = os.path.join(BUILD_DIR, 'slave')
CACHE_DIR = os.path.join(SLAVE_DIR, 'cache_dir')
DEPOT_TOOLS = os.path.realpath(os.path.join(BUILD_DIR, '..', 'depot_tools'))
GIT_CL_PATH = os.path.realpath(os.path.join(DEPOT_TOOLS, 'git_cl.py'))
sys.path.insert(0, os.path.join(BUILD_DIR, 'scripts'))
import common.env
common.env.Install()
from common import chromium_utils
import test_util
local_rietveld = imp.load_source(
'local_rietveld',
os.path.join(DEPOT_TOOLS, 'testing_support', 'local_rietveld.py'))
# The implementation of find_free_port in local_rietveld is super janky.
local_rietveld.find_free_port = lambda _: test_util.find_free_port()
class BotUpdateTest(unittest.TestCase):
"""
POPULATE_DATA = {
'TEST METHOD': [
('REPO NAME', [
('COMMENT', { 'FILE NAME': 'FILE CONTENTS', ... }), # First commit
('COMMENT', { 'FILE NAME': 'FILE CONTENTS', ... }), # Second commit
...
]),
...
],
...
}
'FILE CONTENTS' can be a string template using named parameters. As each
repo is processed, the dict used to complete template parameters is
updated with the url to the repo. As each commit is processed, the dict
is updated with the revision of the resulting commit. After this is
processed:
('MyRepo', [
{ 'file1.txt' : 'Some contents.' },
{ 'file2.txt' : 'Some other contents.' },
]),
... the template completion dict will be updated with these values:
{ 'MyRepo_url': 'FULL URL TO REPO',
'MyRepo_revision_0': 'REVISION OF FIRST COMMIT',
'MyRepo_revision_1': 'REVISION OF SECOND COMMIT' }
... which can be referred to in subsequent entries:
('MyOtherRepo', [
{ 'url.txt': 'The url of MyRepo is %(MyRepo_url)s.' },
{ 'rev.txt': 'The first revision in MyRepo is %(MyRepo_revision_0)s.' }
])
When SVN_POPULATE_DATA is processed, it additionally creates a git mirror
of each repo, and adds template parameters for the git mirror url and
git revisions:
('MyOtherRepo', [
{ 'git_url.txt': 'The git mirror url is %(MyRepo_git_url)s.' },
{ 'git_rev.txt': 'The first git hash is %(MyRepo_git_revision_0)s.' }
])
NOTE that commits are additive; subsequent commits don't have to contain the
contents of files from previous commits, and there is no syntax for deletion.
In the example, after MyRepo is processed, its tip-of-tree will contain two
files: [file1.txt, file2.txt].
"""
SVN_BOILERPLATE = [
('dep1', [
('First commit', {'path1/file1.txt': 'dep1 file 1 line 1.'}),
('Second commit', {'path2/file2.txt': 'dep1 file 2 line 1.'}),
]),
('dep2', [
('First commit', {'path1/file1.txt': 'dep2 file 1 line 1.'}),
('Second commit', {'path2/file2.txt': 'dep2 file 2 line 1.'}),
]),
('top', [
('DEPS commit', {
'file1.txt': 'top file 1 line 1.',
'DEPS': dedent('''\
vars = {
'dep1_revision': '%(dep1_revision_0)s',
'dep2_revision': '%(dep2_revision_1)s',
}
deps = {
'top/ext/dep1': '%(dep1_url)s@' + Var('dep1_revision'),
'top/ext/dep2': '%(dep2_url)s@' + Var('dep2_revision'),
}
''')}),
('.DEPS.git commit', {'.DEPS.git': dedent('''\
vars = {
'dep1_revision': '%(dep1_git_revision_0)s',
'dep2_revision': '%(dep2_git_revision_1)s',
}
deps = {
'top/ext/dep1': '%(dep1_mirror_url)s@' + Var('dep1_revision'),
'top/ext/dep2': '%(dep2_mirror_url)s@' + Var('dep2_revision'),
}
''')}),
]),
]
GIT_BOILERPLATE = [
('dep1', [
('First commit', {'path1/file1.txt': 'dep1 file 1 line 1.'}),
('Second commit', {'path2/file2.txt': 'dep1 file 2 line 1.'}),
]),
('dep2', [
('First commit', {'path1/file1.txt': 'dep2 file 1 line 1.'}),
('Second commit', {'path2/file2.txt': 'dep2 file 2 line 1.'}),
]),
('top', [
('DEPS commit', {
'file1.txt': 'top file 1 line 1.',
'DEPS': dedent('''\
vars = {
'dep1_revision': '%(dep1_revision_0)s',
'dep2_revision': '%(dep2_revision_1)s',
}
deps = {
'top/ext/dep1': '%(dep1_url)s@' + Var('dep1_revision'),
'top/ext/dep2': '%(dep2_url)s@' + Var('dep2_revision'),
}
''')}),
]),
]
SVN_POPULATE_DATA = {
'test_002_svn': SVN_BOILERPLATE,
'test_003_patch': SVN_BOILERPLATE,
}
GIT_POPULATE_DATA = {
'test_001_simple': GIT_BOILERPLATE,
}
# Used to store the result of a subprocess invocation.
SUBPROCESS_RESULT = collections.namedtuple(
'SUBPROCESS_RESULT', ['cmd', 'cwd', 'status', 'stdout', 'stderr'])
# Used to store information about a repository.
# path: relative path from the repository root.
# url: canonical url for repository.
# populate_dir: A working dir used to populate the repository.
# serve_dir: Directory on localhost where the repository is hosted.
# revisions: Revision data from *_POPULATE_DATA.
REPO = collections.namedtuple(
'REPO',
['path', 'url', 'populate_dir', 'serve_dir', 'revisions'])
@staticmethod
def dump_subproc(result):
"""Pretty-prints a SUBPROCESS_RESULT object."""
sep = '\n' + ('#' * 80) + '\n'
print sep, 'Subprocess failed with status %d.\n' % result.status
print result.cmd, '\n\n... in %s\n' % result.cwd
print sep, '# stdout\n', sep, result.stdout, '\n'
print sep, '# stderr\n', sep, result.stderr, '\n', sep
def subproc(self, cmd, cwd=None, stdin=None, timeout=15):
"""Runs a subprocess with a hard time limit."""
if not cwd:
cwd = self.workdir
stdin_arg = PIPE if stdin else None
p = Popen(cmd, stdin=stdin_arg, stdout=PIPE, stderr=PIPE, cwd=cwd)
def _thread_main():
thr = threading.current_thread()
(stdout, stderr) = p.communicate(stdin)
thr.stdout = stdout
thr.stderr = stderr
thr = threading.Thread(target=_thread_main)
thr.daemon = True
thr.start()
thr.join(timeout)
if thr.isAlive():
p.terminate()
msg = 'Subprocess timed out after %d seconds:\n%r' % (timeout, cmd)
self.fail(msg)
return self.SUBPROCESS_RESULT(
cmd, cwd, p.returncode, thr.stdout, thr.stderr)
def assertSubproc(self, cmd, cwd=None, stdin=None, timeout=15):
"""Runs a subprocess and asserts that it exits with zero status."""
result = self.subproc(cmd, cwd, stdin, timeout)
self.assertEqual(result.status, 0)
return result
@staticmethod
def get_files(d):
"""Walks a directory tree, skipping any .git directories, and return all
regular files in it.
"""
result = []
for dirpath, dirnames, filenames in os.walk(d):
for f in filenames:
result.append(
os.path.join(dirpath.replace(d, '').lstrip('/'), f))
try:
dirnames.remove('.git')
except ValueError:
pass
return result
@classmethod
def setUpClass(cls):
cls.server_root = tempfile.mkdtemp(prefix=cls.__name__)
cls.git_server = test_util.LocalGitServer(
os.path.join(cls.server_root, 'git'))
cls.svn_server = test_util.LocalSvnServer(
os.path.join(cls.server_root, 'svn'))
cls.rietveld = local_rietveld.LocalRietveld()
cls.rietveld.start_server()
@classmethod
def tearDownClass(cls):
try:
for d in os.listdir(CACHE_DIR):
try:
if d.lower().startswith(
'localhost:%d-botupdatetest' % cls.git_server.port):
chromium_utils.RemoveDirectory(os.path.join(CACHE_DIR, d))
except Exception:
pass
except Exception:
pass
cls.git_server.stop()
cls.svn_server.stop()
try:
cls.rietveld.stop_server()
except Exception:
pass
chromium_utils.RemoveDirectory(cls.server_root)
def _populate_svn_repo(self, repo):
"""Takes a repository described in SVN_POPULATE_DATA and instantiates it on
the local svn server.
"""
self.template_dict['%s_url' % repo.path] = repo.url
# Create the top-level path for the repository.
self.assertSubproc(
['svn', 'mkdir', '-F', '-', '--parents', repo.url],
stdin='Create parent directories.')
# Populate revision history
if not os.path.exists(os.path.dirname(repo.populate_dir)):
os.makedirs(os.path.dirname(repo.populate_dir))
self.assertSubproc(
['svn', 'co', repo.url, repo.populate_dir])
for i, (comment, file_changes) in enumerate(repo.revisions):
for filename, contents in file_changes.iteritems():
contents = contents % self.template_dict
filedir = os.path.join(repo.populate_dir, os.path.dirname(filename))
if not os.path.exists(filedir):
os.makedirs(filedir)
with open(os.path.join(repo.populate_dir, filename), 'w') as fh:
fh.write(contents)
self.assertSubproc(
['svn', 'add', '--parents', filename], repo.populate_dir)
self.assertSubproc(
['svn', 'commit', '-F', '-'],
repo.populate_dir, stdin=comment)
# Record revision number in template_dict
self.assertSubproc(['svn', 'up'], repo.populate_dir)
(_, _, _, svn_info, _) = self.subproc(['svn', 'info'], repo.populate_dir)
svn_revision = [x[len('Revision: '):] for x in svn_info.splitlines()
if x.startswith('Revision: ')]
self.assertEqual(len(svn_revision), 1)
self.template_dict['%s_revision_%d' % (repo.path, i)] = svn_revision[0]
def _populate_svn_git_mirror(self, repo, mirror):
"""Mirrors an svn repository in to a git repository."""
self.template_dict['%s_mirror_url' % repo.path] = mirror.url
# Create and initialize the repository on the server
os.makedirs(mirror.serve_dir)
self.assertSubproc(['git', 'init', '--bare'], mirror.serve_dir)
# Populate the repository using git-svn
os.makedirs(mirror.populate_dir)
self.assertSubproc(['git', 'init'], mirror.populate_dir)
self.assertSubproc(['git', 'svn', 'init', repo.url], mirror.populate_dir)
self.assertSubproc(['git', 'svn', 'fetch'], mirror.populate_dir)
self.assertSubproc(['git', 'push', mirror.serve_dir,
'refs/remotes/git-svn:refs/heads/master'],
mirror.populate_dir)
# Update template_dict with the git hash corresponding to each svn commit.
for i in xrange(len(repo.revisions)):
svn_rev = self.template_dict['%s_revision_%d' % (repo.path, i)]
result = self.assertSubproc(
['git', 'svn', 'find-rev', 'r%s' % svn_rev], mirror.populate_dir)
sha1 = result.stdout.strip()
self.assertTrue(re.match(r'[0-9a-fA-F]{40}', sha1))
self.template_dict['%s_git_revision_%d' % (repo.path, i)] = sha1
def _populate_git_repo(self, repo):
"""Populates a pure-git repository as defined in GIT_POPULATE_DATA."""
self.template_dict['%s_url' % repo.path] = repo.url + '.git'
# Create and initialize the repository on the server
os.makedirs(repo.serve_dir)
self.assertSubproc(['git', 'init', '--bare'], repo.serve_dir)
# Populate repository with commits
os.makedirs(repo.populate_dir)
self.assertSubproc(['git', 'init'], repo.populate_dir)
for i, (comment, file_changes) in enumerate(repo.revisions):
for filename, contents in file_changes.iteritems():
contents = contents % self.template_dict
filedir = os.path.join(repo.populate_dir, os.path.dirname(filename))
if not os.path.exists(filedir):
os.makedirs(filedir)
with open(os.path.join(repo.populate_dir, filename), 'w') as fh:
fh.write(contents)
self.assertSubproc(['git', 'add', filename], repo.populate_dir)
self.assertSubproc(
['git', 'commit', '-F', '-'], repo.populate_dir, stdin=comment)
result = self.assertSubproc(
['git', 'rev-parse', 'HEAD'], repo.populate_dir)
sha1 = result.stdout.strip()
self.assertTrue(re.match(r'[0-9a-fA-F]{40}', sha1))
self.template_dict['%s_revision_%d' % (repo.path, i)] = sha1
self.assertSubproc(['git', 'push', repo.serve_dir,
'HEAD:refs/heads/master'], repo.populate_dir)
def populate_svn(self):
"""Populates the local svn server with the repositories described in
SVN_POPULATE_DATA for the current test method, and then creates git mirrors
of the repositories on the local git server.
The repository name is always prefixed with the test class and test method
name being run, for disambiguation. For example, if the method
BotUpdateTest.test_999_horse defines a repo named 'feathers', it will be
instantiated as <svn root>/BotUpdateTest/test_999_horse/feathers.
"""
test_prefix_parts = self.test_prefix.split('.')
self.template_dict.update({
'svn_server_url': self.svn_server.url,
'test_prefix': self.test_prefix,
'test_name': self.test_name,
})
for repo, revisions in self.SVN_POPULATE_DATA.get(self.test_name, []):
repo_parts = repo.split('/')
url = '/'.join(
[self.svn_server.url] + test_prefix_parts + repo_parts)
git_url = '/'.join(
[self.git_server.url] + test_prefix_parts + repo_parts) + '.git'
populate_dir = os.path.join(*([self.workdir] + repo_parts))
git_populate_dir = populate_dir + '-mirror'
git_serve_dir = os.path.join(
*([self.git_server.root] + test_prefix_parts + repo_parts)) + '.git'
svn_repo = self.REPO(repo, url, populate_dir, None, revisions)
git_repo = self.REPO(
repo, git_url, git_populate_dir, git_serve_dir, revisions)
self._populate_svn_repo(svn_repo)
self._populate_svn_git_mirror(svn_repo, git_repo)
def populate_git(self):
"""Populates the local git server with the repositories described in
GIT_POPULATE_DATA for the current test method.
The repository name is always prefixed with the test class and test method
name being run, for disambiguation. For example, if the method
BotUpdateTest.test_999_horse defines a repo named 'feathers', it will be
instantiated as <git root>/BotUpdateTest/test_999_horse/feathers.
"""
test_prefix_parts = self.test_prefix.split('.')
self.template_dict.update({
'git_server_url': self.git_server.url,
'test_prefix': self.test_prefix,
'test_name': self.test_name,
})
for repo, revisions in self.GIT_POPULATE_DATA.get(self.test_name, []):
if repo.endswith('.git'):
repo = repo[:-4]
repo_parts = repo.split('/')
url = '/'.join([self.git_server.url] + test_prefix_parts + repo_parts)
populate_dir = os.path.join(*([self.workdir] + repo_parts))
serve_dir = os.path.join(
*([self.git_server.root] + test_prefix_parts + repo_parts)) + '.git'
repo = self.REPO(repo, url, populate_dir, serve_dir, revisions)
self._populate_git_repo(repo)
def setUp(self):
self.test_prefix = self.id().lstrip('__main__.')
self.test_name = self.test_prefix.split('.')[-1]
self.workdir = tempfile.mkdtemp(dir=SLAVE_DIR, prefix=self.test_prefix)
self.builddir = os.path.join(self.workdir, 'build')
os.mkdir(self.builddir)
self.bu_args = [
BOT_UPDATE_PATH, '--force', '--output_json',
os.path.join(self.builddir, 'out.json'), '--master',
'%s_master' % self.test_name, '--builder_name',
'%s_builder' % self.test_name, '--slave_name',
'%s_slave' % self.test_name]
self.template_dict = {}
self.populate_svn()
self.populate_git()
def tearDown(self):
chromium_utils.RemoveDirectory(self.workdir)
def run_bot_update(self, tweak_module_func=None):
"""Runs the main() method of bot_update.py.
This emulates a subprocess call, without actually shelling out. That makes
it easier to debug tests, as a debugger can step directly into
bot_update.py.
tweak_module_func can be used to modify the bot_udpate module before
main() is invoked. Since the module is unloaded at the end of this method,
tweaks do not persist between invocations.
"""
old_argv = sys.argv
sys.argv = self.bu_args
old_cwd = os.getcwd()
os.chdir(self.builddir)
mod = imp.load_source('bot_update', BOT_UPDATE_PATH)
mod.UPLOAD_TELEMETRY = False
if tweak_module_func:
tweak_module_func(mod)
status, stdout, stderr = test_util.capture_terminal(mod.main)
if isinstance(status, Exception):
status = 1
elif status is None:
status = 0
os.chdir(old_cwd)
sys.argv = old_argv
del sys.modules['bot_update']
del mod
return self.SUBPROCESS_RESULT(self.bu_args, self.builddir, status,
stdout.getvalue(), stderr.getvalue())
def run_git_cl(self, argv, cwd, tweak_module_func=None):
cmd = [GIT_CL_PATH] + argv
old_cwd = os.getcwd()
os.chdir(cwd)
old_editor = os.environ.get('GIT_EDITOR')
os.environ['GIT_EDITOR'] = 'true'
mod = imp.load_source('git_cl', GIT_CL_PATH)
if tweak_module_func:
tweak_module_func(mod)
status, stdout, stderr = test_util.capture_terminal(mod.main, argv)
if isinstance(status, Exception):
status = 1
elif status is None:
status = 0
os.chdir(old_cwd)
if old_editor is not None:
os.environ['GIT_EDITOR'] = old_editor
else:
del os.environ['GIT_EDITOR']
del sys.modules['git_cl']
del mod
return self.SUBPROCESS_RESULT(
cmd, cwd, status, stdout.getvalue(), stderr.getvalue())
def test_001_simple(self):
"""Tests a git solution with git-style DEPS, and no .DEPS.git."""
solution = {
'name': 'top',
'url': '%s/BotUpdateTest/test_001_simple/top.git' % self.git_server.url,
'deps_file': 'DEPS'
}
gclient_spec = 'solutions=[%r]' % solution
self.bu_args.extend([
'--specs', gclient_spec,
'--revision', self.template_dict['top_revision_0']])
result = self.run_bot_update()
if bool(result.status):
self.dump_subproc(result)
self.fail('bot_update.py failed')
expected_files = [
'DEPS',
'file1.txt',
'ext/dep1/path1/file1.txt',
'ext/dep2/path1/file1.txt',
'ext/dep2/path2/file2.txt',
]
topdir = os.path.join(self.builddir, 'top')
self.assertItemsEqual(expected_files, self.get_files(topdir))
expected_json = {
'root': 'top',
'properties': {'got_revision': self.template_dict['top_revision_0']},
'did_run': True,
'patch_root': None
}
with open(os.path.join(self.builddir, 'out.json')) as fh:
actual_json = json.load(fh)
self.assertDictContainsSubset(expected_json, actual_json)
def test_002_svn(self):
"""Tests an svn-based solution with svn DEPS and git .DEPS.git."""
solution = {
'name': 'top',
'url': '%s/BotUpdateTest/test_002_svn/top' % self.svn_server.url,
'deps_file': 'DEPS'
}
gclient_spec = 'solutions=[%r]' % solution
self.bu_args.extend([
'--specs', gclient_spec,
'--revision', self.template_dict['top_revision_1']])
def _tweak(mod):
repo_path = 'BotUpdateTest/test_002_svn/top'
mod.RECOGNIZED_PATHS['/svn/%s' % repo_path] = (
'/'.join((self.git_server.url, repo_path)) + '.git')
result = self.run_bot_update(_tweak)
if bool(result.status):
self.dump_subproc(result)
self.fail('bot_update.py failed')
expected_files = [
'DEPS',
'.DEPS.git',
'file1.txt',
'ext/dep1/path1/file1.txt',
'ext/dep2/path1/file1.txt',
'ext/dep2/path2/file2.txt',
]
topdir = os.path.join(self.builddir, 'top')
self.assertItemsEqual(expected_files, self.get_files(topdir))
expected_json = {
'root': 'top',
'properties': {
'got_revision': int(self.template_dict['top_revision_1']),
'got_revision_cp': 'svn@{#%s}' %
self.template_dict['top_revision_1'],
'got_revision_git': self.template_dict['top_git_revision_1']},
'did_run': True,
'patch_root': None
}
with open(os.path.join(self.builddir, 'out.json')) as fh:
actual_json = json.load(fh)
self.assertDictContainsSubset(expected_json, actual_json)
# TODO(mmoss): This fails with an auth.AuthenticationError thrown by git-cl.
# Probably needs to be updated to support oauth2 credentials that are now
# required by git-cl/rietveld, unless there's some way to disable auth for
# testing purposes.
def DISABLED_test_003_patch(self):
'''Git solution with rietveld issue applied.'''
top_workdir = os.path.join(self.workdir, 'top_patch')
self.assertSubproc(
['git', 'clone', self.template_dict['top_mirror_url'],
top_workdir])
self.assertSubproc(
['git', 'config', '--local', 'rietveld.server',
'localhost:%d' % self.rietveld.port], top_workdir)
patched_file = os.path.join(top_workdir, 'patched_file.txt')
with open(patched_file, 'w') as fh:
fh.write('Patched line.\n')
self.assertSubproc(['git', 'add', 'patched_file.txt'], top_workdir)
self.assertSubproc(
['git', 'commit', '-m', 'Patch comment.'], top_workdir)
result = self.run_git_cl(
['upload', '-m', 'Patch comment.', '-t', 'patch title',
'--bypass-hooks'], top_workdir)
if result.status:
self.dump_subproc(result)
self.fail('git cl upload failed.')
issue = self.assertSubproc(['git', 'config', 'branch.master.rietveldissue'],
top_workdir).stdout.strip()
solution = {
'name': 'top',
'url': '%s/BotUpdateTest/test_003_patch/top' % self.svn_server.url,
'deps_file': 'DEPS'
}
gclient_spec = 'solutions=[%r]' % solution
self.bu_args.extend([
'--specs', gclient_spec,
'--issue', issue, '--patch_root', 'top',
'--rietveld_server', 'localhost:%d' % self.rietveld.port,
'--revision', self.template_dict['top_revision_1']])
def _tweak(mod):
repo_path = 'BotUpdateTest/test_003_patch/top'
mod.RECOGNIZED_PATHS['/svn/%s' % repo_path] = (
'/'.join((self.git_server.url, repo_path)) + '.git')
result = self.run_bot_update(_tweak)
if bool(result.status):
self.dump_subproc(result)
self.fail('bot_update.py failed')
expected_files = [
'DEPS',
'.DEPS.git',
'file1.txt',
'patched_file.txt',
'ext/dep1/path1/file1.txt',
'ext/dep2/path1/file1.txt',
'ext/dep2/path2/file2.txt',
]
topdir = os.path.join(self.builddir, 'top')
self.assertItemsEqual(expected_files, self.get_files(topdir))
expected_json = {
'root': 'top',
'properties': {'got_revision': self.template_dict['top_revision_1']},
'did_run': True,
'patch_root': 'top'
}
with open(os.path.join(self.builddir, 'out.json')) as fh:
actual_json = json.load(fh)
self.assertDictContainsSubset(expected_json, actual_json)
if __name__ == '__main__':
unittest.main()