##// END OF EJS Templates
hooks: adjust to handle protected branch cases, and force push.
marcink -
r509:1db1b667 default
parent child Browse files
Show More
@@ -1,106 +1,116 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2018 RhodeCode GmbH
3 3 #
4 4 # This program is free software; you can redistribute it and/or modify
5 5 # it under the terms of the GNU General Public License as published by
6 6 # the Free Software Foundation; either version 3 of the License, or
7 7 # (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software Foundation,
16 16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17 17
18 18 """
19 19 Special exception handling over the wire.
20 20
21 21 Since we cannot assume that our client is able to import our exception classes,
22 22 this module provides a "wrapping" mechanism to raise plain exceptions
23 23 which contain an extra attribute `_vcs_kind` to allow a client to distinguish
24 24 different error conditions.
25 25 """
26 26
27 import functools
28 from pyramid.httpexceptions import HTTPLocked
27 from pyramid.httpexceptions import HTTPLocked, HTTPForbidden
29 28
30 29
31 30 def _make_exception(kind, org_exc, *args):
32 31 """
33 32 Prepares a base `Exception` instance to be sent over the wire.
34 33
35 34 To give our caller a hint what this is about, it will attach an attribute
36 35 `_vcs_kind` to the exception.
37 36 """
38 37 exc = Exception(*args)
39 38 exc._vcs_kind = kind
40 39 exc._org_exc = org_exc
41 40 return exc
42 41
43 42
44 43 def AbortException(org_exc=None):
45 44 def _make_exception_wrapper(*args):
46 45 return _make_exception('abort', org_exc, *args)
47 46 return _make_exception_wrapper
48 47
49 48
50 49 def ArchiveException(org_exc=None):
51 50 def _make_exception_wrapper(*args):
52 51 return _make_exception('archive', org_exc, *args)
53 52 return _make_exception_wrapper
54 53
55 54
56 55 def LookupException(org_exc=None):
57 56 def _make_exception_wrapper(*args):
58 57 return _make_exception('lookup', org_exc, *args)
59 58 return _make_exception_wrapper
60 59
61 60
62 61 def VcsException(org_exc=None):
63 62 def _make_exception_wrapper(*args):
64 63 return _make_exception('error', org_exc, *args)
65 64 return _make_exception_wrapper
66 65
67 66
68 67 def RepositoryLockedException(org_exc=None):
69 68 def _make_exception_wrapper(*args):
70 69 return _make_exception('repo_locked', org_exc, *args)
71 70 return _make_exception_wrapper
72 71
73 72
73 def RepositoryBranchProtectedException(org_exc=None):
74 def _make_exception_wrapper(*args):
75 return _make_exception('repo_branch_protected', org_exc, *args)
76 return _make_exception_wrapper
77
78
74 79 def RequirementException(org_exc=None):
75 80 def _make_exception_wrapper(*args):
76 81 return _make_exception('requirement', org_exc, *args)
77 82 return _make_exception_wrapper
78 83
79 84
80 85 def UnhandledException(org_exc=None):
81 86 def _make_exception_wrapper(*args):
82 87 return _make_exception('unhandled', org_exc, *args)
83 88 return _make_exception_wrapper
84 89
85 90
86 91 def URLError(org_exc=None):
87 92 def _make_exception_wrapper(*args):
88 93 return _make_exception('url_error', org_exc, *args)
89 94 return _make_exception_wrapper
90 95
91 96
92 97 def SubrepoMergeException(org_exc=None):
93 98 def _make_exception_wrapper(*args):
94 99 return _make_exception('subrepo_merge_error', org_exc, *args)
95 100 return _make_exception_wrapper
96 101
97 102
98 103 class HTTPRepoLocked(HTTPLocked):
99 104 """
100 105 Subclass of HTTPLocked response that allows to set the title and status
101 106 code via constructor arguments.
102 107 """
103 108 def __init__(self, title, status_code=None, **kwargs):
104 109 self.code = status_code or HTTPLocked.code
105 110 self.title = title
106 111 super(HTTPRepoLocked, self).__init__(**kwargs)
112
113
114 class HTTPRepoBranchProtected(HTTPForbidden):
115 def __init__(self, *args, **kwargs):
116 super(HTTPForbidden, self).__init__(*args, **kwargs)
@@ -1,572 +1,653 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # RhodeCode VCSServer provides access to different vcs backends via network.
4 4 # Copyright (C) 2014-2018 RhodeCode GmbH
5 5 #
6 6 # This program is free software; you can redistribute it and/or modify
7 7 # it under the terms of the GNU General Public License as published by
8 8 # the Free Software Foundation; either version 3 of the License, or
9 9 # (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software Foundation,
18 18 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
19 19
20 20 import io
21 21 import os
22 22 import sys
23 23 import logging
24 24 import collections
25 25 import importlib
26 26 import base64
27 27
28 28 from httplib import HTTPConnection
29 29
30 30
31 31 import mercurial.scmutil
32 32 import mercurial.node
33 33 import simplejson as json
34 34
35 35 from vcsserver import exceptions, subprocessio, settings
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 class HooksHttpClient(object):
41 41 connection = None
42 42
43 43 def __init__(self, hooks_uri):
44 44 self.hooks_uri = hooks_uri
45 45
46 46 def __call__(self, method, extras):
47 47 connection = HTTPConnection(self.hooks_uri)
48 48 body = self._serialize(method, extras)
49 49 try:
50 50 connection.request('POST', '/', body)
51 51 except Exception:
52 52 log.error('Connection failed on %s', connection)
53 53 raise
54 54 response = connection.getresponse()
55 55 return json.loads(response.read())
56 56
57 57 def _serialize(self, hook_name, extras):
58 58 data = {
59 59 'method': hook_name,
60 60 'extras': extras
61 61 }
62 62 return json.dumps(data)
63 63
64 64
65 65 class HooksDummyClient(object):
66 66 def __init__(self, hooks_module):
67 67 self._hooks_module = importlib.import_module(hooks_module)
68 68
69 69 def __call__(self, hook_name, extras):
70 70 with self._hooks_module.Hooks() as hooks:
71 71 return getattr(hooks, hook_name)(extras)
72 72
73 73
74 74 class RemoteMessageWriter(object):
75 75 """Writer base class."""
76 76 def write(self, message):
77 77 raise NotImplementedError()
78 78
79 79
80 80 class HgMessageWriter(RemoteMessageWriter):
81 81 """Writer that knows how to send messages to mercurial clients."""
82 82
83 83 def __init__(self, ui):
84 84 self.ui = ui
85 85
86 86 def write(self, message):
87 87 # TODO: Check why the quiet flag is set by default.
88 88 old = self.ui.quiet
89 89 self.ui.quiet = False
90 90 self.ui.status(message.encode('utf-8'))
91 91 self.ui.quiet = old
92 92
93 93
94 94 class GitMessageWriter(RemoteMessageWriter):
95 95 """Writer that knows how to send messages to git clients."""
96 96
97 97 def __init__(self, stdout=None):
98 98 self.stdout = stdout or sys.stdout
99 99
100 100 def write(self, message):
101 101 self.stdout.write(message.encode('utf-8'))
102 102
103 103
104 104 class SvnMessageWriter(RemoteMessageWriter):
105 105 """Writer that knows how to send messages to svn clients."""
106 106
107 107 def __init__(self, stderr=None):
108 108 # SVN needs data sent to stderr for back-to-client messaging
109 109 self.stderr = stderr or sys.stderr
110 110
111 111 def write(self, message):
112 112 self.stderr.write(message.encode('utf-8'))
113 113
114 114
115 115 def _handle_exception(result):
116 116 exception_class = result.get('exception')
117 117 exception_traceback = result.get('exception_traceback')
118 118
119 119 if exception_traceback:
120 120 log.error('Got traceback from remote call:%s', exception_traceback)
121 121
122 122 if exception_class == 'HTTPLockedRC':
123 123 raise exceptions.RepositoryLockedException()(*result['exception_args'])
124 elif exception_class == 'HTTPBranchProtected':
125 raise exceptions.RepositoryBranchProtectedException()(*result['exception_args'])
124 126 elif exception_class == 'RepositoryError':
125 127 raise exceptions.VcsException()(*result['exception_args'])
126 128 elif exception_class:
127 129 raise Exception('Got remote exception "%s" with args "%s"' %
128 130 (exception_class, result['exception_args']))
129 131
130 132
131 133 def _get_hooks_client(extras):
132 134 if 'hooks_uri' in extras:
133 135 protocol = extras.get('hooks_protocol')
134 136 return HooksHttpClient(extras['hooks_uri'])
135 137 else:
136 138 return HooksDummyClient(extras['hooks_module'])
137 139
138 140
139 141 def _call_hook(hook_name, extras, writer):
140 142 hooks_client = _get_hooks_client(extras)
141 143 log.debug('Hooks, using client:%s', hooks_client)
142 144 result = hooks_client(hook_name, extras)
143 145 log.debug('Hooks got result: %s', result)
144 146 writer.write(result['output'])
145 147 _handle_exception(result)
146 148
147 149 return result['status']
148 150
149 151
150 152 def _extras_from_ui(ui):
151 153 hook_data = ui.config('rhodecode', 'RC_SCM_DATA')
152 154 if not hook_data:
153 155 # maybe it's inside environ ?
154 156 env_hook_data = os.environ.get('RC_SCM_DATA')
155 157 if env_hook_data:
156 158 hook_data = env_hook_data
157 159
158 160 extras = {}
159 161 if hook_data:
160 162 extras = json.loads(hook_data)
161 163 return extras
162 164
163 165
164 def _rev_range_hash(repo, node):
166 def _rev_range_hash(repo, node, check_heads=False):
165 167
166 168 commits = []
169 revs = []
167 170 start = repo[node].rev()
168 for rev in xrange(start, len(repo)):
171 end = len(repo)
172 for rev in range(start, end):
173 revs.append(rev)
169 174 ctx = repo[rev]
170 175 commit_id = mercurial.node.hex(ctx.node())
171 176 branch = ctx.branch()
172 177 commits.append((commit_id, branch))
173 178
174 return commits
179 parent_heads = []
180 if check_heads:
181 parent_heads = _check_heads(repo, start, end, revs)
182 return commits, parent_heads
183
184
185 def _check_heads(repo, start, end, commits):
186 changelog = repo.changelog
187 parents = set()
188
189 for new_rev in commits:
190 for p in changelog.parentrevs(new_rev):
191 if p == mercurial.node.nullrev:
192 continue
193 if p < start:
194 parents.add(p)
195
196 for p in parents:
197 branch = repo[p].branch()
198 # The heads descending from that parent, on the same branch
199 parent_heads = set([p])
200 reachable = set([p])
201 for x in xrange(p + 1, end):
202 if repo[x].branch() != branch:
203 continue
204 for pp in changelog.parentrevs(x):
205 if pp in reachable:
206 reachable.add(x)
207 parent_heads.discard(pp)
208 parent_heads.add(x)
209 # More than one head? Suggest merging
210 if len(parent_heads) > 1:
211 return list(parent_heads)
212
213 return []
175 214
176 215
177 216 def repo_size(ui, repo, **kwargs):
178 217 extras = _extras_from_ui(ui)
179 218 return _call_hook('repo_size', extras, HgMessageWriter(ui))
180 219
181 220
182 221 def pre_pull(ui, repo, **kwargs):
183 222 extras = _extras_from_ui(ui)
184 223 return _call_hook('pre_pull', extras, HgMessageWriter(ui))
185 224
186 225
187 226 def pre_pull_ssh(ui, repo, **kwargs):
188 227 extras = _extras_from_ui(ui)
189 228 if extras and extras.get('SSH'):
190 229 return pre_pull(ui, repo, **kwargs)
191 230 return 0
192 231
193 232
194 233 def post_pull(ui, repo, **kwargs):
195 234 extras = _extras_from_ui(ui)
196 235 return _call_hook('post_pull', extras, HgMessageWriter(ui))
197 236
198 237
199 238 def post_pull_ssh(ui, repo, **kwargs):
200 239 extras = _extras_from_ui(ui)
201 240 if extras and extras.get('SSH'):
202 241 return post_pull(ui, repo, **kwargs)
203 242 return 0
204 243
205 244
206 245 def pre_push(ui, repo, node=None, **kwargs):
246 """
247 Mercurial pre_push hook
248 """
207 249 extras = _extras_from_ui(ui)
250 detect_force_push = extras.get('detect_force_push')
208 251
209 252 rev_data = []
210 253 if node and kwargs.get('hooktype') == 'pretxnchangegroup':
211 254 branches = collections.defaultdict(list)
212 for commit_id, branch in _rev_range_hash(repo, node):
255 commits, _heads = _rev_range_hash(repo, node, check_heads=detect_force_push)
256 for commit_id, branch in commits:
213 257 branches[branch].append(commit_id)
214 258
215 for branch, commits in branches.iteritems():
259 for branch, commits in branches.items():
216 260 old_rev = kwargs.get('node_last') or commits[0]
217 261 rev_data.append({
218 262 'old_rev': old_rev,
219 263 'new_rev': commits[-1],
220 264 'ref': '',
221 265 'type': 'branch',
222 266 'name': branch,
223 267 })
224 268
269 for push_ref in rev_data:
270 push_ref['multiple_heads'] = _heads
271
225 272 extras['commit_ids'] = rev_data
226 273 return _call_hook('pre_push', extras, HgMessageWriter(ui))
227 274
228 275
229 276 def pre_push_ssh(ui, repo, node=None, **kwargs):
230 277 if _extras_from_ui(ui).get('SSH'):
231 278 return pre_push(ui, repo, node, **kwargs)
232 279
233 280 return 0
234 281
235 282
236 283 def pre_push_ssh_auth(ui, repo, node=None, **kwargs):
284 """
285 Mercurial pre_push hook for SSH
286 """
237 287 extras = _extras_from_ui(ui)
238 288 if extras.get('SSH'):
239 289 permission = extras['SSH_PERMISSIONS']
240 290
241 291 if 'repository.write' == permission or 'repository.admin' == permission:
242 292 return 0
243 293
244 294 # non-zero ret code
245 295 return 1
246 296
247 297 return 0
248 298
249 299
250 300 def post_push(ui, repo, node, **kwargs):
301 """
302 Mercurial post_push hook
303 """
251 304 extras = _extras_from_ui(ui)
252 305
253 306 commit_ids = []
254 307 branches = []
255 308 bookmarks = []
256 309 tags = []
257 310
258 for commit_id, branch in _rev_range_hash(repo, node):
311 commits, _heads = _rev_range_hash(repo, node)
312 for commit_id, branch in commits:
259 313 commit_ids.append(commit_id)
260 314 if branch not in branches:
261 315 branches.append(branch)
262 316
263 317 if hasattr(ui, '_rc_pushkey_branches'):
264 318 bookmarks = ui._rc_pushkey_branches
265 319
266 320 extras['commit_ids'] = commit_ids
267 321 extras['new_refs'] = {
268 322 'branches': branches,
269 323 'bookmarks': bookmarks,
270 324 'tags': tags
271 325 }
272 326
273 327 return _call_hook('post_push', extras, HgMessageWriter(ui))
274 328
275 329
276 330 def post_push_ssh(ui, repo, node, **kwargs):
331 """
332 Mercurial post_push hook for SSH
333 """
277 334 if _extras_from_ui(ui).get('SSH'):
278 335 return post_push(ui, repo, node, **kwargs)
279 336 return 0
280 337
281 338
282 339 def key_push(ui, repo, **kwargs):
283 340 if kwargs['new'] != '0' and kwargs['namespace'] == 'bookmarks':
284 341 # store new bookmarks in our UI object propagated later to post_push
285 342 ui._rc_pushkey_branches = repo[kwargs['key']].bookmarks()
286 343 return
287 344
288 345
289 346 # backward compat
290 347 log_pull_action = post_pull
291 348
292 349 # backward compat
293 350 log_push_action = post_push
294 351
295 352
296 353 def handle_git_pre_receive(unused_repo_path, unused_revs, unused_env):
297 354 """
298 355 Old hook name: keep here for backward compatibility.
299 356
300 357 This is only required when the installed git hooks are not upgraded.
301 358 """
302 359 pass
303 360
304 361
305 362 def handle_git_post_receive(unused_repo_path, unused_revs, unused_env):
306 363 """
307 364 Old hook name: keep here for backward compatibility.
308 365
309 366 This is only required when the installed git hooks are not upgraded.
310 367 """
311 368 pass
312 369
313 370
314 371 HookResponse = collections.namedtuple('HookResponse', ('status', 'output'))
315 372
316 373
317 374 def git_pre_pull(extras):
318 375 """
319 376 Pre pull hook.
320 377
321 378 :param extras: dictionary containing the keys defined in simplevcs
322 379 :type extras: dict
323 380
324 381 :return: status code of the hook. 0 for success.
325 382 :rtype: int
326 383 """
327 384 if 'pull' not in extras['hooks']:
328 385 return HookResponse(0, '')
329 386
330 387 stdout = io.BytesIO()
331 388 try:
332 389 status = _call_hook('pre_pull', extras, GitMessageWriter(stdout))
333 390 except Exception as error:
334 391 status = 128
335 392 stdout.write('ERROR: %s\n' % str(error))
336 393
337 394 return HookResponse(status, stdout.getvalue())
338 395
339 396
340 397 def git_post_pull(extras):
341 398 """
342 399 Post pull hook.
343 400
344 401 :param extras: dictionary containing the keys defined in simplevcs
345 402 :type extras: dict
346 403
347 404 :return: status code of the hook. 0 for success.
348 405 :rtype: int
349 406 """
350 407 if 'pull' not in extras['hooks']:
351 408 return HookResponse(0, '')
352 409
353 410 stdout = io.BytesIO()
354 411 try:
355 412 status = _call_hook('post_pull', extras, GitMessageWriter(stdout))
356 413 except Exception as error:
357 414 status = 128
358 415 stdout.write('ERROR: %s\n' % error)
359 416
360 417 return HookResponse(status, stdout.getvalue())
361 418
362 419
363 420 def _parse_git_ref_lines(revision_lines):
364 421 rev_data = []
365 422 for revision_line in revision_lines or []:
366 423 old_rev, new_rev, ref = revision_line.strip().split(' ')
367 424 ref_data = ref.split('/', 2)
368 425 if ref_data[1] in ('tags', 'heads'):
369 426 rev_data.append({
370 427 'old_rev': old_rev,
371 428 'new_rev': new_rev,
372 429 'ref': ref,
373 430 'type': ref_data[1],
374 431 'name': ref_data[2],
375 432 })
376 433 return rev_data
377 434
378 435
379 436 def git_pre_receive(unused_repo_path, revision_lines, env):
380 437 """
381 438 Pre push hook.
382 439
383 440 :param extras: dictionary containing the keys defined in simplevcs
384 441 :type extras: dict
385 442
386 443 :return: status code of the hook. 0 for success.
387 444 :rtype: int
388 445 """
389 446 extras = json.loads(env['RC_SCM_DATA'])
390 447 rev_data = _parse_git_ref_lines(revision_lines)
391 448 if 'push' not in extras['hooks']:
392 449 return 0
450 empty_commit_id = '0' * 40
451
452 detect_force_push = extras.get('detect_force_push')
453
454 for push_ref in rev_data:
455 push_ref['pruned_sha'] = ''
456 if not detect_force_push:
457 # don't check for forced-push when we don't need to
458 continue
459
460 type_ = push_ref['type']
461 new_branch = push_ref['old_rev'] == empty_commit_id
462 if type_ == 'heads' and not new_branch:
463 old_rev = push_ref['old_rev']
464 new_rev = push_ref['new_rev']
465 cmd = [settings.GIT_EXECUTABLE, 'rev-list',
466 old_rev, '^{}'.format(new_rev)]
467 stdout, stderr = subprocessio.run_command(
468 cmd, env=os.environ.copy())
469 # means we're having some non-reachable objects, this forced push
470 # was used
471 if stdout:
472 push_ref['pruned_sha'] = stdout.splitlines()
473
393 474 extras['commit_ids'] = rev_data
394 475 return _call_hook('pre_push', extras, GitMessageWriter())
395 476
396 477
397 478 def git_post_receive(unused_repo_path, revision_lines, env):
398 479 """
399 480 Post push hook.
400 481
401 482 :param extras: dictionary containing the keys defined in simplevcs
402 483 :type extras: dict
403 484
404 485 :return: status code of the hook. 0 for success.
405 486 :rtype: int
406 487 """
407 488 extras = json.loads(env['RC_SCM_DATA'])
408 489 if 'push' not in extras['hooks']:
409 490 return 0
410 491
411 492 rev_data = _parse_git_ref_lines(revision_lines)
412 493
413 494 git_revs = []
414 495
415 496 # N.B.(skreft): it is ok to just call git, as git before calling a
416 497 # subcommand sets the PATH environment variable so that it point to the
417 498 # correct version of the git executable.
418 499 empty_commit_id = '0' * 40
419 500 branches = []
420 501 tags = []
421 502 for push_ref in rev_data:
422 503 type_ = push_ref['type']
423 504
424 505 if type_ == 'heads':
425 506 if push_ref['old_rev'] == empty_commit_id:
426 507 # starting new branch case
427 508 if push_ref['name'] not in branches:
428 509 branches.append(push_ref['name'])
429 510
430 511 # Fix up head revision if needed
431 512 cmd = [settings.GIT_EXECUTABLE, 'show', 'HEAD']
432 513 try:
433 514 subprocessio.run_command(cmd, env=os.environ.copy())
434 515 except Exception:
435 516 cmd = [settings.GIT_EXECUTABLE, 'symbolic-ref', 'HEAD',
436 517 'refs/heads/%s' % push_ref['name']]
437 518 print("Setting default branch to %s" % push_ref['name'])
438 519 subprocessio.run_command(cmd, env=os.environ.copy())
439 520
440 521 cmd = [settings.GIT_EXECUTABLE, 'for-each-ref',
441 522 '--format=%(refname)', 'refs/heads/*']
442 523 stdout, stderr = subprocessio.run_command(
443 524 cmd, env=os.environ.copy())
444 525 heads = stdout
445 526 heads = heads.replace(push_ref['ref'], '')
446 527 heads = ' '.join(head for head
447 528 in heads.splitlines() if head) or '.'
448 529 cmd = [settings.GIT_EXECUTABLE, 'log', '--reverse',
449 530 '--pretty=format:%H', '--', push_ref['new_rev'],
450 531 '--not', heads]
451 532 stdout, stderr = subprocessio.run_command(
452 533 cmd, env=os.environ.copy())
453 534 git_revs.extend(stdout.splitlines())
454 535 elif push_ref['new_rev'] == empty_commit_id:
455 536 # delete branch case
456 537 git_revs.append('delete_branch=>%s' % push_ref['name'])
457 538 else:
458 539 if push_ref['name'] not in branches:
459 540 branches.append(push_ref['name'])
460 541
461 542 cmd = [settings.GIT_EXECUTABLE, 'log',
462 543 '{old_rev}..{new_rev}'.format(**push_ref),
463 544 '--reverse', '--pretty=format:%H']
464 545 stdout, stderr = subprocessio.run_command(
465 546 cmd, env=os.environ.copy())
466 547 git_revs.extend(stdout.splitlines())
467 548 elif type_ == 'tags':
468 549 if push_ref['name'] not in tags:
469 550 tags.append(push_ref['name'])
470 551 git_revs.append('tag=>%s' % push_ref['name'])
471 552
472 553 extras['commit_ids'] = git_revs
473 554 extras['new_refs'] = {
474 555 'branches': branches,
475 556 'bookmarks': [],
476 557 'tags': tags,
477 558 }
478 559
479 560 if 'repo_size' in extras['hooks']:
480 561 try:
481 562 _call_hook('repo_size', extras, GitMessageWriter())
482 563 except:
483 564 pass
484 565
485 566 return _call_hook('post_push', extras, GitMessageWriter())
486 567
487 568
488 569 def _get_extras_from_txn_id(path, txn_id):
489 570 extras = {}
490 571 try:
491 572 cmd = ['svnlook', 'pget',
492 573 '-t', txn_id,
493 574 '--revprop', path, 'rc-scm-extras']
494 575 stdout, stderr = subprocessio.run_command(
495 576 cmd, env=os.environ.copy())
496 577 extras = json.loads(base64.urlsafe_b64decode(stdout))
497 578 except Exception:
498 579 log.exception('Failed to extract extras info from txn_id')
499 580
500 581 return extras
501 582
502 583
503 584 def svn_pre_commit(repo_path, commit_data, env):
504 585 path, txn_id = commit_data
505 586 branches = []
506 587 tags = []
507 588
508 589 if env.get('RC_SCM_DATA'):
509 590 extras = json.loads(env['RC_SCM_DATA'])
510 591 else:
511 592 # fallback method to read from TXN-ID stored data
512 593 extras = _get_extras_from_txn_id(path, txn_id)
513 594 if not extras:
514 595 return 0
515 596
516 597 extras['commit_ids'] = []
517 598 extras['txn_id'] = txn_id
518 599 extras['new_refs'] = {
519 600 'branches': branches,
520 601 'bookmarks': [],
521 602 'tags': tags,
522 603 }
523 604
524 605 return _call_hook('pre_push', extras, SvnMessageWriter())
525 606
526 607
527 608 def _get_extras_from_commit_id(commit_id, path):
528 609 extras = {}
529 610 try:
530 611 cmd = ['svnlook', 'pget',
531 612 '-r', commit_id,
532 613 '--revprop', path, 'rc-scm-extras']
533 614 stdout, stderr = subprocessio.run_command(
534 615 cmd, env=os.environ.copy())
535 616 extras = json.loads(base64.urlsafe_b64decode(stdout))
536 617 except Exception:
537 618 log.exception('Failed to extract extras info from commit_id')
538 619
539 620 return extras
540 621
541 622
542 623 def svn_post_commit(repo_path, commit_data, env):
543 624 """
544 625 commit_data is path, rev, txn_id
545 626 """
546 627 path, commit_id, txn_id = commit_data
547 628 branches = []
548 629 tags = []
549 630
550 631 if env.get('RC_SCM_DATA'):
551 632 extras = json.loads(env['RC_SCM_DATA'])
552 633 else:
553 634 # fallback method to read from TXN-ID stored data
554 635 extras = _get_extras_from_commit_id(commit_id, path)
555 636 if not extras:
556 637 return 0
557 638
558 639 extras['commit_ids'] = [commit_id]
559 640 extras['txn_id'] = txn_id
560 641 extras['new_refs'] = {
561 642 'branches': branches,
562 643 'bookmarks': [],
563 644 'tags': tags,
564 645 }
565 646
566 647 if 'repo_size' in extras['hooks']:
567 648 try:
568 649 _call_hook('repo_size', extras, SvnMessageWriter())
569 650 except Exception:
570 651 pass
571 652
572 653 return _call_hook('post_push', extras, SvnMessageWriter())
@@ -1,559 +1,563 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2018 RhodeCode GmbH
3 3 #
4 4 # This program is free software; you can redistribute it and/or modify
5 5 # it under the terms of the GNU General Public License as published by
6 6 # the Free Software Foundation; either version 3 of the License, or
7 7 # (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software Foundation,
16 16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17 17
18 18 import os
19 19 import sys
20 20 import base64
21 21 import locale
22 22 import logging
23 23 import uuid
24 24 import wsgiref.util
25 25 import traceback
26 26 from itertools import chain
27 27
28 28 import simplejson as json
29 29 import msgpack
30 30 from pyramid.config import Configurator
31 31 from pyramid.settings import asbool, aslist
32 32 from pyramid.wsgi import wsgiapp
33 33 from pyramid.compat import configparser
34 34
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38 # due to Mercurial/glibc2.27 problems we need to detect if locale settings are
39 39 # causing problems and "fix" it in case they do and fallback to LC_ALL = C
40 40
41 41 try:
42 42 locale.setlocale(locale.LC_ALL, '')
43 43 except locale.Error as e:
44 44 log.error(
45 45 'LOCALE ERROR: failed to set LC_ALL, fallback to LC_ALL=C, org error: %s', e)
46 46 os.environ['LC_ALL'] = 'C'
47 47
48 48
49 49 from vcsserver import remote_wsgi, scm_app, settings, hgpatches
50 50 from vcsserver.git_lfs.app import GIT_LFS_CONTENT_TYPE, GIT_LFS_PROTO_PAT
51 51 from vcsserver.echo_stub import remote_wsgi as remote_wsgi_stub
52 52 from vcsserver.echo_stub.echo_app import EchoApp
53 from vcsserver.exceptions import HTTPRepoLocked
53 from vcsserver.exceptions import HTTPRepoLocked, HTTPRepoBranchProtected
54 54 from vcsserver.lib.exc_tracking import store_exception
55 55 from vcsserver.server import VcsServer
56 56
57 57 try:
58 58 from vcsserver.git import GitFactory, GitRemote
59 59 except ImportError:
60 60 GitFactory = None
61 61 GitRemote = None
62 62
63 63 try:
64 64 from vcsserver.hg import MercurialFactory, HgRemote
65 65 except ImportError:
66 66 MercurialFactory = None
67 67 HgRemote = None
68 68
69 69 try:
70 70 from vcsserver.svn import SubversionFactory, SvnRemote
71 71 except ImportError:
72 72 SubversionFactory = None
73 73 SvnRemote = None
74 74
75 75
76 76
77 77
78 78 def _is_request_chunked(environ):
79 79 stream = environ.get('HTTP_TRANSFER_ENCODING', '') == 'chunked'
80 80 return stream
81 81
82 82
83 83 def _int_setting(settings, name, default):
84 84 settings[name] = int(settings.get(name, default))
85 85
86 86
87 87 def _bool_setting(settings, name, default):
88 88 input_val = settings.get(name, default)
89 89 if isinstance(input_val, unicode):
90 90 input_val = input_val.encode('utf8')
91 91 settings[name] = asbool(input_val)
92 92
93 93
94 94 def _list_setting(settings, name, default):
95 95 raw_value = settings.get(name, default)
96 96
97 97 # Otherwise we assume it uses pyramids space/newline separation.
98 98 settings[name] = aslist(raw_value)
99 99
100 100
101 101 def _string_setting(settings, name, default, lower=True):
102 102 value = settings.get(name, default)
103 103 if lower:
104 104 value = value.lower()
105 105 settings[name] = value
106 106
107 107
108 108 class VCS(object):
109 109 def __init__(self, locale=None, cache_config=None):
110 110 self.locale = locale
111 111 self.cache_config = cache_config
112 112 self._configure_locale()
113 113
114 114 if GitFactory and GitRemote:
115 115 git_factory = GitFactory()
116 116 self._git_remote = GitRemote(git_factory)
117 117 else:
118 118 log.info("Git client import failed")
119 119
120 120 if MercurialFactory and HgRemote:
121 121 hg_factory = MercurialFactory()
122 122 self._hg_remote = HgRemote(hg_factory)
123 123 else:
124 124 log.info("Mercurial client import failed")
125 125
126 126 if SubversionFactory and SvnRemote:
127 127 svn_factory = SubversionFactory()
128 128
129 129 # hg factory is used for svn url validation
130 130 hg_factory = MercurialFactory()
131 131 self._svn_remote = SvnRemote(svn_factory, hg_factory=hg_factory)
132 132 else:
133 133 log.info("Subversion client import failed")
134 134
135 135 self._vcsserver = VcsServer()
136 136
137 137 def _configure_locale(self):
138 138 if self.locale:
139 139 log.info('Settings locale: `LC_ALL` to %s' % self.locale)
140 140 else:
141 141 log.info(
142 142 'Configuring locale subsystem based on environment variables')
143 143 try:
144 144 # If self.locale is the empty string, then the locale
145 145 # module will use the environment variables. See the
146 146 # documentation of the package `locale`.
147 147 locale.setlocale(locale.LC_ALL, self.locale)
148 148
149 149 language_code, encoding = locale.getlocale()
150 150 log.info(
151 151 'Locale set to language code "%s" with encoding "%s".',
152 152 language_code, encoding)
153 153 except locale.Error:
154 154 log.exception(
155 155 'Cannot set locale, not configuring the locale system')
156 156
157 157
158 158 class WsgiProxy(object):
159 159 def __init__(self, wsgi):
160 160 self.wsgi = wsgi
161 161
162 162 def __call__(self, environ, start_response):
163 163 input_data = environ['wsgi.input'].read()
164 164 input_data = msgpack.unpackb(input_data)
165 165
166 166 error = None
167 167 try:
168 168 data, status, headers = self.wsgi.handle(
169 169 input_data['environment'], input_data['input_data'],
170 170 *input_data['args'], **input_data['kwargs'])
171 171 except Exception as e:
172 172 data, status, headers = [], None, None
173 173 error = {
174 174 'message': str(e),
175 175 '_vcs_kind': getattr(e, '_vcs_kind', None)
176 176 }
177 177
178 178 start_response(200, {})
179 179 return self._iterator(error, status, headers, data)
180 180
181 181 def _iterator(self, error, status, headers, data):
182 182 initial_data = [
183 183 error,
184 184 status,
185 185 headers,
186 186 ]
187 187
188 188 for d in chain(initial_data, data):
189 189 yield msgpack.packb(d)
190 190
191 191
192 192 class HTTPApplication(object):
193 193 ALLOWED_EXCEPTIONS = ('KeyError', 'URLError')
194 194
195 195 remote_wsgi = remote_wsgi
196 196 _use_echo_app = False
197 197
198 198 def __init__(self, settings=None, global_config=None):
199 199 self._sanitize_settings_and_apply_defaults(settings)
200 200
201 201 self.config = Configurator(settings=settings)
202 202 self.global_config = global_config
203 203 self.config.include('vcsserver.lib.rc_cache')
204 204
205 205 locale = settings.get('locale', '') or 'en_US.UTF-8'
206 206 vcs = VCS(locale=locale, cache_config=settings)
207 207 self._remotes = {
208 208 'hg': vcs._hg_remote,
209 209 'git': vcs._git_remote,
210 210 'svn': vcs._svn_remote,
211 211 'server': vcs._vcsserver,
212 212 }
213 213 if settings.get('dev.use_echo_app', 'false').lower() == 'true':
214 214 self._use_echo_app = True
215 215 log.warning("Using EchoApp for VCS operations.")
216 216 self.remote_wsgi = remote_wsgi_stub
217 217 self._configure_settings(settings)
218 218 self._configure()
219 219
220 220 def _configure_settings(self, app_settings):
221 221 """
222 222 Configure the settings module.
223 223 """
224 224 git_path = app_settings.get('git_path', None)
225 225 if git_path:
226 226 settings.GIT_EXECUTABLE = git_path
227 227 binary_dir = app_settings.get('core.binary_dir', None)
228 228 if binary_dir:
229 229 settings.BINARY_DIR = binary_dir
230 230
231 231 def _sanitize_settings_and_apply_defaults(self, settings):
232 232 # repo_object cache
233 233 _string_setting(
234 234 settings,
235 235 'rc_cache.repo_object.backend',
236 236 'dogpile.cache.rc.memory_lru')
237 237 _int_setting(
238 238 settings,
239 239 'rc_cache.repo_object.expiration_time',
240 240 300)
241 241 _int_setting(
242 242 settings,
243 243 'rc_cache.repo_object.max_size',
244 244 1024)
245 245
246 246 def _configure(self):
247 247 self.config.add_renderer(
248 248 name='msgpack',
249 249 factory=self._msgpack_renderer_factory)
250 250
251 251 self.config.add_route('service', '/_service')
252 252 self.config.add_route('status', '/status')
253 253 self.config.add_route('hg_proxy', '/proxy/hg')
254 254 self.config.add_route('git_proxy', '/proxy/git')
255 255 self.config.add_route('vcs', '/{backend}')
256 256 self.config.add_route('stream_git', '/stream/git/*repo_name')
257 257 self.config.add_route('stream_hg', '/stream/hg/*repo_name')
258 258
259 259 self.config.add_view(
260 260 self.status_view, route_name='status', renderer='json')
261 261 self.config.add_view(
262 262 self.service_view, route_name='service', renderer='msgpack')
263 263
264 264 self.config.add_view(self.hg_proxy(), route_name='hg_proxy')
265 265 self.config.add_view(self.git_proxy(), route_name='git_proxy')
266 266 self.config.add_view(
267 267 self.vcs_view, route_name='vcs', renderer='msgpack',
268 268 custom_predicates=[self.is_vcs_view])
269 269
270 270 self.config.add_view(self.hg_stream(), route_name='stream_hg')
271 271 self.config.add_view(self.git_stream(), route_name='stream_git')
272 272
273 273 def notfound(request):
274 274 return {'status': '404 NOT FOUND'}
275 275 self.config.add_notfound_view(notfound, renderer='json')
276 276
277 277 self.config.add_view(self.handle_vcs_exception, context=Exception)
278 278
279 279 self.config.add_tween(
280 280 'vcsserver.tweens.RequestWrapperTween',
281 281 )
282 282
283 283 def wsgi_app(self):
284 284 return self.config.make_wsgi_app()
285 285
286 286 def vcs_view(self, request):
287 287 remote = self._remotes[request.matchdict['backend']]
288 288 payload = msgpack.unpackb(request.body, use_list=True)
289 289 method = payload.get('method')
290 290 params = payload.get('params')
291 291 wire = params.get('wire')
292 292 args = params.get('args')
293 293 kwargs = params.get('kwargs')
294 294 context_uid = None
295 295
296 296 if wire:
297 297 try:
298 298 wire['context'] = context_uid = uuid.UUID(wire['context'])
299 299 except KeyError:
300 300 pass
301 301 args.insert(0, wire)
302 302
303 303 log.debug('method called:%s with kwargs:%s context_uid: %s',
304 304 method, kwargs, context_uid)
305 305 try:
306 306 resp = getattr(remote, method)(*args, **kwargs)
307 307 except Exception as e:
308 308 exc_info = list(sys.exc_info())
309 309 exc_type, exc_value, exc_traceback = exc_info
310 310
311 311 org_exc = getattr(e, '_org_exc', None)
312 312 org_exc_name = None
313 313 if org_exc:
314 314 org_exc_name = org_exc.__class__.__name__
315 315 # replace our "faked" exception with our org
316 316 exc_info[0] = org_exc.__class__
317 317 exc_info[1] = org_exc
318 318
319 319 store_exception(id(exc_info), exc_info)
320 320
321 321 tb_info = ''.join(
322 322 traceback.format_exception(exc_type, exc_value, exc_traceback))
323 323
324 324 type_ = e.__class__.__name__
325 325 if type_ not in self.ALLOWED_EXCEPTIONS:
326 326 type_ = None
327 327
328 328 resp = {
329 329 'id': payload.get('id'),
330 330 'error': {
331 331 'message': e.message,
332 332 'traceback': tb_info,
333 333 'org_exc': org_exc_name,
334 334 'type': type_
335 335 }
336 336 }
337 337 try:
338 338 resp['error']['_vcs_kind'] = getattr(e, '_vcs_kind', None)
339 339 except AttributeError:
340 340 pass
341 341 else:
342 342 resp = {
343 343 'id': payload.get('id'),
344 344 'result': resp
345 345 }
346 346
347 347 return resp
348 348
349 349 def status_view(self, request):
350 350 import vcsserver
351 351 return {'status': 'OK', 'vcsserver_version': vcsserver.__version__,
352 352 'pid': os.getpid()}
353 353
354 354 def service_view(self, request):
355 355 import vcsserver
356 356
357 357 payload = msgpack.unpackb(request.body, use_list=True)
358 358
359 359 try:
360 360 path = self.global_config['__file__']
361 361 config = configparser.ConfigParser()
362 362 config.read(path)
363 363 parsed_ini = config
364 364 if parsed_ini.has_section('server:main'):
365 365 parsed_ini = dict(parsed_ini.items('server:main'))
366 366 except Exception:
367 367 log.exception('Failed to read .ini file for display')
368 368 parsed_ini = {}
369 369
370 370 resp = {
371 371 'id': payload.get('id'),
372 372 'result': dict(
373 373 version=vcsserver.__version__,
374 374 config=parsed_ini,
375 375 payload=payload,
376 376 )
377 377 }
378 378 return resp
379 379
380 380 def _msgpack_renderer_factory(self, info):
381 381 def _render(value, system):
382 382 value = msgpack.packb(value)
383 383 request = system.get('request')
384 384 if request is not None:
385 385 response = request.response
386 386 ct = response.content_type
387 387 if ct == response.default_content_type:
388 388 response.content_type = 'application/x-msgpack'
389 389 return value
390 390 return _render
391 391
392 392 def set_env_from_config(self, environ, config):
393 393 dict_conf = {}
394 394 try:
395 395 for elem in config:
396 396 if elem[0] == 'rhodecode':
397 397 dict_conf = json.loads(elem[2])
398 398 break
399 399 except Exception:
400 400 log.exception('Failed to fetch SCM CONFIG')
401 401 return
402 402
403 403 username = dict_conf.get('username')
404 404 if username:
405 405 environ['REMOTE_USER'] = username
406 406 # mercurial specific, some extension api rely on this
407 407 environ['HGUSER'] = username
408 408
409 409 ip = dict_conf.get('ip')
410 410 if ip:
411 411 environ['REMOTE_HOST'] = ip
412 412
413 413 if _is_request_chunked(environ):
414 414 # set the compatibility flag for webob
415 415 environ['wsgi.input_terminated'] = True
416 416
417 417 def hg_proxy(self):
418 418 @wsgiapp
419 419 def _hg_proxy(environ, start_response):
420 420 app = WsgiProxy(self.remote_wsgi.HgRemoteWsgi())
421 421 return app(environ, start_response)
422 422 return _hg_proxy
423 423
424 424 def git_proxy(self):
425 425 @wsgiapp
426 426 def _git_proxy(environ, start_response):
427 427 app = WsgiProxy(self.remote_wsgi.GitRemoteWsgi())
428 428 return app(environ, start_response)
429 429 return _git_proxy
430 430
431 431 def hg_stream(self):
432 432 if self._use_echo_app:
433 433 @wsgiapp
434 434 def _hg_stream(environ, start_response):
435 435 app = EchoApp('fake_path', 'fake_name', None)
436 436 return app(environ, start_response)
437 437 return _hg_stream
438 438 else:
439 439 @wsgiapp
440 440 def _hg_stream(environ, start_response):
441 441 log.debug('http-app: handling hg stream')
442 442 repo_path = environ['HTTP_X_RC_REPO_PATH']
443 443 repo_name = environ['HTTP_X_RC_REPO_NAME']
444 444 packed_config = base64.b64decode(
445 445 environ['HTTP_X_RC_REPO_CONFIG'])
446 446 config = msgpack.unpackb(packed_config)
447 447 app = scm_app.create_hg_wsgi_app(
448 448 repo_path, repo_name, config)
449 449
450 450 # Consistent path information for hgweb
451 451 environ['PATH_INFO'] = environ['HTTP_X_RC_PATH_INFO']
452 452 environ['REPO_NAME'] = repo_name
453 453 self.set_env_from_config(environ, config)
454 454
455 455 log.debug('http-app: starting app handler '
456 456 'with %s and process request', app)
457 457 return app(environ, ResponseFilter(start_response))
458 458 return _hg_stream
459 459
460 460 def git_stream(self):
461 461 if self._use_echo_app:
462 462 @wsgiapp
463 463 def _git_stream(environ, start_response):
464 464 app = EchoApp('fake_path', 'fake_name', None)
465 465 return app(environ, start_response)
466 466 return _git_stream
467 467 else:
468 468 @wsgiapp
469 469 def _git_stream(environ, start_response):
470 470 log.debug('http-app: handling git stream')
471 471 repo_path = environ['HTTP_X_RC_REPO_PATH']
472 472 repo_name = environ['HTTP_X_RC_REPO_NAME']
473 473 packed_config = base64.b64decode(
474 474 environ['HTTP_X_RC_REPO_CONFIG'])
475 475 config = msgpack.unpackb(packed_config)
476 476
477 477 environ['PATH_INFO'] = environ['HTTP_X_RC_PATH_INFO']
478 478 self.set_env_from_config(environ, config)
479 479
480 480 content_type = environ.get('CONTENT_TYPE', '')
481 481
482 482 path = environ['PATH_INFO']
483 483 is_lfs_request = GIT_LFS_CONTENT_TYPE in content_type
484 484 log.debug(
485 485 'LFS: Detecting if request `%s` is LFS server path based '
486 486 'on content type:`%s`, is_lfs:%s',
487 487 path, content_type, is_lfs_request)
488 488
489 489 if not is_lfs_request:
490 490 # fallback detection by path
491 491 if GIT_LFS_PROTO_PAT.match(path):
492 492 is_lfs_request = True
493 493 log.debug(
494 494 'LFS: fallback detection by path of: `%s`, is_lfs:%s',
495 495 path, is_lfs_request)
496 496
497 497 if is_lfs_request:
498 498 app = scm_app.create_git_lfs_wsgi_app(
499 499 repo_path, repo_name, config)
500 500 else:
501 501 app = scm_app.create_git_wsgi_app(
502 502 repo_path, repo_name, config)
503 503
504 504 log.debug('http-app: starting app handler '
505 505 'with %s and process request', app)
506 506
507 507 return app(environ, start_response)
508 508
509 509 return _git_stream
510 510
511 511 def is_vcs_view(self, context, request):
512 512 """
513 513 View predicate that returns true if given backend is supported by
514 514 defined remotes.
515 515 """
516 516 backend = request.matchdict.get('backend')
517 517 return backend in self._remotes
518 518
519 519 def handle_vcs_exception(self, exception, request):
520 520 _vcs_kind = getattr(exception, '_vcs_kind', '')
521 521 if _vcs_kind == 'repo_locked':
522 522 # Get custom repo-locked status code if present.
523 523 status_code = request.headers.get('X-RC-Locked-Status-Code')
524 524 return HTTPRepoLocked(
525 525 title=exception.message, status_code=status_code)
526 526
527 elif _vcs_kind == 'repo_branch_protected':
528 # Get custom repo-branch-protected status code if present.
529 return HTTPRepoBranchProtected(title=exception.message)
530
527 531 exc_info = request.exc_info
528 532 store_exception(id(exc_info), exc_info)
529 533
530 534 traceback_info = 'unavailable'
531 535 if request.exc_info:
532 536 exc_type, exc_value, exc_tb = request.exc_info
533 537 traceback_info = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
534 538
535 539 log.error(
536 540 'error occurred handling this request for path: %s, \n tb: %s',
537 541 request.path, traceback_info)
538 542 raise exception
539 543
540 544
541 545 class ResponseFilter(object):
542 546
543 547 def __init__(self, start_response):
544 548 self._start_response = start_response
545 549
546 550 def __call__(self, status, response_headers, exc_info=None):
547 551 headers = tuple(
548 552 (h, v) for h, v in response_headers
549 553 if not wsgiref.util.is_hop_by_hop(h))
550 554 return self._start_response(status, headers, exc_info)
551 555
552 556
553 557 def main(global_config, **settings):
554 558 if MercurialFactory:
555 559 hgpatches.patch_largefiles_capabilities()
556 560 hgpatches.patch_subrepo_type_mapping()
557 561
558 562 app = HTTPApplication(settings=settings, global_config=global_config)
559 563 return app.wsgi_app()
General Comments 0
You need to be logged in to leave comments. Login now