Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(57)

Side by Side Diff: testing_support/gerrit_test_case.py

Issue 26399002: Add git/gerrit-on-borg utilities. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Cleanup, minor refactor, doc string. Created 7 years, 2 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« gerrit_util.py ('K') | « gerrit_util.py ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
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
5 """Test framework for code that interacts with gerrit.
6
7 class GerritTestCase
8 --------------------------------------------------------------------------------
9 This class initializes and runs an a gerrit instance on localhost. To use the
10 framework, define a class that extends GerritTestCase, and then do standard
11 python unittest development as described here:
12
13 http://docs.python.org/2.7/library/unittest.html#basic-example
14
15 When your test code runs, the framework will:
16
17 - Download the latest stable(-ish) binary release of the gerrit code.
18 - Start up a live gerrit instance running in a temp directory on the localhost.
19 - Set up a single gerrit user account with admin priveleges.
20 - Supply credential helpers for interacting with the gerrit instance via http
21 or ssh.
22
23 Refer to depot_tools/testing_support/gerrit-init.sh for details about how the
24 gerrit instance is set up, and refer to helper methods defined below
25 (createProject, cloneProject, uploadChange, etc.) for ways to interact with the
26 gerrit instance from your test methods.
27
28
29 class RepoTestCase
30 --------------------------------------------------------------------------------
31 This class extends GerritTestCase, and creates a set of project repositories
32 and a manifest repository that can be used in conjunction with the 'repo' tool.
33
34 Each test method will initialize and sync a brand-new repo working directory.
35 The 'repo' command may be invoked in a subprocess as part of your tests.
36
37 One gotcha: 'repo upload' will always attempt to use the ssh interface to talk
38 to gerrit.
39 """
40
41 import collections
42 from cStringIO import StringIO
43 import errno
44 import httplib
45 import json
46 import mock
Vadim Sh. 2013/10/10 21:55:47 Is it always available? Is it a requirement for ge
szager 2013/10/10 23:58:07 Fair point; I got rid of all the mock stuff.
47 import netrc
48 import os
49 import re
50 import shutil
51 import signal
52 import socket
53 import stat
54 import subprocess
55 import sys
56 import tempfile
57 import unittest
58 import urllib
59
60 import gerrit_util
Vadim Sh. 2013/10/10 21:55:47 I'm assume depot_tools/ is in sys.path? gerrit_tes
szager 2013/10/10 23:58:07 Just to be safe, derive path to gerrit_util.py fro
61
62
63 # When debugging test code, it's sometimes helpful to leave the test gerrit
64 # instance intact and running after the test code exits. Setting TEARDOWN
65 # to False will do that.
66 TEARDOWN = True
Vadim Sh. 2013/10/10 21:55:47 I also find auth.type = DEVELOPMENT_BECOME_ANY_ACC
szager 2013/10/10 23:58:07 Added this to gerrit-init.sh
67
68 class GerritTestCase(unittest.TestCase):
69 """Test class for tests that interact with a gerrit server.
70
71 The class setup creates and launches a stand-alone gerrit instance running on
72 localhost, for test methods to interact with. Class teardown stops and
73 deletes the gerrit instance.
74
75 Note that there is a single gerrit instance for ALL test methods in a
76 GerritTestCase sub-class.
77 """
78
79 COMMIT_RE = re.compile(r'^commit ([0-9a-fA-F]{40})$')
80 CHANGEID_RE = re.compile('^\s+Change-Id:\s*(\S+)$')
81 DEVNULL = open(os.devnull, 'w')
82 TEST_USERNAME = 'test-username'
83 TEST_EMAIL = 'test-username@test.org'
84
85 GerritInstance = collections.namedtuple('GerritInstance', [
86 'credential_file',
87 'gerrit_dir',
88 'gerrit_exe',
89 'gerrit_host',
90 'gerrit_pid',
91 'gerrit_url',
92 'git_dir',
93 'git_host',
94 'git_url',
95 'http_port',
96 'netrc_file',
97 'ssh_ident',
98 'ssh_port',
99 ])
100
101 @classmethod
102 def check_call(cls, *args, **kwargs):
103 kwargs.setdefault('stdout', cls.DEVNULL)
104 kwargs.setdefault('stderr', cls.DEVNULL)
105 subprocess.check_call(*args, **kwargs)
106
107 @classmethod
108 def check_output(cls, *args, **kwargs):
109 kwargs.setdefault('stderr', cls.DEVNULL)
110 return subprocess.check_output(*args, **kwargs)
111
112 @classmethod
113 def _create_gerrit_instance(cls, gerrit_dir):
114 gerrit_init_script = os.path.join(
115 os.path.dirname(__file__), 'gerrit-init.sh')
Vadim Sh. 2013/10/10 21:55:47 __file__ can be a path relative to a cwd when pyth
szager 2013/10/10 23:58:07 Done.
116 http_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
117 http_sock.bind(('', 0))
118 http_port = str(http_sock.getsockname()[1])
119 ssh_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
120 ssh_sock.bind(('', 0))
121 ssh_port = str(ssh_sock.getsockname()[1])
122
123 # NOTE: this is not completely safe. These port numbers could be
124 # re-assigned by the OS between the calls to socket.close() and gerrit
125 # starting up. The only safe way to do this would be to pass file
126 # descriptors down to the gerrit process, which is not even remotely
127 # supported. Alas.
128 http_sock.close()
129 ssh_sock.close()
130 cls.check_call(['bash', gerrit_init_script, '--http-port', http_port,
131 '--ssh-port', ssh_port, gerrit_dir])
132
133 gerrit_exe = os.path.join(gerrit_dir, 'bin', 'gerrit.sh')
134 cls.check_call(['bash', gerrit_exe, 'start'])
135 with open(os.path.join(gerrit_dir, 'logs', 'gerrit.pid')) as fh:
136 gerrit_pid = int(fh.read().rstrip())
137
138 return cls.GerritInstance(
139 credential_file=os.path.join(gerrit_dir, 'tmp', '.git-credentials'),
140 gerrit_dir=gerrit_dir,
141 gerrit_exe=gerrit_exe,
142 gerrit_host='localhost:%s' % http_port,
143 gerrit_pid=gerrit_pid,
144 gerrit_url='http://localhost:%s' % http_port,
145 git_dir=os.path.join(gerrit_dir, 'git'),
146 git_host='%s/git' % gerrit_dir,
147 git_url='file://%s/git' % gerrit_dir,
148 http_port=http_port,
149 netrc_file=os.path.join(gerrit_dir, 'tmp', '.netrc'),
150 ssh_ident=os.path.join(gerrit_dir, 'tmp', 'id_rsa'),
151 ssh_port=ssh_port,)
152
153 @classmethod
154 def setUpClass(cls):
155 """Sets up the gerrit instances in a class-specific temp dir."""
156 # Create gerrit instance.
157 gerrit_dir = tempfile.mkdtemp()
158 os.chmod(gerrit_dir, 0700)
159 gi = cls.gerrit_instance = cls._create_gerrit_instance(gerrit_dir)
160
161 # Set netrc file for http authentication.
162 cls.netrc_patcher = mock.patch.object(
163 gerrit_util, 'NETRC', netrc.netrc(gi.netrc_file))
164 cls.netrc_patcher.start()
165
166 # gerrit_util only knows about https connections, and that's a good thing.
167 # But for testing, it's much simpler to use http connections.
168 cls.httplib_patcher = mock.patch.object(
169 httplib, 'HTTPSConnection', httplib.HTTPConnection)
170 cls.httplib_patcher.start()
171 cls.protocol_patcher = mock.patch.object(
172 gerrit_util, 'GERRIT_PROTOCOL', 'http')
173 cls.protocol_patcher.start()
174
175 # Because we communicate with the test server via http, rather than https,
176 # libcurl won't add authentication headers to raw git requests unless the
177 # gerrit server returns 401. That works for pushes, but for read operations
178 # (like git-ls-remote), gerrit will simply omit any ref that requires
179 # authentication. By default gerrit doesn't permit anonymous read access to
180 # refs/meta/config. Override that behavior so tests can access
181 # refs/meta/config if necessary.
182 clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'All-Projects')
183 cls._CloneProject('All-Projects', clone_path)
184 project_config = os.path.join(clone_path, 'project.config')
185 cls.check_call(['git', 'config', '--file', project_config, '--add',
186 'access.refs/meta/config.read', 'group Anonymous Users'])
187 cls.check_call(['git', 'add', project_config], cwd=clone_path)
188 cls.check_call(
189 ['git', 'commit', '-m', 'Anonyous read for refs/meta/config'],
190 cwd=clone_path)
191 cls.check_call(['git', 'push', 'origin', 'HEAD:refs/meta/config'],
192 cwd=clone_path)
193
194 def setUp(self):
195 self.tempdir = tempfile.mkdtemp()
196 os.chmod(self.tempdir, 0700)
197
198 def tearDown(self):
199 if TEARDOWN:
200 shutil.rmtree(self.tempdir)
201
202 @classmethod
203 def createProject(cls, name, description='Test project', owners=None,
204 submit_type='CHERRY_PICK'):
205 """Create a project on the test gerrit server."""
206 if owners is None:
207 owners = ['Administrators']
208 body = {
209 'description': description,
210 'submit_type': submit_type,
211 'owners': owners,
212 }
213 path = 'projects/%s' % urllib.quote(name, '')
214 conn = gerrit_util.CreateHttpConn(
215 cls.gerrit_instance.gerrit_host, path, reqtype='PUT', body=body)
216 response = conn.getresponse()
217 assert response.status == 201
218 s = StringIO(response.read())
Vadim Sh. 2013/10/10 21:55:47 Can it use ReadHttpJsonResponse?
szager 2013/10/10 23:58:07 The only problem is that ReadHttpJsonResponse dema
219 assert s.readline().rstrip() == ")]}'"
220 jmsg = json.load(s)
221 assert jmsg['name'] == name
222
223 @classmethod
224 def _CloneProject(cls, name, path):
Vadim Sh. 2013/10/10 21:55:47 I see 3 different naming styles: 1) createProject
szager 2013/10/10 23:58:07 Yeah, I'm open to suggestions here. Basically: _
225 """Clone a project from the test gerrit server."""
226 gi = cls.gerrit_instance
227 parent_dir = os.path.dirname(path)
228 if not os.path.exists(parent_dir):
229 os.makedirs(parent_dir)
230 url = '/'.join((gi.gerrit_url, name))
231 cls.check_call(['git', 'clone', url, path])
232
233 # Install commit-msg hook to add Change-Id lines.
234 hook_path = os.path.join(path, '.git', 'hooks', 'commit-msg')
235 cls.check_call(['curl', '-o', hook_path,
236 '/'.join((gi.gerrit_url, 'tools/hooks/commit-msg'))])
237 os.chmod(hook_path, stat.S_IRWXU)
238
239 config_path = os.path.join(path, '.git', 'config')
240 # Set git identity to test account
241 cls.check_call(['git', 'config', '--file', config_path, 'user.email',
242 cls.TEST_EMAIL])
243 # Configure non-interactive credentials for git operations.
244 cls.check_call(['git', 'config', '--file', config_path, 'credential.helper',
245 'store --file=%s' % gi.credential_file])
246 return path
247
248 def cloneProject(self, name, path=None):
249 """Clone a project from the test gerrit server."""
250 if path is None:
251 path = os.path.basename(name)
252 if path.endswith('.git'):
253 path = path[:-4]
254 path = os.path.join(self.tempdir, path)
255 return self._CloneProject(name, path)
256
257 @classmethod
258 def _CreateCommit(cls, clone_path, fn=None, msg=None, text=None):
259 """Create a commit in the given git checkout."""
260 if not fn:
261 fn = 'test-file.txt'
262 if not msg:
263 msg = 'Test Message'
264 if not text:
265 text = 'Another day, another dollar.'
266 fpath = os.path.join(clone_path, fn)
267 with open(fpath, 'a') as fh:
268 fh.write('%s\n' % text)
269 cls.check_call(['git', 'add', fn], cwd=clone_path)
270 cls.check_call(['git', 'commit', '-m', msg], cwd=clone_path)
271 return cls._GetCommit(clone_path)
272
273 def createCommit(self, clone_path, fn=None, msg=None, text=None):
274 """Create a commit in the given git checkout."""
275 clone_path = os.path.join(self.tempdir, clone_path)
276 return self._CreateCommit(clone_path, fn, msg, text)
277
278 @classmethod
279 def _GetCommit(cls, clone_path, ref='HEAD'):
280 """Get the sha1 and change-id for a ref in the git checkout."""
281 log_proc = cls.check_output(['git', 'log', '-n', '1', ref], cwd=clone_path)
282 sha1 = None
283 change_id = None
284 for line in log_proc.splitlines():
285
Vadim Sh. 2013/10/10 21:55:47 why is this new line here?
szager 2013/10/10 23:58:07 Removed.
286 match = cls.COMMIT_RE.match(line)
287 if match:
288 sha1 = match.group(1)
289 continue
290 match = cls.CHANGEID_RE.match(line)
291 if match:
292 change_id = match.group(1)
293 continue
294 assert sha1
295 assert change_id
296 return (sha1, change_id)
297
298 def getCommit(self, clone_path, ref='HEAD'):
299 """Get the sha1 and change-id for a ref in the git checkout."""
300 clone_path = os.path.join(self.tempdir, clone_path)
301 return self._GetCommit(clone_path, ref)
302
303 @classmethod
304 def _UploadChange(cls, clone_path, branch='master', remote='origin'):
305 """Create a gerrit CL from the HEAD of a git checkout."""
306 cls.check_call(
307 ['git', 'push', remote, 'HEAD:refs/for/%s' % branch], cwd=clone_path)
308
309 def uploadChange(self, clone_path, branch='master', remote='origin'):
310 """Create a gerrit CL from the HEAD of a git checkout."""
311 clone_path = os.path.join(self.tempdir, clone_path)
312 self._UploadChange(clone_path, branch, remote)
313
314 @classmethod
315 def _PushBranch(cls, clone_path, branch='master'):
316 """Push a branch directly to gerrit, bypassing code review."""
317 cls.check_call(
318 ['git', 'push', 'origin', 'HEAD:refs/heads/%s' % branch],
319 cwd=clone_path)
320
321 def pushBranch(self, clone_path, branch='master'):
322 """Push a branch directly to gerrit, bypassing code review."""
323 clone_path = os.path.join(self.tempdir, clone_path)
324 self._PushBranch(clone_path, branch)
325
326 @classmethod
327 def createAccount(cls, name='Test User', email='test-user@test.org',
328 password=None, groups=None):
329 """Create a new user account on gerrit."""
330 username = email.partition('@')[0]
331 gerrit_cmd = 'gerrit create-account %s --full-name "%s" --email %s' % (
332 username, name, email)
333 if password:
334 gerrit_cmd += ' --http-password "%s"' % password
335 if groups:
336 gerrit_cmd += ' '.join(['--group %s' % x for x in groups])
337 ssh_cmd = ['ssh', '-p', cls.gerrit_instance.ssh_port,
338 '-i', cls.gerrit_instance.ssh_ident,
339 '-o', 'NoHostAuthenticationForLocalhost=yes',
340 '-o', 'StrictHostKeyChecking=no',
341 '%s@localhost' % cls.TEST_USERNAME, gerrit_cmd]
342 cls.check_call(ssh_cmd)
343
344 @classmethod
345 def _stop_gerrit(cls, gerrit_instance):
346 """Stops the running gerrit instance and deletes it."""
347 try:
348 # This should terminate the gerrit process.
349 cls.check_call(['bash', gerrit_instance.gerrit_exe, 'stop'])
350 finally:
351 try:
352 # cls.gerrit_pid should have already terminated. If it did, then
353 # os.waitpid will raise OSError.
354 os.waitpid(gerrit_instance.gerrit_pid, os.WNOHANG)
355 except OSError as e:
356 if e.errno == errno.ECHILD:
357 # If gerrit shut down cleanly, os.waitpid will land here.
358 # pylint: disable=W0150
359 return
360
361 # If we get here, the gerrit process is still alive. Send the process
362 # SIGKILL for good measure.
363 try:
364 os.kill(gerrit_instance.gerrit_pid, signal.SIGKILL)
365 except OSError:
366 if e.errno == errno.ESRCH:
367 # os.kill raised an error because the process doesn't exist. Maybe
368 # gerrit shut down cleanly after all.
369 # pylint: disable=W0150
370 return
371
372 # Announce that gerrit didn't shut down cleanly.
373 msg = 'Test gerrit server (pid=%d) did not shut down cleanly.' % (
374 gerrit_instance.gerrit_pid)
375 print >> sys.stderr, msg
376
377 @classmethod
378 def tearDownClass(cls):
379 cls.httplib_patcher.stop()
380 cls.protocol_patcher.stop()
381 if TEARDOWN:
382 cls._stop_gerrit(cls.gerrit_instance)
383 shutil.rmtree(cls.gerrit_instance.gerrit_dir)
384
385
386 class RepoTestCase(GerritTestCase):
387 """Test class which runs in a repo checkout."""
388
389 REPO_URL = 'https://chromium.googlesource.com/external/repo'
390 MANIFEST_PROJECT = 'remotepath/manifest'
391 MANIFEST_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
392 <manifest>
393 <remote name="remote1"
394 fetch="%(gerrit_url)s"
395 review="%(gerrit_host)s" />
396 <remote name="remote2"
397 fetch="%(gerrit_url)s"
398 review="%(gerrit_host)s" />
399 <default revision="refs/heads/master" remote="remote1" sync-j="1" />
400 <project remote="remote1" path="localpath/testproj1" name="remotepath/testproj 1" />
401 <project remote="remote1" path="localpath/testproj2" name="remotepath/testproj 2" />
402 <project remote="remote2" path="localpath/testproj3" name="remotepath/testproj 3" />
403 <project remote="remote2" path="localpath/testproj4" name="remotepath/testproj 4" />
404 </manifest>
405 """
406
407 @classmethod
408 def setUpClass(cls):
409 GerritTestCase.setUpClass()
410 gi = cls.gerrit_instance
411
412 # Create local mirror of repo tool repository.
413 mirror_path = os.path.join(gi.git_dir, 'repo.git')
414 cls.check_call(
Vadim Sh. 2013/10/10 21:55:47 Are we OK with touching the network? We can proba
szager 2013/10/10 23:58:07 I also prefer that unit tests don't touch the netw
415 ['git', 'clone', '--mirror', cls.REPO_URL, mirror_path])
416
417 # Check out the top-level repo script; it will be used for invocation.
418 repo_clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'repo')
419 cls.check_call(['git', 'clone', '-n', cls.REPO_URL, repo_clone_path])
Vadim Sh. 2013/10/10 21:55:47 Maybe clone from mirror created above?
szager 2013/10/10 23:58:07 Ah yes, that was the intention. Fixed.
420 cls.check_call(
421 ['git', 'checkout', 'origin/stable', 'repo'], cwd=repo_clone_path)
422 shutil.rmtree(os.path.join(repo_clone_path, '.git'))
423 cls.repo_exe = os.path.join(repo_clone_path, 'repo')
424
425 # Create manifest repository.
426 cls.createProject(cls.MANIFEST_PROJECT)
427 clone_path = os.path.join(gi.gerrit_dir, 'tmp', 'manifest')
428 cls._CloneProject(cls.MANIFEST_PROJECT, clone_path)
429 manifest_path = os.path.join(clone_path, 'default.xml')
430 with open(manifest_path, 'w') as fh:
431 fh.write(cls.MANIFEST_TEMPLATE % gi.__dict__)
432 cls.check_call(['git', 'add', 'default.xml'], cwd=clone_path)
433 cls.check_call(['git', 'commit', '-m', 'Test manifest.'], cwd=clone_path)
434 cls._PushBranch(clone_path)
435
436 # Create project repositories.
437 for i in xrange(1, 5):
438 proj = 'testproj%d' % i
439 cls.createProject('remotepath/%s' % proj)
440 clone_path = os.path.join(gi.gerrit_dir, 'tmp', proj)
441 cls._CloneProject('remotepath/%s' % proj, clone_path)
442 cls._CreateCommit(clone_path)
443 cls._PushBranch(clone_path, 'master')
444
445 def setUp(self):
446 super(RepoTestCase, self).setUp()
447 manifest_url = '/'.join((self.gerrit_instance.gerrit_url,
448 self.MANIFEST_PROJECT))
449 repo_url = '/'.join((self.gerrit_instance.gerrit_url, 'repo'))
450 self.check_call(
451 [self.repo_exe, 'init', '-u', manifest_url, '--repo-url',
452 repo_url, '--no-repo-verify'], cwd=self.tempdir)
453 self.check_call([self.repo_exe, 'sync'], cwd=self.tempdir)
454 for i in xrange(1, 5):
455 # Do the extra bookkeeping that _CloneProject does.
456 proj = 'testproj%d' % i
Vadim Sh. 2013/10/10 21:55:47 Can this bookkeeping be extracted into separate fu
szager 2013/10/10 23:58:07 Done.
457 config_path = os.path.join(
458 self.tempdir, 'localpath', proj, '.git', 'config')
459 self.check_call(['git', 'config', '--file', config_path, 'user.email',
460 self.TEST_EMAIL])
461 self.check_call(
462 ['git', 'config', '--file', config_path, 'credential.helper',
463 'store --file=%s' % self.gerrit_instance.credential_file])
464 # Tell 'repo upload' to upload this project without prompting.
465 self.check_call(
466 ['git', 'config', '--file', config_path, 'review.%s.upload' %
467 self.gerrit_instance.gerrit_host, 'true'])
468
469 @classmethod
470 def runRepo(cls, *args, **kwargs):
471 # Unfortunately, munging $HOME appears to be the only way to control the
472 # netrc file used by repo.
473 munged_home = os.path.join(cls.gerrit_instance.gerrit_dir, 'tmp')
474 if 'env' not in kwargs:
475 env = kwargs['env'] = os.environ.copy()
476 env['HOME'] = munged_home
477 else:
478 env.setdefault('HOME', munged_home)
479 args[0].insert(0, cls.repo_exe)
480 cls.check_call(*args, **kwargs)
481
482 def uploadChange(self, clone_path, branch='master', remote='origin'):
483 review_host = self.check_output(
484 ['git', 'config', 'remote.%s.review' % remote],
485 cwd=clone_path).strip()
486 assert(review_host)
487 projectname = self.check_output(
488 ['git', 'config', 'remote.%s.projectname' % remote],
489 cwd=clone_path).strip()
490 assert(projectname)
491 GerritTestCase._UploadChange(
492 clone_path, branch=branch, remote='%s://%s/%s' % (
493 gerrit_util.GERRIT_PROTOCOL, review_host, projectname))
OLDNEW
« gerrit_util.py ('K') | « gerrit_util.py ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698