gerrit_test_case.py 18 KB

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