gerrit_test_case.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. # Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """Test framework for code that interacts with gerrit.
  5. class GerritTestCase
  6. --------------------------------------------------------------------------------
  7. This class initializes and runs an a gerrit instance on localhost. To use the
  8. framework, define a class that extends GerritTestCase, and then do standard
  9. python unittest development as described here:
  10. http://docs.python.org/2.7/library/unittest.html#basic-example
  11. When your test code runs, the framework will:
  12. - Download the latest stable(-ish) binary release of the gerrit code.
  13. - Start up a live gerrit instance running in a temp directory on the localhost.
  14. - Set up a single gerrit user account with admin priveleges.
  15. - Supply credential helpers for interacting with the gerrit instance via http
  16. or ssh.
  17. Refer to depot_tools/testing_support/gerrit-init.sh for details about how the
  18. gerrit instance is set up, and refer to helper methods defined below
  19. (createProject, cloneProject, uploadChange, etc.) for ways to interact with the
  20. gerrit instance from your test methods.
  21. class RepoTestCase
  22. --------------------------------------------------------------------------------
  23. This class extends GerritTestCase, and creates a set of project repositories
  24. and a manifest repository that can be used in conjunction with the 'repo' tool.
  25. Each test method will initialize and sync a brand-new repo working directory.
  26. The 'repo' command may be invoked in a subprocess as part of your tests.
  27. One gotcha: 'repo upload' will always attempt to use the ssh interface to talk
  28. to gerrit.
  29. """
  30. import collections
  31. import errno
  32. import netrc
  33. import os
  34. import re
  35. import shutil
  36. import signal
  37. import socket
  38. import stat
  39. import subprocess
  40. import sys
  41. import tempfile
  42. import unittest
  43. import urllib
  44. import gerrit_util
  45. DEPOT_TOOLS_DIR = os.path.normpath(os.path.join(
  46. os.path.realpath(__file__), '..', '..'))
  47. # When debugging test code, it's sometimes helpful to leave the test gerrit
  48. # instance intact and running after the test code exits. Setting TEARDOWN
  49. # to False will do that.
  50. TEARDOWN = True
  51. class GerritTestCase(unittest.TestCase):
  52. """Test class for tests that interact with a gerrit server.
  53. The class setup creates and launches a stand-alone gerrit instance running on
  54. localhost, for test methods to interact with. Class teardown stops and
  55. deletes the gerrit instance.
  56. Note that there is a single gerrit instance for ALL test methods in a
  57. GerritTestCase sub-class.
  58. """
  59. COMMIT_RE = re.compile(r'^commit ([0-9a-fA-F]{40})$')
  60. CHANGEID_RE = re.compile('^\s+Change-Id:\s*(\S+)$')
  61. DEVNULL = open(os.devnull, 'w')
  62. TEST_USERNAME = 'test-username'
  63. TEST_EMAIL = 'test-username@test.org'
  64. GerritInstance = collections.namedtuple('GerritInstance', [
  65. 'credential_file',
  66. 'gerrit_dir',
  67. 'gerrit_exe',
  68. 'gerrit_host',
  69. 'gerrit_pid',
  70. 'gerrit_url',
  71. 'git_dir',
  72. 'git_host',
  73. 'git_url',
  74. 'http_port',
  75. 'netrc_file',
  76. 'ssh_ident',
  77. 'ssh_port',
  78. ])
  79. @classmethod
  80. def check_call(cls, *args, **kwargs):
  81. kwargs.setdefault('stdout', cls.DEVNULL)
  82. kwargs.setdefault('stderr', cls.DEVNULL)
  83. subprocess.check_call(*args, **kwargs)
  84. @classmethod
  85. def check_output(cls, *args, **kwargs):
  86. kwargs.setdefault('stderr', cls.DEVNULL)
  87. return subprocess.check_output(*args, **kwargs)
  88. @classmethod
  89. def _create_gerrit_instance(cls, gerrit_dir):
  90. gerrit_init_script = os.path.join(
  91. DEPOT_TOOLS_DIR, 'testing_support', 'gerrit-init.sh')
  92. http_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  93. http_sock.bind(('', 0))
  94. http_port = str(http_sock.getsockname()[1])
  95. ssh_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  96. ssh_sock.bind(('', 0))
  97. ssh_port = str(ssh_sock.getsockname()[1])
  98. # NOTE: this is not completely safe. These port numbers could be
  99. # re-assigned by the OS between the calls to socket.close() and gerrit
  100. # starting up. The only safe way to do this would be to pass file
  101. # descriptors down to the gerrit process, which is not even remotely
  102. # supported. Alas.
  103. http_sock.close()
  104. ssh_sock.close()
  105. cls.check_call(['bash', gerrit_init_script, '--http-port', http_port,
  106. '--ssh-port', ssh_port, gerrit_dir])
  107. gerrit_exe = os.path.join(gerrit_dir, 'bin', 'gerrit.sh')
  108. cls.check_call(['bash', gerrit_exe, 'start'])
  109. with open(os.path.join(gerrit_dir, 'logs', 'gerrit.pid')) as fh:
  110. gerrit_pid = int(fh.read().rstrip())
  111. return cls.GerritInstance(
  112. credential_file=os.path.join(gerrit_dir, 'tmp', '.git-credentials'),
  113. gerrit_dir=gerrit_dir,
  114. gerrit_exe=gerrit_exe,
  115. gerrit_host='localhost:%s' % http_port,
  116. gerrit_pid=gerrit_pid,
  117. gerrit_url='http://localhost:%s' % http_port,
  118. git_dir=os.path.join(gerrit_dir, 'git'),
  119. git_host='%s/git' % gerrit_dir,
  120. git_url='file://%s/git' % gerrit_dir,
  121. http_port=http_port,
  122. netrc_file=os.path.join(gerrit_dir, 'tmp', '.netrc'),
  123. ssh_ident=os.path.join(gerrit_dir, 'tmp', 'id_rsa'),
  124. ssh_port=ssh_port,)
  125. @classmethod
  126. def setUpClass(cls):
  127. """Sets up the gerrit instances in a class-specific temp dir."""
  128. # Create gerrit instance.
  129. gerrit_dir = tempfile.mkdtemp()
  130. os.chmod(gerrit_dir, 0o700)
  131. gi = cls.gerrit_instance = cls._create_gerrit_instance(gerrit_dir)
  132. # Set netrc file for http authentication.
  133. cls.gerrit_util_netrc_orig = gerrit_util.NETRC
  134. gerrit_util.NETRC = netrc.netrc(gi.netrc_file)
  135. # gerrit_util.py defaults to using https, but for testing, it's much
  136. # simpler to use http connections.
  137. cls.gerrit_util_protocol_orig = gerrit_util.GERRIT_PROTOCOL
  138. gerrit_util.GERRIT_PROTOCOL = 'http'
  139. # Because we communicate with the test server via http, rather than https,
  140. # libcurl won't add authentication headers to raw git requests unless the
  141. # gerrit server returns 401. That works for pushes, but for read operations
  142. # (like git-ls-remote), gerrit will simply omit any ref that requires
  143. # authentication. By default gerrit doesn't permit anonymous read access to
  144. # refs/meta/config. Override that behavior so tests can access
  145. # refs/meta/config if necessary.
  146. clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'All-Projects')
  147. cls._CloneProject('All-Projects', clone_path)
  148. project_config = os.path.join(clone_path, 'project.config')
  149. cls.check_call(['git', 'config', '--file', project_config, '--add',
  150. 'access.refs/meta/config.read', 'group Anonymous Users'])
  151. cls.check_call(['git', 'add', project_config], cwd=clone_path)
  152. cls.check_call(
  153. ['git', 'commit', '-m', 'Anonyous read for refs/meta/config'],
  154. cwd=clone_path)
  155. cls.check_call(['git', 'push', 'origin', 'HEAD:refs/meta/config'],
  156. cwd=clone_path)
  157. def setUp(self):
  158. self.tempdir = tempfile.mkdtemp()
  159. os.chmod(self.tempdir, 0o700)
  160. def tearDown(self):
  161. if TEARDOWN:
  162. shutil.rmtree(self.tempdir)
  163. @classmethod
  164. def createProject(cls, name, description='Test project', owners=None,
  165. submit_type='CHERRY_PICK'):
  166. """Create a project on the test gerrit server."""
  167. if owners is None:
  168. owners = ['Administrators']
  169. body = {
  170. 'description': description,
  171. 'submit_type': submit_type,
  172. 'owners': owners,
  173. }
  174. path = 'projects/%s' % urllib.quote(name, '')
  175. conn = gerrit_util.CreateHttpConn(
  176. cls.gerrit_instance.gerrit_host, path, reqtype='PUT', body=body)
  177. jmsg = gerrit_util.ReadHttpJsonResponse(conn, expect_status=201)
  178. assert jmsg['name'] == name
  179. @classmethod
  180. def _post_clone_bookkeeping(cls, clone_path):
  181. config_path = os.path.join(clone_path, '.git', 'config')
  182. cls.check_call(
  183. ['git', 'config', '--file', config_path, 'user.email', cls.TEST_EMAIL])
  184. cls.check_call(
  185. ['git', 'config', '--file', config_path, 'credential.helper',
  186. 'store --file=%s' % cls.gerrit_instance.credential_file])
  187. @classmethod
  188. def _CloneProject(cls, name, path):
  189. """Clone a project from the test gerrit server."""
  190. gi = cls.gerrit_instance
  191. parent_dir = os.path.dirname(path)
  192. if not os.path.exists(parent_dir):
  193. os.makedirs(parent_dir)
  194. url = '/'.join((gi.gerrit_url, name))
  195. cls.check_call(['git', 'clone', url, path])
  196. cls._post_clone_bookkeeping(path)
  197. # Install commit-msg hook to add Change-Id lines.
  198. hook_path = os.path.join(path, '.git', 'hooks', 'commit-msg')
  199. cls.check_call(['curl', '-o', hook_path,
  200. '/'.join((gi.gerrit_url, 'tools/hooks/commit-msg'))])
  201. os.chmod(hook_path, stat.S_IRWXU)
  202. return path
  203. def cloneProject(self, name, path=None):
  204. """Clone a project from the test gerrit server."""
  205. if path is None:
  206. path = os.path.basename(name)
  207. if path.endswith('.git'):
  208. path = path[:-4]
  209. path = os.path.join(self.tempdir, path)
  210. return self._CloneProject(name, path)
  211. @classmethod
  212. def _CreateCommit(cls, clone_path, fn=None, msg=None, text=None):
  213. """Create a commit in the given git checkout."""
  214. if not fn:
  215. fn = 'test-file.txt'
  216. if not msg:
  217. msg = 'Test Message'
  218. if not text:
  219. text = 'Another day, another dollar.'
  220. fpath = os.path.join(clone_path, fn)
  221. with open(fpath, 'a') as fh:
  222. fh.write('%s\n' % text)
  223. cls.check_call(['git', 'add', fn], cwd=clone_path)
  224. cls.check_call(['git', 'commit', '-m', msg], cwd=clone_path)
  225. return cls._GetCommit(clone_path)
  226. def createCommit(self, clone_path, fn=None, msg=None, text=None):
  227. """Create a commit in the given git checkout."""
  228. clone_path = os.path.join(self.tempdir, clone_path)
  229. return self._CreateCommit(clone_path, fn, msg, text)
  230. @classmethod
  231. def _GetCommit(cls, clone_path, ref='HEAD'):
  232. """Get the sha1 and change-id for a ref in the git checkout."""
  233. log_proc = cls.check_output(['git', 'log', '-n', '1', ref], cwd=clone_path)
  234. sha1 = None
  235. change_id = None
  236. for line in log_proc.splitlines():
  237. match = cls.COMMIT_RE.match(line)
  238. if match:
  239. sha1 = match.group(1)
  240. continue
  241. match = cls.CHANGEID_RE.match(line)
  242. if match:
  243. change_id = match.group(1)
  244. continue
  245. assert sha1
  246. assert change_id
  247. return (sha1, change_id)
  248. def getCommit(self, clone_path, ref='HEAD'):
  249. """Get the sha1 and change-id for a ref in the git checkout."""
  250. clone_path = os.path.join(self.tempdir, clone_path)
  251. return self._GetCommit(clone_path, ref)
  252. @classmethod
  253. def _UploadChange(cls, clone_path, branch='master', remote='origin'):
  254. """Create a gerrit CL from the HEAD of a git checkout."""
  255. cls.check_call(
  256. ['git', 'push', remote, 'HEAD:refs/for/%s' % branch], cwd=clone_path)
  257. def uploadChange(self, clone_path, branch='master', remote='origin'):
  258. """Create a gerrit CL from the HEAD of a git checkout."""
  259. clone_path = os.path.join(self.tempdir, clone_path)
  260. self._UploadChange(clone_path, branch, remote)
  261. @classmethod
  262. def _PushBranch(cls, clone_path, branch='master'):
  263. """Push a branch directly to gerrit, bypassing code review."""
  264. cls.check_call(
  265. ['git', 'push', 'origin', 'HEAD:refs/heads/%s' % branch],
  266. cwd=clone_path)
  267. def pushBranch(self, clone_path, branch='master'):
  268. """Push a branch directly to gerrit, bypassing code review."""
  269. clone_path = os.path.join(self.tempdir, clone_path)
  270. self._PushBranch(clone_path, branch)
  271. @classmethod
  272. def createAccount(cls, name='Test User', email='test-user@test.org',
  273. password=None, groups=None):
  274. """Create a new user account on gerrit."""
  275. username = email.partition('@')[0]
  276. gerrit_cmd = 'gerrit create-account %s --full-name "%s" --email %s' % (
  277. username, name, email)
  278. if password:
  279. gerrit_cmd += ' --http-password "%s"' % password
  280. if groups:
  281. gerrit_cmd += ' '.join(['--group %s' % x for x in groups])
  282. ssh_cmd = ['ssh', '-p', cls.gerrit_instance.ssh_port,
  283. '-i', cls.gerrit_instance.ssh_ident,
  284. '-o', 'NoHostAuthenticationForLocalhost=yes',
  285. '-o', 'StrictHostKeyChecking=no',
  286. '%s@localhost' % cls.TEST_USERNAME, gerrit_cmd]
  287. cls.check_call(ssh_cmd)
  288. @classmethod
  289. def _stop_gerrit(cls, gerrit_instance):
  290. """Stops the running gerrit instance and deletes it."""
  291. try:
  292. # This should terminate the gerrit process.
  293. cls.check_call(['bash', gerrit_instance.gerrit_exe, 'stop'])
  294. finally:
  295. try:
  296. # cls.gerrit_pid should have already terminated. If it did, then
  297. # os.waitpid will raise OSError.
  298. os.waitpid(gerrit_instance.gerrit_pid, os.WNOHANG)
  299. except OSError as e:
  300. if e.errno == errno.ECHILD:
  301. # If gerrit shut down cleanly, os.waitpid will land here.
  302. # pylint: disable=lost-exception
  303. return
  304. # If we get here, the gerrit process is still alive. Send the process
  305. # SIGKILL for good measure.
  306. try:
  307. os.kill(gerrit_instance.gerrit_pid, signal.SIGKILL)
  308. except OSError:
  309. if e.errno == errno.ESRCH:
  310. # os.kill raised an error because the process doesn't exist. Maybe
  311. # gerrit shut down cleanly after all.
  312. # pylint: disable=lost-exception
  313. return
  314. # Announce that gerrit didn't shut down cleanly.
  315. msg = 'Test gerrit server (pid=%d) did not shut down cleanly.' % (
  316. gerrit_instance.gerrit_pid)
  317. print >> sys.stderr, msg
  318. @classmethod
  319. def tearDownClass(cls):
  320. gerrit_util.NETRC = cls.gerrit_util_netrc_orig
  321. gerrit_util.GERRIT_PROTOCOL = cls.gerrit_util_protocol_orig
  322. if TEARDOWN:
  323. cls._stop_gerrit(cls.gerrit_instance)
  324. shutil.rmtree(cls.gerrit_instance.gerrit_dir)
  325. class RepoTestCase(GerritTestCase):
  326. """Test class which runs in a repo checkout."""
  327. REPO_URL = 'https://chromium.googlesource.com/external/repo'
  328. MANIFEST_PROJECT = 'remotepath/manifest'
  329. MANIFEST_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
  330. <manifest>
  331. <remote name="remote1"
  332. fetch="%(gerrit_url)s"
  333. review="%(gerrit_host)s" />
  334. <remote name="remote2"
  335. fetch="%(gerrit_url)s"
  336. review="%(gerrit_host)s" />
  337. <default revision="refs/heads/master" remote="remote1" sync-j="1" />
  338. <project remote="remote1" path="localpath/testproj1" name="remotepath/testproj1" />
  339. <project remote="remote1" path="localpath/testproj2" name="remotepath/testproj2" />
  340. <project remote="remote2" path="localpath/testproj3" name="remotepath/testproj3" />
  341. <project remote="remote2" path="localpath/testproj4" name="remotepath/testproj4" />
  342. </manifest>
  343. """
  344. @classmethod
  345. def setUpClass(cls):
  346. GerritTestCase.setUpClass()
  347. gi = cls.gerrit_instance
  348. # Create local mirror of repo tool repository.
  349. repo_mirror_path = os.path.join(gi.git_dir, 'repo.git')
  350. cls.check_call(
  351. ['git', 'clone', '--mirror', cls.REPO_URL, repo_mirror_path])
  352. # Check out the top-level repo script; it will be used for invocation.
  353. repo_clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'repo')
  354. cls.check_call(['git', 'clone', '-n', repo_mirror_path, repo_clone_path])
  355. cls.check_call(
  356. ['git', 'checkout', 'origin/stable', 'repo'], cwd=repo_clone_path)
  357. shutil.rmtree(os.path.join(repo_clone_path, '.git'))
  358. cls.repo_exe = os.path.join(repo_clone_path, 'repo')
  359. # Create manifest repository.
  360. cls.createProject(cls.MANIFEST_PROJECT)
  361. clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'manifest')
  362. cls._CloneProject(cls.MANIFEST_PROJECT, clone_path)
  363. manifest_path = os.path.join(clone_path, 'default.xml')
  364. with open(manifest_path, 'w') as fh:
  365. fh.write(cls.MANIFEST_TEMPLATE % gi.__dict__)
  366. cls.check_call(['git', 'add', 'default.xml'], cwd=clone_path)
  367. cls.check_call(['git', 'commit', '-m', 'Test manifest.'], cwd=clone_path)
  368. cls._PushBranch(clone_path)
  369. # Create project repositories.
  370. for i in xrange(1, 5):
  371. proj = 'testproj%d' % i
  372. cls.createProject('remotepath/%s' % proj)
  373. clone_path = os.path.join(gi.gerrit_dir, 'tmp', proj)
  374. cls._CloneProject('remotepath/%s' % proj, clone_path)
  375. cls._CreateCommit(clone_path)
  376. cls._PushBranch(clone_path, 'master')
  377. def setUp(self):
  378. super(RepoTestCase, self).setUp()
  379. manifest_url = '/'.join((self.gerrit_instance.gerrit_url,
  380. self.MANIFEST_PROJECT))
  381. repo_url = '/'.join((self.gerrit_instance.gerrit_url, 'repo'))
  382. self.check_call(
  383. [self.repo_exe, 'init', '-u', manifest_url, '--repo-url',
  384. repo_url, '--no-repo-verify'], cwd=self.tempdir)
  385. self.check_call([self.repo_exe, 'sync'], cwd=self.tempdir)
  386. for i in xrange(1, 5):
  387. clone_path = os.path.join(self.tempdir, 'localpath', 'testproj%d' % i)
  388. self._post_clone_bookkeeping(clone_path)
  389. # Tell 'repo upload' to upload this project without prompting.
  390. config_path = os.path.join(clone_path, '.git', 'config')
  391. self.check_call(
  392. ['git', 'config', '--file', config_path, 'review.%s.upload' %
  393. self.gerrit_instance.gerrit_host, 'true'])
  394. @classmethod
  395. def runRepo(cls, *args, **kwargs):
  396. # Unfortunately, munging $HOME appears to be the only way to control the
  397. # netrc file used by repo.
  398. munged_home = os.path.join(cls.gerrit_instance.gerrit_dir, 'tmp')
  399. if 'env' not in kwargs:
  400. env = kwargs['env'] = os.environ.copy()
  401. env['HOME'] = munged_home
  402. else:
  403. env.setdefault('HOME', munged_home)
  404. args[0].insert(0, cls.repo_exe)
  405. cls.check_call(*args, **kwargs)
  406. def uploadChange(self, clone_path, branch='master', remote='origin'):
  407. review_host = self.check_output(
  408. ['git', 'config', 'remote.%s.review' % remote],
  409. cwd=clone_path).strip()
  410. assert(review_host)
  411. projectname = self.check_output(
  412. ['git', 'config', 'remote.%s.projectname' % remote],
  413. cwd=clone_path).strip()
  414. assert(projectname)
  415. GerritTestCase._UploadChange(
  416. clone_path, branch=branch, remote='%s://%s/%s' % (
  417. gerrit_util.GERRIT_PROTOCOL, review_host, projectname))