##// END OF EJS Templates
core: deprecated http based callback deamon....
super-admin -
r1315:910dabad default
parent child Browse files
Show More
@@ -1,131 +1,131 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2023 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 27 from pyramid.httpexceptions import HTTPLocked, HTTPForbidden
28 28
29 29
30 30 def _make_exception(kind, org_exc, *args):
31 31 """
32 32 Prepares a base `Exception` instance to be sent over the wire.
33 33
34 34 To give our caller a hint what this is about, it will attach an attribute
35 35 `_vcs_kind` to the exception.
36 36 """
37 37 exc = Exception(*args)
38 38 exc._vcs_kind = kind
39 39 exc._org_exc = org_exc
40 40 exc._org_exc_tb = getattr(org_exc, '_org_exc_tb', '')
41 41 return exc
42 42
43 43
44 44 def AbortException(org_exc=None):
45 45 def _make_exception_wrapper(*args):
46 46 return _make_exception('abort', org_exc, *args)
47 47 return _make_exception_wrapper
48 48
49 49
50 50 def ArchiveException(org_exc=None):
51 51 def _make_exception_wrapper(*args):
52 52 return _make_exception('archive', org_exc, *args)
53 53 return _make_exception_wrapper
54 54
55 55
56 56 def ClientNotSupportedException(org_exc=None):
57 57 def _make_exception_wrapper(*args):
58 58 return _make_exception('client_not_supported', org_exc, *args)
59 59 return _make_exception_wrapper
60 60
61 61
62 62 def LookupException(org_exc=None):
63 63 def _make_exception_wrapper(*args):
64 64 return _make_exception('lookup', org_exc, *args)
65 65 return _make_exception_wrapper
66 66
67 67
68 68 def VcsException(org_exc=None):
69 69 def _make_exception_wrapper(*args):
70 70 return _make_exception('error', org_exc, *args)
71 71 return _make_exception_wrapper
72 72
73 73
74 def RepositoryLockedException(org_exc=None):
74 def LockedRepoException(org_exc=None):
75 75 def _make_exception_wrapper(*args):
76 76 return _make_exception('repo_locked', org_exc, *args)
77 77 return _make_exception_wrapper
78 78
79 79
80 80 def RepositoryBranchProtectedException(org_exc=None):
81 81 def _make_exception_wrapper(*args):
82 82 return _make_exception('repo_branch_protected', org_exc, *args)
83 83 return _make_exception_wrapper
84 84
85 85
86 86 def RequirementException(org_exc=None):
87 87 def _make_exception_wrapper(*args):
88 88 return _make_exception('requirement', org_exc, *args)
89 89 return _make_exception_wrapper
90 90
91 91
92 92 def UnhandledException(org_exc=None):
93 93 def _make_exception_wrapper(*args):
94 94 return _make_exception('unhandled', org_exc, *args)
95 95 return _make_exception_wrapper
96 96
97 97
98 98 def URLError(org_exc=None):
99 99 def _make_exception_wrapper(*args):
100 100 return _make_exception('url_error', org_exc, *args)
101 101 return _make_exception_wrapper
102 102
103 103
104 104 def SubrepoMergeException(org_exc=None):
105 105 def _make_exception_wrapper(*args):
106 106 return _make_exception('subrepo_merge_error', org_exc, *args)
107 107 return _make_exception_wrapper
108 108
109 109
110 110 class HTTPRepoLocked(HTTPLocked):
111 111 """
112 112 Subclass of HTTPLocked response that allows to set the title and status
113 113 code via constructor arguments.
114 114 """
115 115 def __init__(self, title, status_code=None, **kwargs):
116 116 self.code = status_code or HTTPLocked.code
117 117 self.title = title
118 118 super().__init__(**kwargs)
119 119
120 120
121 121 class HTTPRepoBranchProtected(HTTPForbidden):
122 122 def __init__(self, *args, **kwargs):
123 123 super(HTTPForbidden, self).__init__(*args, **kwargs)
124 124
125 125
126 126 class RefNotFoundException(KeyError):
127 127 pass
128 128
129 129
130 130 class NoContentException(ValueError):
131 131 pass
@@ -1,828 +1,780 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2023 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 io
19 19 import os
20 20 import sys
21 21 import logging
22 22 import collections
23 23 import base64
24 24 import msgpack
25 25 import dataclasses
26 26 import pygit2
27 27
28 28 import http.client
29 29 from celery import Celery
30 30
31 31 import mercurial.scmutil
32 32 import mercurial.node
33 33
34 34 from vcsserver import exceptions, subprocessio, settings
35 35 from vcsserver.lib.ext_json import json
36 36 from vcsserver.lib.str_utils import ascii_str, safe_str
37 37 from vcsserver.lib.svn_txn_utils import get_txn_id_from_store
38 38 from vcsserver.remote.git_remote import Repository
39 39
40 40 celery_app = Celery('__vcsserver__')
41 41 log = logging.getLogger(__name__)
42 42
43 43
44 class HooksHttpClient:
45 proto = 'msgpack.v1'
46 connection = None
47
48 def __init__(self, hooks_uri):
49 self.hooks_uri = hooks_uri
50
51 def __repr__(self):
52 return f'{self.__class__}(hook_uri={self.hooks_uri}, proto={self.proto})'
53
54 def __call__(self, method, extras):
55 connection = http.client.HTTPConnection(self.hooks_uri)
56 # binary msgpack body
57 headers, body = self._serialize(method, extras)
58 log.debug('Doing a new hooks call using HTTPConnection to %s', self.hooks_uri)
59
60 try:
61 try:
62 connection.request('POST', '/', body, headers)
63 except Exception as error:
64 log.error('Hooks calling Connection failed on %s, org error: %s', connection.__dict__, error)
65 raise
66
67 response = connection.getresponse()
68 try:
69 return msgpack.load(response)
70 except Exception:
71 response_data = response.read()
72 log.exception('Failed to decode hook response json data. '
73 'response_code:%s, raw_data:%s',
74 response.status, response_data)
75 raise
76 finally:
77 connection.close()
78
79 @classmethod
80 def _serialize(cls, hook_name, extras):
81 data = {
82 'method': hook_name,
83 'extras': extras
84 }
85 headers = {
86 "rc-hooks-protocol": cls.proto,
87 "Connection": "keep-alive"
88 }
89 return headers, msgpack.packb(data)
90
91
92 44 class HooksCeleryClient:
93 45 TASK_TIMEOUT = 60 # time in seconds
94 46
95 47 def __init__(self, queue, backend):
96 48 celery_app.config_from_object({
97 49 'broker_url': queue, 'result_backend': backend,
98 50 'broker_connection_retry_on_startup': True,
99 51 'task_serializer': 'json',
100 52 'accept_content': ['json', 'msgpack'],
101 53 'result_serializer': 'json',
102 54 'result_accept_content': ['json', 'msgpack']
103 55 })
104 56 self.celery_app = celery_app
105 57
106 58 def __call__(self, method, extras):
107 59 inquired_task = self.celery_app.signature(
108 60 f'rhodecode.lib.celerylib.tasks.{method}'
109 61 )
110 62 result = inquired_task.delay(extras).get(timeout=self.TASK_TIMEOUT)
111 63
112 64 return result
113 65
114 66
115 67 class HooksShadowRepoClient:
116 68
117 69 def __call__(self, hook_name, extras):
118 70 return {'output': '', 'status': 0}
119 71
120 72
121 73 class RemoteMessageWriter:
122 74 """Writer base class."""
123 75 def write(self, message):
124 76 raise NotImplementedError()
125 77
126 78
127 79 class HgMessageWriter(RemoteMessageWriter):
128 80 """Writer that knows how to send messages to mercurial clients."""
129 81
130 82 def __init__(self, ui):
131 83 self.ui = ui
132 84
133 85 def write(self, message: str):
134 86 # TODO: Check why the quiet flag is set by default.
135 87 old = self.ui.quiet
136 88 self.ui.quiet = False
137 89 self.ui.status(message.encode('utf-8'))
138 90 self.ui.quiet = old
139 91
140 92
141 93 class GitMessageWriter(RemoteMessageWriter):
142 94 """Writer that knows how to send messages to git clients."""
143 95
144 96 def __init__(self, stdout=None):
145 97 self.stdout = stdout or sys.stdout
146 98
147 99 def write(self, message: str):
148 100 self.stdout.write(message)
149 101
150 102
151 103 class SvnMessageWriter(RemoteMessageWriter):
152 104 """Writer that knows how to send messages to svn clients."""
153 105
154 106 def __init__(self, stderr=None):
155 107 # SVN needs data sent to stderr for back-to-client messaging
156 108 self.stderr = stderr or sys.stderr
157 109
158 110 def write(self, message):
159 111 self.stderr.write(message)
160 112
161 113
162 114 def _maybe_handle_exception(result):
115
116
163 117 exception_class = result.get('exception')
164 118 exception_traceback = result.get('exception_traceback')
165 if not (exception_class and exception_traceback):
119 if not exception_class:
166 120 return
121
167 122 log.debug('Handling hook-call exception: %s', exception_class)
168 123
169 124 if exception_traceback:
170 125 log.error('Got traceback from remote call:%s', exception_traceback)
171 126
172 if exception_class == 'HTTPLockedRC':
173 raise exceptions.RepositoryLockedException()(*result['exception_args'])
127 if exception_class == 'HTTPLockedRepo':
128 raise exceptions.LockedRepoException()(*result['exception_args'])
174 129 elif exception_class == 'ClientNotSupportedError':
175 130 raise exceptions.ClientNotSupportedException()(*result['exception_args'])
176 131 elif exception_class == 'HTTPBranchProtected':
177 132 raise exceptions.RepositoryBranchProtectedException()(*result['exception_args'])
178 133 elif exception_class == 'RepositoryError':
179 134 raise exceptions.VcsException()(*result['exception_args'])
180 135 elif exception_class:
181 136 raise Exception(
182 137 f"""Got remote exception "{exception_class}" with args "{result['exception_args']}" """
183 138 )
184 139
185 140
186 141 def _get_hooks_client(extras):
187 hooks_uri = extras.get('hooks_uri')
188 142 task_queue = extras.get('task_queue')
189 143 task_backend = extras.get('task_backend')
190 144 is_shadow_repo = extras.get('is_shadow_repo')
191 145
192 if hooks_uri:
193 return HooksHttpClient(hooks_uri)
194 elif task_queue and task_backend:
146 if task_queue and task_backend:
195 147 return HooksCeleryClient(task_queue, task_backend)
196 148 elif is_shadow_repo:
197 149 return HooksShadowRepoClient()
198 150 else:
199 151 raise Exception("Hooks client not found!")
200 152
201 153
202 154 def _call_hook(hook_name, extras, writer):
203 155 hooks_client = _get_hooks_client(extras)
204 156 log.debug('Hooks, using client:%s', hooks_client)
205 157 result = hooks_client(hook_name, extras)
206 158 log.debug('Hooks got result: %s', result)
207 159 _maybe_handle_exception(result)
208 160 writer.write(result['output'])
209 161
210 162 return result['status']
211 163
212 164
213 165 def _extras_from_ui(ui):
214 166 hook_data = ui.config(b'rhodecode', b'RC_SCM_DATA')
215 167 if not hook_data:
216 168 # maybe it's inside environ ?
217 169 env_hook_data = os.environ.get('RC_SCM_DATA')
218 170 if env_hook_data:
219 171 hook_data = env_hook_data
220 172
221 173 extras = {}
222 174 if hook_data:
223 175 extras = json.loads(hook_data)
224 176 return extras
225 177
226 178
227 179 def _rev_range_hash(repo, node, check_heads=False):
228 180 from vcsserver.hgcompat import get_ctx
229 181
230 182 commits = []
231 183 revs = []
232 184 start = get_ctx(repo, node).rev()
233 185 end = len(repo)
234 186 for rev in range(start, end):
235 187 revs.append(rev)
236 188 ctx = get_ctx(repo, rev)
237 189 commit_id = ascii_str(mercurial.node.hex(ctx.node()))
238 190 branch = safe_str(ctx.branch())
239 191 commits.append((commit_id, branch))
240 192
241 193 parent_heads = []
242 194 if check_heads:
243 195 parent_heads = _check_heads(repo, start, end, revs)
244 196 return commits, parent_heads
245 197
246 198
247 199 def _check_heads(repo, start, end, commits):
248 200 from vcsserver.hgcompat import get_ctx
249 201 changelog = repo.changelog
250 202 parents = set()
251 203
252 204 for new_rev in commits:
253 205 for p in changelog.parentrevs(new_rev):
254 206 if p == mercurial.node.nullrev:
255 207 continue
256 208 if p < start:
257 209 parents.add(p)
258 210
259 211 for p in parents:
260 212 branch = get_ctx(repo, p).branch()
261 213 # The heads descending from that parent, on the same branch
262 214 parent_heads = {p}
263 215 reachable = {p}
264 216 for x in range(p + 1, end):
265 217 if get_ctx(repo, x).branch() != branch:
266 218 continue
267 219 for pp in changelog.parentrevs(x):
268 220 if pp in reachable:
269 221 reachable.add(x)
270 222 parent_heads.discard(pp)
271 223 parent_heads.add(x)
272 224 # More than one head? Suggest merging
273 225 if len(parent_heads) > 1:
274 226 return list(parent_heads)
275 227
276 228 return []
277 229
278 230
279 231 def _get_git_env():
280 232 env = {}
281 233 for k, v in os.environ.items():
282 234 if k.startswith('GIT'):
283 235 env[k] = v
284 236
285 237 # serialized version
286 238 return [(k, v) for k, v in env.items()]
287 239
288 240
289 241 def _get_hg_env(old_rev, new_rev, txnid, repo_path):
290 242 env = {}
291 243 for k, v in os.environ.items():
292 244 if k.startswith('HG'):
293 245 env[k] = v
294 246
295 247 env['HG_NODE'] = old_rev
296 248 env['HG_NODE_LAST'] = new_rev
297 249 env['HG_TXNID'] = txnid
298 250 env['HG_PENDING'] = repo_path
299 251
300 252 return [(k, v) for k, v in env.items()]
301 253
302 254
303 255 def _get_ini_settings(ini_file):
304 256 from vcsserver.http_main import sanitize_settings_and_apply_defaults
305 257 from vcsserver.lib.config_utils import get_app_config_lightweight, configure_and_store_settings
306 258
307 259 global_config = {'__file__': ini_file}
308 260 ini_settings = get_app_config_lightweight(ini_file)
309 261 sanitize_settings_and_apply_defaults(global_config, ini_settings)
310 262 configure_and_store_settings(global_config, ini_settings)
311 263
312 264 return ini_settings
313 265
314 266
315 267 def _fix_hooks_executables(ini_path=''):
316 268 """
317 269 This is a trick to set proper settings.EXECUTABLE paths for certain execution patterns
318 270 especially for subversion where hooks strip entire env, and calling just 'svn' command will most likely fail
319 271 because svn is not on PATH
320 272 """
321 273 # set defaults, in case we can't read from ini_file
322 274 core_binary_dir = settings.BINARY_DIR or '/usr/local/bin/rhodecode_bin/vcs_bin'
323 275 if ini_path:
324 276 ini_settings = _get_ini_settings(ini_path)
325 277 core_binary_dir = ini_settings['core.binary_dir']
326 278
327 279 settings.BINARY_DIR = core_binary_dir
328 280
329 281
330 282 def repo_size(ui, repo, **kwargs):
331 283 extras = _extras_from_ui(ui)
332 284 return _call_hook('repo_size', extras, HgMessageWriter(ui))
333 285
334 286
335 287 def pre_pull(ui, repo, **kwargs):
336 288 extras = _extras_from_ui(ui)
337 289 return _call_hook('pre_pull', extras, HgMessageWriter(ui))
338 290
339 291
340 292 def pre_pull_ssh(ui, repo, **kwargs):
341 293 extras = _extras_from_ui(ui)
342 294 if extras and extras.get('SSH'):
343 295 return pre_pull(ui, repo, **kwargs)
344 296 return 0
345 297
346 298
347 299 def post_pull(ui, repo, **kwargs):
348 300 extras = _extras_from_ui(ui)
349 301 return _call_hook('post_pull', extras, HgMessageWriter(ui))
350 302
351 303
352 304 def post_pull_ssh(ui, repo, **kwargs):
353 305 extras = _extras_from_ui(ui)
354 306 if extras and extras.get('SSH'):
355 307 return post_pull(ui, repo, **kwargs)
356 308 return 0
357 309
358 310
359 311 def pre_push(ui, repo, node=None, **kwargs):
360 312 """
361 313 Mercurial pre_push hook
362 314 """
363 315 extras = _extras_from_ui(ui)
364 316 detect_force_push = extras.get('detect_force_push')
365 317
366 318 rev_data = []
367 319 hook_type: str = safe_str(kwargs.get('hooktype'))
368 320
369 321 if node and hook_type == 'pretxnchangegroup':
370 322 branches = collections.defaultdict(list)
371 323 commits, _heads = _rev_range_hash(repo, node, check_heads=detect_force_push)
372 324 for commit_id, branch in commits:
373 325 branches[branch].append(commit_id)
374 326
375 327 for branch, commits in branches.items():
376 328 old_rev = ascii_str(kwargs.get('node_last')) or commits[0]
377 329 rev_data.append({
378 330 'total_commits': len(commits),
379 331 'old_rev': old_rev,
380 332 'new_rev': commits[-1],
381 333 'ref': '',
382 334 'type': 'branch',
383 335 'name': branch,
384 336 })
385 337
386 338 for push_ref in rev_data:
387 339 push_ref['multiple_heads'] = _heads
388 340
389 341 repo_path = os.path.join(
390 342 extras.get('repo_store', ''), extras.get('repository', ''))
391 343 push_ref['hg_env'] = _get_hg_env(
392 344 old_rev=push_ref['old_rev'],
393 345 new_rev=push_ref['new_rev'], txnid=ascii_str(kwargs.get('txnid')),
394 346 repo_path=repo_path)
395 347
396 348 extras['hook_type'] = hook_type or 'pre_push'
397 349 extras['commit_ids'] = rev_data
398 350
399 351 return _call_hook('pre_push', extras, HgMessageWriter(ui))
400 352
401 353
402 354 def pre_push_ssh(ui, repo, node=None, **kwargs):
403 355 extras = _extras_from_ui(ui)
404 356 if extras.get('SSH'):
405 357 return pre_push(ui, repo, node, **kwargs)
406 358
407 359 return 0
408 360
409 361
410 362 def pre_push_ssh_auth(ui, repo, node=None, **kwargs):
411 363 """
412 364 Mercurial pre_push hook for SSH
413 365 """
414 366 extras = _extras_from_ui(ui)
415 367 if extras.get('SSH'):
416 368 permission = extras['SSH_PERMISSIONS']
417 369
418 370 if 'repository.write' == permission or 'repository.admin' == permission:
419 371 return 0
420 372
421 373 # non-zero ret code
422 374 return 1
423 375
424 376 return 0
425 377
426 378
427 379 def post_push(ui, repo, node, **kwargs):
428 380 """
429 381 Mercurial post_push hook
430 382 """
431 383 extras = _extras_from_ui(ui)
432 384
433 385 commit_ids = []
434 386 branches = []
435 387 bookmarks = []
436 388 tags = []
437 389 hook_type: str = safe_str(kwargs.get('hooktype'))
438 390
439 391 commits, _heads = _rev_range_hash(repo, node)
440 392 for commit_id, branch in commits:
441 393 commit_ids.append(commit_id)
442 394 if branch not in branches:
443 395 branches.append(branch)
444 396
445 397 if hasattr(ui, '_rc_pushkey_bookmarks'):
446 398 bookmarks = ui._rc_pushkey_bookmarks
447 399
448 400 extras['hook_type'] = hook_type or 'post_push'
449 401 extras['commit_ids'] = commit_ids
450 402
451 403 extras['new_refs'] = {
452 404 'branches': branches,
453 405 'bookmarks': bookmarks,
454 406 'tags': tags
455 407 }
456 408
457 409 return _call_hook('post_push', extras, HgMessageWriter(ui))
458 410
459 411
460 412 def post_push_ssh(ui, repo, node, **kwargs):
461 413 """
462 414 Mercurial post_push hook for SSH
463 415 """
464 416 if _extras_from_ui(ui).get('SSH'):
465 417 return post_push(ui, repo, node, **kwargs)
466 418 return 0
467 419
468 420
469 421 def key_push(ui, repo, **kwargs):
470 422 from vcsserver.hgcompat import get_ctx
471 423
472 424 if kwargs['new'] != b'0' and kwargs['namespace'] == b'bookmarks':
473 425 # store new bookmarks in our UI object propagated later to post_push
474 426 ui._rc_pushkey_bookmarks = get_ctx(repo, kwargs['key']).bookmarks()
475 427 return
476 428
477 429
478 430 # backward compat
479 431 log_pull_action = post_pull
480 432
481 433 # backward compat
482 434 log_push_action = post_push
483 435
484 436
485 437 def handle_git_pre_receive(unused_repo_path, unused_revs, unused_env):
486 438 """
487 439 Old hook name: keep here for backward compatibility.
488 440
489 441 This is only required when the installed git hooks are not upgraded.
490 442 """
491 443 pass
492 444
493 445
494 446 def handle_git_post_receive(unused_repo_path, unused_revs, unused_env):
495 447 """
496 448 Old hook name: keep here for backward compatibility.
497 449
498 450 This is only required when the installed git hooks are not upgraded.
499 451 """
500 452 pass
501 453
502 454
503 455 @dataclasses.dataclass
504 456 class HookResponse:
505 457 status: int
506 458 output: str
507 459
508 460
509 461 def git_pre_pull(extras) -> HookResponse:
510 462 """
511 463 Pre pull hook.
512 464
513 465 :param extras: dictionary containing the keys defined in simplevcs
514 466 :type extras: dict
515 467
516 468 :return: status code of the hook. 0 for success.
517 469 :rtype: int
518 470 """
519 471
520 472 if 'pull' not in extras['hooks']:
521 473 return HookResponse(0, '')
522 474
523 475 stdout = io.StringIO()
524 476 try:
525 477 status_code = _call_hook('pre_pull', extras, GitMessageWriter(stdout))
526 478
527 479 except Exception as error:
528 480 log.exception('Failed to call pre_pull hook')
529 481 status_code = 128
530 482 stdout.write(f'ERROR: {error}\n')
531 483
532 484 return HookResponse(status_code, stdout.getvalue())
533 485
534 486
535 487 def git_post_pull(extras) -> HookResponse:
536 488 """
537 489 Post pull hook.
538 490
539 491 :param extras: dictionary containing the keys defined in simplevcs
540 492 :type extras: dict
541 493
542 494 :return: status code of the hook. 0 for success.
543 495 :rtype: int
544 496 """
545 497 if 'pull' not in extras['hooks']:
546 498 return HookResponse(0, '')
547 499
548 500 stdout = io.StringIO()
549 501 try:
550 502 status = _call_hook('post_pull', extras, GitMessageWriter(stdout))
551 503 except Exception as error:
552 504 status = 128
553 505 stdout.write(f'ERROR: {error}\n')
554 506
555 507 return HookResponse(status, stdout.getvalue())
556 508
557 509
558 510 def _parse_git_ref_lines(revision_lines):
559 511 rev_data = []
560 512 for revision_line in revision_lines or []:
561 513 old_rev, new_rev, ref = revision_line.strip().split(' ')
562 514 ref_data = ref.split('/', 2)
563 515 if ref_data[1] in ('tags', 'heads'):
564 516 rev_data.append({
565 517 # NOTE(marcink):
566 518 # we're unable to tell total_commits for git at this point
567 519 # but we set the variable for consistency with GIT
568 520 'total_commits': -1,
569 521 'old_rev': old_rev,
570 522 'new_rev': new_rev,
571 523 'ref': ref,
572 524 'type': ref_data[1],
573 525 'name': ref_data[2],
574 526 })
575 527 return rev_data
576 528
577 529
578 530 def git_pre_receive(unused_repo_path, revision_lines, env) -> int:
579 531 """
580 532 Pre push hook.
581 533
582 534 :return: status code of the hook. 0 for success.
583 535 """
584 536 extras = json.loads(env['RC_SCM_DATA'])
585 537 rev_data = _parse_git_ref_lines(revision_lines)
586 538 if 'push' not in extras['hooks']:
587 539 return 0
588 540 _fix_hooks_executables(env.get('RC_INI_FILE'))
589 541
590 542 empty_commit_id = '0' * 40
591 543
592 544 detect_force_push = extras.get('detect_force_push')
593 545
594 546 for push_ref in rev_data:
595 547 # store our git-env which holds the temp store
596 548 push_ref['git_env'] = _get_git_env()
597 549 push_ref['pruned_sha'] = ''
598 550 if not detect_force_push:
599 551 # don't check for forced-push when we don't need to
600 552 continue
601 553
602 554 type_ = push_ref['type']
603 555 new_branch = push_ref['old_rev'] == empty_commit_id
604 556 delete_branch = push_ref['new_rev'] == empty_commit_id
605 557 if type_ == 'heads' and not (new_branch or delete_branch):
606 558 old_rev = push_ref['old_rev']
607 559 new_rev = push_ref['new_rev']
608 560 cmd = [settings.GIT_EXECUTABLE(), 'rev-list', old_rev, f'^{new_rev}']
609 561 stdout, stderr = subprocessio.run_command(
610 562 cmd, env=os.environ.copy())
611 563 # means we're having some non-reachable objects, this forced push was used
612 564 if stdout:
613 565 push_ref['pruned_sha'] = stdout.splitlines()
614 566
615 567 extras['hook_type'] = 'pre_receive'
616 568 extras['commit_ids'] = rev_data
617 569
618 570 stdout = sys.stdout
619 571 status_code = _call_hook('pre_push', extras, GitMessageWriter(stdout))
620 572
621 573 return status_code
622 574
623 575
624 576 def git_post_receive(unused_repo_path, revision_lines, env) -> int:
625 577 """
626 578 Post push hook.
627 579
628 580 :return: status code of the hook. 0 for success.
629 581 """
630 582 extras = json.loads(env['RC_SCM_DATA'])
631 583 if 'push' not in extras['hooks']:
632 584 return 0
633 585
634 586 _fix_hooks_executables(env.get('RC_INI_FILE'))
635 587
636 588 rev_data = _parse_git_ref_lines(revision_lines)
637 589
638 590 git_revs = []
639 591
640 592 # N.B.(skreft): it is ok to just call git, as git before calling a
641 593 # subcommand sets the PATH environment variable so that it point to the
642 594 # correct version of the git executable.
643 595 empty_commit_id = '0' * 40
644 596 branches = []
645 597 tags = []
646 598 for push_ref in rev_data:
647 599 type_ = push_ref['type']
648 600
649 601 if type_ == 'heads':
650 602 # starting new branch case
651 603 if push_ref['old_rev'] == empty_commit_id:
652 604 push_ref_name = push_ref['name']
653 605
654 606 if push_ref_name not in branches:
655 607 branches.append(push_ref_name)
656 608
657 609 need_head_set = ''
658 610 with Repository(os.getcwd()) as repo:
659 611 try:
660 612 repo.head
661 613 except pygit2.GitError:
662 614 need_head_set = f'refs/heads/{push_ref_name}'
663 615
664 616 if need_head_set:
665 617 repo.set_head(need_head_set)
666 618 print(f"Setting default branch to {push_ref_name}")
667 619
668 620 cmd = [settings.GIT_EXECUTABLE(), 'for-each-ref', '--format=%(refname)', 'refs/heads/*']
669 621 stdout, stderr = subprocessio.run_command(
670 622 cmd, env=os.environ.copy())
671 623 heads = safe_str(stdout)
672 624 heads = heads.replace(push_ref['ref'], '')
673 625 heads = ' '.join(head for head
674 626 in heads.splitlines() if head) or '.'
675 627 cmd = [settings.GIT_EXECUTABLE(), 'log', '--reverse',
676 628 '--pretty=format:%H', '--', push_ref['new_rev'],
677 629 '--not', heads]
678 630 stdout, stderr = subprocessio.run_command(
679 631 cmd, env=os.environ.copy())
680 632 git_revs.extend(list(map(ascii_str, stdout.splitlines())))
681 633
682 634 # delete branch case
683 635 elif push_ref['new_rev'] == empty_commit_id:
684 636 git_revs.append(f'delete_branch=>{push_ref["name"]}')
685 637 else:
686 638 if push_ref['name'] not in branches:
687 639 branches.append(push_ref['name'])
688 640
689 641 cmd = [settings.GIT_EXECUTABLE(), 'log',
690 642 f'{push_ref["old_rev"]}..{push_ref["new_rev"]}',
691 643 '--reverse', '--pretty=format:%H']
692 644 stdout, stderr = subprocessio.run_command(
693 645 cmd, env=os.environ.copy())
694 646 # we get bytes from stdout, we need str to be consistent
695 647 log_revs = list(map(ascii_str, stdout.splitlines()))
696 648 git_revs.extend(log_revs)
697 649
698 650 # Pure pygit2 impl. but still 2-3x slower :/
699 651 # results = []
700 652 #
701 653 # with Repository(os.getcwd()) as repo:
702 654 # repo_new_rev = repo[push_ref['new_rev']]
703 655 # repo_old_rev = repo[push_ref['old_rev']]
704 656 # walker = repo.walk(repo_new_rev.id, pygit2.GIT_SORT_TOPOLOGICAL)
705 657 #
706 658 # for commit in walker:
707 659 # if commit.id == repo_old_rev.id:
708 660 # break
709 661 # results.append(commit.id.hex)
710 662 # # reverse the order, can't use GIT_SORT_REVERSE
711 663 # log_revs = results[::-1]
712 664
713 665 elif type_ == 'tags':
714 666 if push_ref['name'] not in tags:
715 667 tags.append(push_ref['name'])
716 668 git_revs.append(f'tag=>{push_ref["name"]}')
717 669
718 670 extras['hook_type'] = 'post_receive'
719 671 extras['commit_ids'] = git_revs
720 672 extras['new_refs'] = {
721 673 'branches': branches,
722 674 'bookmarks': [],
723 675 'tags': tags,
724 676 }
725 677
726 678 stdout = sys.stdout
727 679
728 680 if 'repo_size' in extras['hooks']:
729 681 try:
730 682 _call_hook('repo_size', extras, GitMessageWriter(stdout))
731 683 except Exception:
732 684 pass
733 685
734 686 status_code = _call_hook('post_push', extras, GitMessageWriter(stdout))
735 687 return status_code
736 688
737 689
738 690 def get_extras_from_txn_id(repo_path, txn_id):
739 691 extras = get_txn_id_from_store(repo_path, txn_id)
740 692 return extras
741 693
742 694
743 695 def svn_pre_commit(repo_path, commit_data, env):
744 696
745 697 path, txn_id = commit_data
746 698 branches = []
747 699 tags = []
748 700
749 701 if env.get('RC_SCM_DATA'):
750 702 extras = json.loads(env['RC_SCM_DATA'])
751 703 else:
752 704 ini_path = env.get('RC_INI_FILE')
753 705 if ini_path:
754 706 _get_ini_settings(ini_path)
755 707 # fallback method to read from TXN-ID stored data
756 708 extras = get_extras_from_txn_id(path, txn_id)
757 709
758 710 if not extras:
759 711 raise ValueError('SVN-PRE-COMMIT: Failed to extract context data in called extras for hook execution')
760 712
761 713 if extras.get('rc_internal_commit'):
762 714 # special marker for internal commit, we don't call hooks client
763 715 return 0
764 716
765 717 extras['hook_type'] = 'pre_commit'
766 718 extras['commit_ids'] = [txn_id]
767 719 extras['txn_id'] = txn_id
768 720 extras['new_refs'] = {
769 721 'total_commits': 1,
770 722 'branches': branches,
771 723 'bookmarks': [],
772 724 'tags': tags,
773 725 }
774 726
775 727 return _call_hook('pre_push', extras, SvnMessageWriter())
776 728
777 729
778 730 def svn_post_commit(repo_path, commit_data, env):
779 731 """
780 732 commit_data is path, rev, txn_id
781 733 """
782 734
783 735 if len(commit_data) == 3:
784 736 path, commit_id, txn_id = commit_data
785 737 elif len(commit_data) == 2:
786 738 log.error('Failed to extract txn_id from commit_data using legacy method. '
787 739 'Some functionality might be limited')
788 740 path, commit_id = commit_data
789 741 txn_id = None
790 742 else:
791 743 return 0
792 744
793 745 branches = []
794 746 tags = []
795 747
796 748 if env.get('RC_SCM_DATA'):
797 749 extras = json.loads(env['RC_SCM_DATA'])
798 750 else:
799 751 ini_path = env.get('RC_INI_FILE')
800 752 if ini_path:
801 753 _get_ini_settings(ini_path)
802 754 # fallback method to read from TXN-ID stored data
803 755 extras = get_extras_from_txn_id(path, txn_id)
804 756
805 757 if not extras and txn_id:
806 758 raise ValueError('SVN-POST-COMMIT: Failed to extract context data in called extras for hook execution')
807 759
808 760 if extras.get('rc_internal_commit'):
809 761 # special marker for internal commit, we don't call hooks client
810 762 return 0
811 763
812 764 extras['hook_type'] = 'post_commit'
813 765 extras['commit_ids'] = [commit_id]
814 766 extras['txn_id'] = txn_id
815 767 extras['new_refs'] = {
816 768 'branches': branches,
817 769 'bookmarks': [],
818 770 'tags': tags,
819 771 'total_commits': 1,
820 772 }
821 773
822 774 if 'repo_size' in extras['hooks']:
823 775 try:
824 776 _call_hook('repo_size', extras, SvnMessageWriter())
825 777 except Exception:
826 778 pass
827 779
828 780 return _call_hook('post_push', extras, SvnMessageWriter())
@@ -1,257 +1,135 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2023 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 import threading
19 import msgpack
20
21 from http.server import BaseHTTPRequestHandler
22 from socketserver import TCPServer
18 import pytest
23 19
24 20 import mercurial.ui
25 21 import mock
26 import pytest
27 22
28 from vcsserver.hooks import HooksHttpClient
29 23 from vcsserver.lib.ext_json import json
30 24 from vcsserver import hooks
31 25
32 26
33 27 def get_hg_ui(extras=None):
34 28 """Create a Config object with a valid RC_SCM_DATA entry."""
35 29 extras = extras or {}
36 30 required_extras = {
37 31 'username': '',
38 32 'repository': '',
39 33 'locked_by': '',
40 34 'scm': '',
41 35 'make_lock': '',
42 36 'action': '',
43 37 'ip': '',
44 'hooks_uri': 'fake_hooks_uri',
45 38 }
46 39 required_extras.update(extras)
47 40 hg_ui = mercurial.ui.ui()
48 41 hg_ui.setconfig(b'rhodecode', b'RC_SCM_DATA', json.dumps(required_extras))
49 42
50 43 return hg_ui
51 44
52 45
53 46 def test_git_pre_receive_is_disabled():
54 47 extras = {'hooks': ['pull']}
55 48 response = hooks.git_pre_receive(None, None,
56 49 {'RC_SCM_DATA': json.dumps(extras)})
57 50
58 51 assert response == 0
59 52
60 53
61 54 def test_git_post_receive_is_disabled():
62 55 extras = {'hooks': ['pull']}
63 56 response = hooks.git_post_receive(None, '',
64 57 {'RC_SCM_DATA': json.dumps(extras)})
65 58
66 59 assert response == 0
67 60
68 61
69 62 def test_git_post_receive_calls_repo_size():
70 63 extras = {'hooks': ['push', 'repo_size']}
71 64
72 65 with mock.patch.object(hooks, '_call_hook') as call_hook_mock:
73 66 hooks.git_post_receive(
74 67 None, '', {'RC_SCM_DATA': json.dumps(extras)})
75 68 extras.update({'commit_ids': [], 'hook_type': 'post_receive',
76 69 'new_refs': {'bookmarks': [], 'branches': [], 'tags': []}})
77 70 expected_calls = [
78 71 mock.call('repo_size', extras, mock.ANY),
79 72 mock.call('post_push', extras, mock.ANY),
80 73 ]
81 74 assert call_hook_mock.call_args_list == expected_calls
82 75
83 76
84 77 def test_git_post_receive_does_not_call_disabled_repo_size():
85 78 extras = {'hooks': ['push']}
86 79
87 80 with mock.patch.object(hooks, '_call_hook') as call_hook_mock:
88 81 hooks.git_post_receive(
89 82 None, '', {'RC_SCM_DATA': json.dumps(extras)})
90 83 extras.update({'commit_ids': [], 'hook_type': 'post_receive',
91 84 'new_refs': {'bookmarks': [], 'branches': [], 'tags': []}})
92 85 expected_calls = [
93 86 mock.call('post_push', extras, mock.ANY)
94 87 ]
95 88 assert call_hook_mock.call_args_list == expected_calls
96 89
97 90
98 91 def test_repo_size_exception_does_not_affect_git_post_receive():
99 92 extras = {'hooks': ['push', 'repo_size']}
100 93 status = 0
101 94
102 95 def side_effect(name, *args, **kwargs):
103 96 if name == 'repo_size':
104 97 raise Exception('Fake exception')
105 98 else:
106 99 return status
107 100
108 101 with mock.patch.object(hooks, '_call_hook') as call_hook_mock:
109 102 call_hook_mock.side_effect = side_effect
110 103 result = hooks.git_post_receive(
111 104 None, '', {'RC_SCM_DATA': json.dumps(extras)})
112 105 assert result == status
113 106
114 107
115 108 def test_git_pre_pull_is_disabled():
116 109 assert hooks.git_pre_pull({'hooks': ['push']}) == hooks.HookResponse(0, '')
117 110
118 111
119 112 def test_git_post_pull_is_disabled():
120 113 assert (
121 114 hooks.git_post_pull({'hooks': ['push']}) == hooks.HookResponse(0, ''))
122 115
123 116
124 117 class TestGetHooksClient:
125 118
126 def test_returns_http_client_when_protocol_matches(self):
127 hooks_uri = 'localhost:8000'
128 result = hooks._get_hooks_client({
129 'hooks_uri': hooks_uri,
130 'hooks_protocol': 'http'
131 })
132 assert isinstance(result, hooks.HooksHttpClient)
133 assert result.hooks_uri == hooks_uri
134
135 119 def test_return_celery_client_when_queue_and_backend_provided(self):
136 120 task_queue = 'redis://task_queue:0'
137 121 task_backend = task_queue
138 122 result = hooks._get_hooks_client({
139 123 'task_queue': task_queue,
140 124 'task_backend': task_backend
141 125 })
142 126 assert isinstance(result, hooks.HooksCeleryClient)
143 127
144 128
145 class TestHooksHttpClient:
146 def test_init_sets_hooks_uri(self):
147 uri = 'localhost:3000'
148 client = hooks.HooksHttpClient(uri)
149 assert client.hooks_uri == uri
150
151 def test_serialize_returns_serialized_string(self):
152 client = hooks.HooksHttpClient('localhost:3000')
153 hook_name = 'test'
154 extras = {
155 'first': 1,
156 'second': 'two'
157 }
158 hooks_proto, result = client._serialize(hook_name, extras)
159 expected_result = msgpack.packb({
160 'method': hook_name,
161 'extras': extras,
162 })
163 assert hooks_proto == {'rc-hooks-protocol': 'msgpack.v1', 'Connection': 'keep-alive'}
164 assert result == expected_result
165
166 def test_call_queries_http_server(self, http_mirror):
167 client = hooks.HooksHttpClient(http_mirror.uri)
168 hook_name = 'test'
169 extras = {
170 'first': 1,
171 'second': 'two'
172 }
173 result = client(hook_name, extras)
174 expected_result = msgpack.unpackb(msgpack.packb({
175 'method': hook_name,
176 'extras': extras
177 }), raw=False)
178 assert result == expected_result
179
180
181 @pytest.fixture
182 def http_mirror(request):
183 server = MirrorHttpServer()
184 request.addfinalizer(server.stop)
185 return server
186
187
188 class MirrorHttpHandler(BaseHTTPRequestHandler):
189
190 def do_POST(self):
191 length = int(self.headers['Content-Length'])
192 body = self.rfile.read(length)
193 self.send_response(200)
194 self.end_headers()
195 self.wfile.write(body)
196
197
198 class MirrorHttpServer:
199 ip_address = '127.0.0.1'
200 port = 0
129 class TestHooksCeleryClient:
201 130
202 def __init__(self):
203 self._daemon = TCPServer((self.ip_address, 0), MirrorHttpHandler)
204 _, self.port = self._daemon.server_address
205 self._thread = threading.Thread(target=self._daemon.serve_forever)
206 self._thread.daemon = True
207 self._thread.start()
208
209 def stop(self):
210 self._daemon.shutdown()
211 self._thread.join()
212 self._daemon = None
213 self._thread = None
214
215 @property
216 def uri(self):
217 return '{}:{}'.format(self.ip_address, self.port)
218
219
220 def test_hooks_http_client_init():
221 hooks_uri = 'http://localhost:8000'
222 client = HooksHttpClient(hooks_uri)
223 assert client.hooks_uri == hooks_uri
224
225
226 def test_hooks_http_client_call():
227 hooks_uri = 'http://localhost:8000'
228
229 method = 'test_method'
230 extras = {'key': 'value'}
231
232 with \
233 mock.patch('http.client.HTTPConnection') as mock_connection,\
234 mock.patch('msgpack.load') as mock_load:
235
236 client = HooksHttpClient(hooks_uri)
237
238 mock_load.return_value = {'result': 'success'}
239 response = mock.MagicMock()
240 response.status = 200
241 mock_connection.request.side_effect = None
242 mock_connection.getresponse.return_value = response
243
244 result = client(method, extras)
245
246 mock_connection.assert_called_with(hooks_uri)
247 mock_connection.return_value.request.assert_called_once()
248 assert result == {'result': 'success'}
249
250
251 def test_hooks_http_client_serialize():
252 method = 'test_method'
253 extras = {'key': 'value'}
254 headers, body = HooksHttpClient._serialize(method, extras)
255
256 assert headers == {'rc-hooks-protocol': HooksHttpClient.proto, 'Connection': 'keep-alive'}
257 assert msgpack.unpackb(body) == {'method': method, 'extras': extras}
131 def test_hooks_http_client_init(self):
132 queue = 'redis://redis:6379/0'
133 backend = 'redis://redis:6379/0'
134 client = hooks.HooksCeleryClient(queue, backend)
135 assert client.celery_app.conf.broker_url == queue
General Comments 0
You need to be logged in to leave comments. Login now