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