##// END OF EJS Templates
hooks: new hook support for python3
super-admin -
r5081:fb3d7403 default
parent child Browse files
Show More
@@ -1,538 +1,533 b''
1 1
2 2
3 3 # Copyright (C) 2013-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
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 Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 Set of hooks run by RhodeCode Enterprise
24 24 """
25 25
26 26 import os
27 27 import logging
28 28
29 29 import rhodecode
30 30 from rhodecode import events
31 31 from rhodecode.lib import helpers as h
32 32 from rhodecode.lib import audit_logger
33 33 from rhodecode.lib.utils2 import safe_str, user_agent_normalizer
34 34 from rhodecode.lib.exceptions import (
35 35 HTTPLockedRC, HTTPBranchProtected, UserCreationError)
36 36 from rhodecode.model.db import Repository, User
37 37 from rhodecode.lib.statsd_client import StatsdClient
38 38
39 39 log = logging.getLogger(__name__)
40 40
41 41
42 42 class HookResponse(object):
43 43 def __init__(self, status, output):
44 44 self.status = status
45 45 self.output = output
46 46
47 47 def __add__(self, other):
48 48 other_status = getattr(other, 'status', 0)
49 49 new_status = max(self.status, other_status)
50 50 other_output = getattr(other, 'output', '')
51 51 new_output = self.output + other_output
52 52
53 53 return HookResponse(new_status, new_output)
54 54
55 55 def __bool__(self):
56 56 return self.status == 0
57 57
58 58
59 59 def is_shadow_repo(extras):
60 60 """
61 61 Returns ``True`` if this is an action executed against a shadow repository.
62 62 """
63 63 return extras['is_shadow_repo']
64 64
65 65
66 66 def _get_scm_size(alias, root_path):
67 67
68 68 if not alias.startswith('.'):
69 69 alias += '.'
70 70
71 71 size_scm, size_root = 0, 0
72 72 for path, unused_dirs, files in os.walk(safe_str(root_path)):
73 73 if path.find(alias) != -1:
74 74 for f in files:
75 75 try:
76 76 size_scm += os.path.getsize(os.path.join(path, f))
77 77 except OSError:
78 78 pass
79 79 else:
80 80 for f in files:
81 81 try:
82 82 size_root += os.path.getsize(os.path.join(path, f))
83 83 except OSError:
84 84 pass
85 85
86 86 size_scm_f = h.format_byte_size_binary(size_scm)
87 87 size_root_f = h.format_byte_size_binary(size_root)
88 88 size_total_f = h.format_byte_size_binary(size_root + size_scm)
89 89
90 90 return size_scm_f, size_root_f, size_total_f
91 91
92 92
93 93 # actual hooks called by Mercurial internally, and GIT by our Python Hooks
94 94 def repo_size(extras):
95 95 """Present size of repository after push."""
96 96 repo = Repository.get_by_repo_name(extras.repository)
97 vcs_part = safe_str('.%s' % repo.repo_type)
98 size_vcs, size_root, size_total = _get_scm_size(vcs_part,
99 repo.repo_full_path)
100 msg = ('Repository `%s` size summary %s:%s repo:%s total:%s\n'
101 % (repo.repo_name, vcs_part, size_vcs, size_root, size_total))
97 vcs_part = f'.{repo.repo_type}'
98 size_vcs, size_root, size_total = _get_scm_size(vcs_part, repo.repo_full_path)
99 msg = (f'RhodeCode: `{repo.repo_name}` size summary {vcs_part}:{size_vcs} repo:{size_root} total:{size_total}\n')
102 100 return HookResponse(0, msg)
103 101
104 102
105 103 def pre_push(extras):
106 104 """
107 105 Hook executed before pushing code.
108 106
109 107 It bans pushing when the repository is locked.
110 108 """
111 109
112 110 user = User.get_by_username(extras.username)
113 111 output = ''
114 112 if extras.locked_by[0] and user.user_id != int(extras.locked_by[0]):
115 113 locked_by = User.get(extras.locked_by[0]).username
116 114 reason = extras.locked_by[2]
117 115 # this exception is interpreted in git/hg middlewares and based
118 116 # on that proper return code is server to client
119 117 _http_ret = HTTPLockedRC(
120 118 _locked_by_explanation(extras.repository, locked_by, reason))
121 119 if str(_http_ret.code).startswith('2'):
122 120 # 2xx Codes don't raise exceptions
123 121 output = _http_ret.title
124 122 else:
125 123 raise _http_ret
126 124
127 125 hook_response = ''
128 126 if not is_shadow_repo(extras):
127
129 128 if extras.commit_ids and extras.check_branch_perms:
130
131 129 auth_user = user.AuthUser()
132 130 repo = Repository.get_by_repo_name(extras.repository)
133 131 affected_branches = []
134 132 if repo.repo_type == 'hg':
135 133 for entry in extras.commit_ids:
136 134 if entry['type'] == 'branch':
137 135 is_forced = bool(entry['multiple_heads'])
138 136 affected_branches.append([entry['name'], is_forced])
139 137 elif repo.repo_type == 'git':
140 138 for entry in extras.commit_ids:
141 139 if entry['type'] == 'heads':
142 140 is_forced = bool(entry['pruned_sha'])
143 141 affected_branches.append([entry['name'], is_forced])
144 142
145 143 for branch_name, is_forced in affected_branches:
146 144
147 145 rule, branch_perm = auth_user.get_rule_and_branch_permission(
148 146 extras.repository, branch_name)
149 147 if not branch_perm:
150 148 # no branch permission found for this branch, just keep checking
151 149 continue
152 150
153 151 if branch_perm == 'branch.push_force':
154 152 continue
155 153 elif branch_perm == 'branch.push' and is_forced is False:
156 154 continue
157 155 elif branch_perm == 'branch.push' and is_forced is True:
158 halt_message = 'Branch `{}` changes rejected by rule {}. ' \
159 'FORCE PUSH FORBIDDEN.'.format(branch_name, rule)
156 halt_message = f'Branch `{branch_name}` changes rejected by rule {rule}. ' \
157 f'FORCE PUSH FORBIDDEN.'
160 158 else:
161 halt_message = 'Branch `{}` changes rejected by rule {}.'.format(
162 branch_name, rule)
159 halt_message = f'Branch `{branch_name}` changes rejected by rule {rule}.'
163 160
164 161 if halt_message:
165 162 _http_ret = HTTPBranchProtected(halt_message)
166 163 raise _http_ret
167 164
168 165 # Propagate to external components. This is done after checking the
169 166 # lock, for consistent behavior.
170 167 hook_response = pre_push_extension(
171 168 repo_store_path=Repository.base_path(), **extras)
172 169 events.trigger(events.RepoPrePushEvent(
173 170 repo_name=extras.repository, extras=extras))
174 171
175 172 return HookResponse(0, output) + hook_response
176 173
177 174
178 175 def pre_pull(extras):
179 176 """
180 177 Hook executed before pulling the code.
181 178
182 179 It bans pulling when the repository is locked.
183 180 """
184 181
185 182 output = ''
186 183 if extras.locked_by[0]:
187 184 locked_by = User.get(extras.locked_by[0]).username
188 185 reason = extras.locked_by[2]
189 186 # this exception is interpreted in git/hg middlewares and based
190 187 # on that proper return code is server to client
191 188 _http_ret = HTTPLockedRC(
192 189 _locked_by_explanation(extras.repository, locked_by, reason))
193 190 if str(_http_ret.code).startswith('2'):
194 191 # 2xx Codes don't raise exceptions
195 192 output = _http_ret.title
196 193 else:
197 194 raise _http_ret
198 195
199 196 # Propagate to external components. This is done after checking the
200 197 # lock, for consistent behavior.
201 198 hook_response = ''
202 199 if not is_shadow_repo(extras):
203 200 extras.hook_type = extras.hook_type or 'pre_pull'
204 201 hook_response = pre_pull_extension(
205 202 repo_store_path=Repository.base_path(), **extras)
206 203 events.trigger(events.RepoPrePullEvent(
207 204 repo_name=extras.repository, extras=extras))
208 205
209 206 return HookResponse(0, output) + hook_response
210 207
211 208
212 209 def post_pull(extras):
213 210 """Hook executed after client pulls the code."""
214 211
215 212 audit_user = audit_logger.UserWrap(
216 213 username=extras.username,
217 214 ip_addr=extras.ip)
218 215 repo = audit_logger.RepoWrap(repo_name=extras.repository)
219 216 audit_logger.store(
220 217 'user.pull', action_data={'user_agent': extras.user_agent},
221 218 user=audit_user, repo=repo, commit=True)
222 219
223 220 statsd = StatsdClient.statsd
224 221 if statsd:
225 222 statsd.incr('rhodecode_pull_total', tags=[
226 223 'user-agent:{}'.format(user_agent_normalizer(extras.user_agent)),
227 224 ])
228 225 output = ''
229 226 # make lock is a tri state False, True, None. We only make lock on True
230 227 if extras.make_lock is True and not is_shadow_repo(extras):
231 228 user = User.get_by_username(extras.username)
232 229 Repository.lock(Repository.get_by_repo_name(extras.repository),
233 230 user.user_id,
234 231 lock_reason=Repository.LOCK_PULL)
235 232 msg = 'Made lock on repo `%s`' % (extras.repository,)
236 233 output += msg
237 234
238 235 if extras.locked_by[0]:
239 236 locked_by = User.get(extras.locked_by[0]).username
240 237 reason = extras.locked_by[2]
241 238 _http_ret = HTTPLockedRC(
242 239 _locked_by_explanation(extras.repository, locked_by, reason))
243 240 if str(_http_ret.code).startswith('2'):
244 241 # 2xx Codes don't raise exceptions
245 242 output += _http_ret.title
246 243
247 244 # Propagate to external components.
248 245 hook_response = ''
249 246 if not is_shadow_repo(extras):
250 247 extras.hook_type = extras.hook_type or 'post_pull'
251 248 hook_response = post_pull_extension(
252 249 repo_store_path=Repository.base_path(), **extras)
253 250 events.trigger(events.RepoPullEvent(
254 251 repo_name=extras.repository, extras=extras))
255 252
256 253 return HookResponse(0, output) + hook_response
257 254
258 255
259 256 def post_push(extras):
260 257 """Hook executed after user pushes to the repository."""
261 258 commit_ids = extras.commit_ids
262 259
263 260 # log the push call
264 261 audit_user = audit_logger.UserWrap(
265 262 username=extras.username, ip_addr=extras.ip)
266 263 repo = audit_logger.RepoWrap(repo_name=extras.repository)
267 264 audit_logger.store(
268 265 'user.push', action_data={
269 266 'user_agent': extras.user_agent,
270 267 'commit_ids': commit_ids[:400]},
271 268 user=audit_user, repo=repo, commit=True)
272 269
273 270 statsd = StatsdClient.statsd
274 271 if statsd:
275 272 statsd.incr('rhodecode_push_total', tags=[
276 273 'user-agent:{}'.format(user_agent_normalizer(extras.user_agent)),
277 274 ])
278 275
279 276 # Propagate to external components.
280 277 output = ''
281 278 # make lock is a tri state False, True, None. We only release lock on False
282 279 if extras.make_lock is False and not is_shadow_repo(extras):
283 280 Repository.unlock(Repository.get_by_repo_name(extras.repository))
284 msg = 'Released lock on repo `{}`\n'.format(safe_str(extras.repository))
281 msg = f'Released lock on repo `{extras.repository}`\n'
285 282 output += msg
286 283
287 284 if extras.locked_by[0]:
288 285 locked_by = User.get(extras.locked_by[0]).username
289 286 reason = extras.locked_by[2]
290 287 _http_ret = HTTPLockedRC(
291 288 _locked_by_explanation(extras.repository, locked_by, reason))
292 289 # TODO: johbo: if not?
293 290 if str(_http_ret.code).startswith('2'):
294 291 # 2xx Codes don't raise exceptions
295 292 output += _http_ret.title
296 293
297 294 if extras.new_refs:
298 295 tmpl = '{}/{}/pull-request/new?{{ref_type}}={{ref_name}}'.format(
299 296 safe_str(extras.server_url), safe_str(extras.repository))
300 297
301 298 for branch_name in extras.new_refs['branches']:
302 output += 'RhodeCode: open pull request link: {}\n'.format(
303 tmpl.format(ref_type='branch', ref_name=safe_str(branch_name)))
299 pr_link = tmpl.format(ref_type='branch', ref_name=safe_str(branch_name))
300 output += f'RhodeCode: open pull request link: {pr_link}\n'
304 301
305 302 for book_name in extras.new_refs['bookmarks']:
306 output += 'RhodeCode: open pull request link: {}\n'.format(
307 tmpl.format(ref_type='bookmark', ref_name=safe_str(book_name)))
303 pr_link = tmpl.format(ref_type='bookmark', ref_name=safe_str(book_name))
304 output += f'RhodeCode: open pull request link: {pr_link}\n'
308 305
309 306 hook_response = ''
310 307 if not is_shadow_repo(extras):
311 308 hook_response = post_push_extension(
312 309 repo_store_path=Repository.base_path(),
313 310 **extras)
314 311 events.trigger(events.RepoPushEvent(
315 312 repo_name=extras.repository, pushed_commit_ids=commit_ids, extras=extras))
316 313
317 314 output += 'RhodeCode: push completed\n'
318 315 return HookResponse(0, output) + hook_response
319 316
320 317
321 318 def _locked_by_explanation(repo_name, user_name, reason):
322 message = (
323 'Repository `%s` locked by user `%s`. Reason:`%s`'
324 % (repo_name, user_name, reason))
319 message = f'Repository `{repo_name}` locked by user `{user_name}`. Reason:`{reason}`'
325 320 return message
326 321
327 322
328 323 def check_allowed_create_user(user_dict, created_by, **kwargs):
329 324 # pre create hooks
330 325 if pre_create_user.is_active():
331 326 hook_result = pre_create_user(created_by=created_by, **user_dict)
332 327 allowed = hook_result.status == 0
333 328 if not allowed:
334 329 reason = hook_result.output
335 330 raise UserCreationError(reason)
336 331
337 332
338 333 class ExtensionCallback(object):
339 334 """
340 335 Forwards a given call to rcextensions, sanitizes keyword arguments.
341 336
342 337 Does check if there is an extension active for that hook. If it is
343 338 there, it will forward all `kwargs_keys` keyword arguments to the
344 339 extension callback.
345 340 """
346 341
347 342 def __init__(self, hook_name, kwargs_keys):
348 343 self._hook_name = hook_name
349 344 self._kwargs_keys = set(kwargs_keys)
350 345
351 346 def __call__(self, *args, **kwargs):
352 347 log.debug('Calling extension callback for `%s`', self._hook_name)
353 348 callback = self._get_callback()
354 349 if not callback:
355 350 log.debug('extension callback `%s` not found, skipping...', self._hook_name)
356 351 return
357 352
358 353 kwargs_to_pass = {}
359 354 for key in self._kwargs_keys:
360 355 try:
361 356 kwargs_to_pass[key] = kwargs[key]
362 357 except KeyError:
363 358 log.error('Failed to fetch %s key from given kwargs. '
364 359 'Expected keys: %s', key, self._kwargs_keys)
365 360 raise
366 361
367 362 # backward compat for removed api_key for old hooks. This was it works
368 363 # with older rcextensions that require api_key present
369 364 if self._hook_name in ['CREATE_USER_HOOK', 'DELETE_USER_HOOK']:
370 365 kwargs_to_pass['api_key'] = '_DEPRECATED_'
371 366 return callback(**kwargs_to_pass)
372 367
373 368 def is_active(self):
374 369 return hasattr(rhodecode.EXTENSIONS, self._hook_name)
375 370
376 371 def _get_callback(self):
377 372 return getattr(rhodecode.EXTENSIONS, self._hook_name, None)
378 373
379 374
380 375 pre_pull_extension = ExtensionCallback(
381 376 hook_name='PRE_PULL_HOOK',
382 377 kwargs_keys=(
383 378 'server_url', 'config', 'scm', 'username', 'ip', 'action',
384 379 'repository', 'hook_type', 'user_agent', 'repo_store_path',))
385 380
386 381
387 382 post_pull_extension = ExtensionCallback(
388 383 hook_name='PULL_HOOK',
389 384 kwargs_keys=(
390 385 'server_url', 'config', 'scm', 'username', 'ip', 'action',
391 386 'repository', 'hook_type', 'user_agent', 'repo_store_path',))
392 387
393 388
394 389 pre_push_extension = ExtensionCallback(
395 390 hook_name='PRE_PUSH_HOOK',
396 391 kwargs_keys=(
397 392 'server_url', 'config', 'scm', 'username', 'ip', 'action',
398 393 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',))
399 394
400 395
401 396 post_push_extension = ExtensionCallback(
402 397 hook_name='PUSH_HOOK',
403 398 kwargs_keys=(
404 399 'server_url', 'config', 'scm', 'username', 'ip', 'action',
405 400 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',))
406 401
407 402
408 403 pre_create_user = ExtensionCallback(
409 404 hook_name='PRE_CREATE_USER_HOOK',
410 405 kwargs_keys=(
411 406 'username', 'password', 'email', 'firstname', 'lastname', 'active',
412 407 'admin', 'created_by'))
413 408
414 409
415 410 create_pull_request = ExtensionCallback(
416 411 hook_name='CREATE_PULL_REQUEST',
417 412 kwargs_keys=(
418 413 'server_url', 'config', 'scm', 'username', 'ip', 'action',
419 414 'repository', 'pull_request_id', 'url', 'title', 'description',
420 415 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
421 416 'mergeable', 'source', 'target', 'author', 'reviewers'))
422 417
423 418
424 419 merge_pull_request = ExtensionCallback(
425 420 hook_name='MERGE_PULL_REQUEST',
426 421 kwargs_keys=(
427 422 'server_url', 'config', 'scm', 'username', 'ip', 'action',
428 423 'repository', 'pull_request_id', 'url', 'title', 'description',
429 424 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
430 425 'mergeable', 'source', 'target', 'author', 'reviewers'))
431 426
432 427
433 428 close_pull_request = ExtensionCallback(
434 429 hook_name='CLOSE_PULL_REQUEST',
435 430 kwargs_keys=(
436 431 'server_url', 'config', 'scm', 'username', 'ip', 'action',
437 432 'repository', 'pull_request_id', 'url', 'title', 'description',
438 433 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
439 434 'mergeable', 'source', 'target', 'author', 'reviewers'))
440 435
441 436
442 437 review_pull_request = ExtensionCallback(
443 438 hook_name='REVIEW_PULL_REQUEST',
444 439 kwargs_keys=(
445 440 'server_url', 'config', 'scm', 'username', 'ip', 'action',
446 441 'repository', 'pull_request_id', 'url', 'title', 'description',
447 442 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
448 443 'mergeable', 'source', 'target', 'author', 'reviewers'))
449 444
450 445
451 446 comment_pull_request = ExtensionCallback(
452 447 hook_name='COMMENT_PULL_REQUEST',
453 448 kwargs_keys=(
454 449 'server_url', 'config', 'scm', 'username', 'ip', 'action',
455 450 'repository', 'pull_request_id', 'url', 'title', 'description',
456 451 'status', 'comment', 'created_on', 'updated_on', 'commit_ids', 'review_status',
457 452 'mergeable', 'source', 'target', 'author', 'reviewers'))
458 453
459 454
460 455 comment_edit_pull_request = ExtensionCallback(
461 456 hook_name='COMMENT_EDIT_PULL_REQUEST',
462 457 kwargs_keys=(
463 458 'server_url', 'config', 'scm', 'username', 'ip', 'action',
464 459 'repository', 'pull_request_id', 'url', 'title', 'description',
465 460 'status', 'comment', 'created_on', 'updated_on', 'commit_ids', 'review_status',
466 461 'mergeable', 'source', 'target', 'author', 'reviewers'))
467 462
468 463
469 464 update_pull_request = ExtensionCallback(
470 465 hook_name='UPDATE_PULL_REQUEST',
471 466 kwargs_keys=(
472 467 'server_url', 'config', 'scm', 'username', 'ip', 'action',
473 468 'repository', 'pull_request_id', 'url', 'title', 'description',
474 469 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status',
475 470 'mergeable', 'source', 'target', 'author', 'reviewers'))
476 471
477 472
478 473 create_user = ExtensionCallback(
479 474 hook_name='CREATE_USER_HOOK',
480 475 kwargs_keys=(
481 476 'username', 'full_name_or_username', 'full_contact', 'user_id',
482 477 'name', 'firstname', 'short_contact', 'admin', 'lastname',
483 478 'ip_addresses', 'extern_type', 'extern_name',
484 479 'email', 'api_keys', 'last_login',
485 480 'full_name', 'active', 'password', 'emails',
486 481 'inherit_default_permissions', 'created_by', 'created_on'))
487 482
488 483
489 484 delete_user = ExtensionCallback(
490 485 hook_name='DELETE_USER_HOOK',
491 486 kwargs_keys=(
492 487 'username', 'full_name_or_username', 'full_contact', 'user_id',
493 488 'name', 'firstname', 'short_contact', 'admin', 'lastname',
494 489 'ip_addresses',
495 490 'email', 'last_login',
496 491 'full_name', 'active', 'password', 'emails',
497 492 'inherit_default_permissions', 'deleted_by'))
498 493
499 494
500 495 create_repository = ExtensionCallback(
501 496 hook_name='CREATE_REPO_HOOK',
502 497 kwargs_keys=(
503 498 'repo_name', 'repo_type', 'description', 'private', 'created_on',
504 499 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
505 500 'clone_uri', 'fork_id', 'group_id', 'created_by'))
506 501
507 502
508 503 delete_repository = ExtensionCallback(
509 504 hook_name='DELETE_REPO_HOOK',
510 505 kwargs_keys=(
511 506 'repo_name', 'repo_type', 'description', 'private', 'created_on',
512 507 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
513 508 'clone_uri', 'fork_id', 'group_id', 'deleted_by', 'deleted_on'))
514 509
515 510
516 511 comment_commit_repository = ExtensionCallback(
517 512 hook_name='COMMENT_COMMIT_REPO_HOOK',
518 513 kwargs_keys=(
519 514 'repo_name', 'repo_type', 'description', 'private', 'created_on',
520 515 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
521 516 'clone_uri', 'fork_id', 'group_id',
522 517 'repository', 'created_by', 'comment', 'commit'))
523 518
524 519 comment_edit_commit_repository = ExtensionCallback(
525 520 hook_name='COMMENT_EDIT_COMMIT_REPO_HOOK',
526 521 kwargs_keys=(
527 522 'repo_name', 'repo_type', 'description', 'private', 'created_on',
528 523 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics',
529 524 'clone_uri', 'fork_id', 'group_id',
530 525 'repository', 'created_by', 'comment', 'commit'))
531 526
532 527
533 528 create_repository_group = ExtensionCallback(
534 529 hook_name='CREATE_REPO_GROUP_HOOK',
535 530 kwargs_keys=(
536 531 'group_name', 'group_parent_id', 'group_description',
537 532 'group_id', 'user_id', 'created_by', 'created_on',
538 533 'enable_locking'))
@@ -1,364 +1,438 b''
1 1
2 2 # Copyright (C) 2010-2020 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 Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 import os
21 21 import time
22 22 import logging
23 23 import tempfile
24 24 import traceback
25 25 import threading
26 26 import socket
27 27 import msgpack
28 import gevent
28 29
29 30 from http.server import BaseHTTPRequestHandler
30 31 from socketserver import TCPServer
31 32
32 33 import rhodecode
33 34 from rhodecode.lib.exceptions import HTTPLockedRC, HTTPBranchProtected
34 35 from rhodecode.model import meta
35 36 from rhodecode.lib.base import bootstrap_request, bootstrap_config
36 37 from rhodecode.lib import hooks_base
37 38 from rhodecode.lib.utils2 import AttributeDict
38 39 from rhodecode.lib.ext_json import json
39 40 from rhodecode.lib import rc_cache
40 41
41 42 log = logging.getLogger(__name__)
42 43
43 44
44 45 class HooksHttpHandler(BaseHTTPRequestHandler):
45 46
47 JSON_HOOKS_PROTO = 'json.v1'
48 MSGPACK_HOOKS_PROTO = 'msgpack.v1'
49 # starting with RhodeCode 5.0.0 MsgPack is the default, prior it used json
50 DEFAULT_HOOKS_PROTO = MSGPACK_HOOKS_PROTO
51
52 @classmethod
53 def serialize_data(cls, data, proto=DEFAULT_HOOKS_PROTO):
54 if proto == cls.MSGPACK_HOOKS_PROTO:
55 return msgpack.packb(data)
56 return json.dumps(data)
57
58 @classmethod
59 def deserialize_data(cls, data, proto=DEFAULT_HOOKS_PROTO):
60 if proto == cls.MSGPACK_HOOKS_PROTO:
61 return msgpack.unpackb(data)
62 return json.loads(data)
63
46 64 def do_POST(self):
47 65 hooks_proto, method, extras = self._read_request()
48 66 log.debug('Handling HooksHttpHandler %s with %s proto', method, hooks_proto)
49 67
50 68 txn_id = getattr(self.server, 'txn_id', None)
51 69 if txn_id:
52 70 log.debug('Computing TXN_ID based on `%s`:`%s`',
53 71 extras['repository'], extras['txn_id'])
54 72 computed_txn_id = rc_cache.utils.compute_key_from_params(
55 73 extras['repository'], extras['txn_id'])
56 74 if txn_id != computed_txn_id:
57 75 raise Exception(
58 76 'TXN ID fail: expected {} got {} instead'.format(
59 77 txn_id, computed_txn_id))
60 78
61 79 request = getattr(self.server, 'request', None)
62 80 try:
63 81 hooks = Hooks(request=request, log_prefix='HOOKS: {} '.format(self.server.server_address))
64 82 result = self._call_hook_method(hooks, method, extras)
83
65 84 except Exception as e:
66 85 exc_tb = traceback.format_exc()
67 86 result = {
68 87 'exception': e.__class__.__name__,
69 88 'exception_traceback': exc_tb,
70 89 'exception_args': e.args
71 90 }
72 91 self._write_response(hooks_proto, result)
73 92
74 93 def _read_request(self):
75 94 length = int(self.headers['Content-Length'])
76 hooks_proto = self.headers.get('rc-hooks-protocol') or 'json.v1'
77 if hooks_proto == 'msgpack.v1':
95 # respect sent headers, fallback to OLD proto for compatability
96 hooks_proto = self.headers.get('rc-hooks-protocol') or self.JSON_HOOKS_PROTO
97 if hooks_proto == self.MSGPACK_HOOKS_PROTO:
78 98 # support for new vcsserver msgpack based protocol hooks
79 data = msgpack.unpackb(self.rfile.read(length), raw=False)
99 body = self.rfile.read(length)
100 data = self.deserialize_data(body)
80 101 else:
81 102 body = self.rfile.read(length)
82 data = json.loads(body)
103 data = self.deserialize_data(body)
83 104
84 105 return hooks_proto, data['method'], data['extras']
85 106
86 107 def _write_response(self, hooks_proto, result):
87 108 self.send_response(200)
88 if hooks_proto == 'msgpack.v1':
109 if hooks_proto == self.MSGPACK_HOOKS_PROTO:
89 110 self.send_header("Content-type", "application/msgpack")
90 111 self.end_headers()
91 self.wfile.write(msgpack.packb(result))
112 data = self.serialize_data(result)
113 self.wfile.write(data)
92 114 else:
93 115 self.send_header("Content-type", "text/json")
94 116 self.end_headers()
95 self.wfile.write(json.dumps(result))
117 data = self.serialize_data(result)
118 self.wfile.write(data)
96 119
97 120 def _call_hook_method(self, hooks, method, extras):
98 121 try:
99 122 result = getattr(hooks, method)(extras)
100 123 finally:
101 124 meta.Session.remove()
102 125 return result
103 126
104 127 def log_message(self, format, *args):
105 128 """
106 129 This is an overridden method of BaseHTTPRequestHandler which logs using
107 130 logging library instead of writing directly to stderr.
108 131 """
109 132
110 133 message = format % args
111 134
112 135 log.debug(
113 "HOOKS: %s - - [%s] %s", self.client_address,
136 "HOOKS: client=%s - - [%s] %s", self.client_address,
114 137 self.log_date_time_string(), message)
115 138
116 139
117 140 class DummyHooksCallbackDaemon(object):
118 141 hooks_uri = ''
119 142
120 143 def __init__(self):
121 144 self.hooks_module = Hooks.__module__
122 145
123 146 def __enter__(self):
124 147 log.debug('Running `%s` callback daemon', self.__class__.__name__)
125 148 return self
126 149
127 150 def __exit__(self, exc_type, exc_val, exc_tb):
128 151 log.debug('Exiting `%s` callback daemon', self.__class__.__name__)
129 152
130 153
131 154 class ThreadedHookCallbackDaemon(object):
132 155
133 156 _callback_thread = None
134 157 _daemon = None
135 158 _done = False
159 use_gevent = False
136 160
137 161 def __init__(self, txn_id=None, host=None, port=None):
138 162 self._prepare(txn_id=txn_id, host=host, port=port)
163 if self.use_gevent:
164 self._run_func = self._run_gevent
165 self._stop_func = self._stop_gevent
166 else:
167 self._run_func = self._run
168 self._stop_func = self._stop
139 169
140 170 def __enter__(self):
141 171 log.debug('Running `%s` callback daemon', self.__class__.__name__)
142 self._run()
172 self._run_func()
143 173 return self
144 174
145 175 def __exit__(self, exc_type, exc_val, exc_tb):
146 176 log.debug('Exiting `%s` callback daemon', self.__class__.__name__)
147 self._stop()
177 self._stop_func()
148 178
149 179 def _prepare(self, txn_id=None, host=None, port=None):
150 180 raise NotImplementedError()
151 181
152 182 def _run(self):
153 183 raise NotImplementedError()
154 184
155 185 def _stop(self):
156 186 raise NotImplementedError()
157 187
188 def _run_gevent(self):
189 raise NotImplementedError()
190
191 def _stop_gevent(self):
192 raise NotImplementedError()
193
158 194
159 195 class HttpHooksCallbackDaemon(ThreadedHookCallbackDaemon):
160 196 """
161 197 Context manager which will run a callback daemon in a background thread.
162 198 """
163 199
164 200 hooks_uri = None
165 201
166 202 # From Python docs: Polling reduces our responsiveness to a shutdown
167 203 # request and wastes cpu at all other times.
168 204 POLL_INTERVAL = 0.01
169 205
206 use_gevent = False
207
170 208 @property
171 209 def _hook_prefix(self):
172 210 return 'HOOKS: {} '.format(self.hooks_uri)
173 211
174 212 def get_hostname(self):
175 213 return socket.gethostname() or '127.0.0.1'
176 214
177 215 def get_available_port(self, min_port=20000, max_port=65535):
178 216 from rhodecode.lib.utils2 import get_available_port as _get_port
179 217 return _get_port(min_port, max_port)
180 218
181 219 def _prepare(self, txn_id=None, host=None, port=None):
182 220 from pyramid.threadlocal import get_current_request
183 221
184 222 if not host or host == "*":
185 223 host = self.get_hostname()
186 224 if not port:
187 225 port = self.get_available_port()
188 226
189 227 server_address = (host, port)
190 228 self.hooks_uri = '{}:{}'.format(host, port)
191 229 self.txn_id = txn_id
192 230 self._done = False
193 231
194 232 log.debug(
195 233 "%s Preparing HTTP callback daemon registering hook object: %s",
196 234 self._hook_prefix, HooksHttpHandler)
197 235
198 236 self._daemon = TCPServer(server_address, HooksHttpHandler)
199 237 # inject transaction_id for later verification
200 238 self._daemon.txn_id = self.txn_id
201 239
202 240 # pass the WEB app request into daemon
203 241 self._daemon.request = get_current_request()
204 242
205 243 def _run(self):
206 log.debug("Running event loop of callback daemon in background thread")
244 log.debug("Running thread-based loop of callback daemon in background")
207 245 callback_thread = threading.Thread(
208 246 target=self._daemon.serve_forever,
209 247 kwargs={'poll_interval': self.POLL_INTERVAL})
210 248 callback_thread.daemon = True
211 249 callback_thread.start()
212 250 self._callback_thread = callback_thread
213 251
252 def _run_gevent(self):
253 log.debug("Running gevent-based loop of callback daemon in background")
254 # create a new greenlet for the daemon's serve_forever method
255 callback_greenlet = gevent.spawn(
256 self._daemon.serve_forever,
257 poll_interval=self.POLL_INTERVAL)
258
259 # store reference to greenlet
260 self._callback_greenlet = callback_greenlet
261
262 # switch to this greenlet
263 gevent.sleep(0.01)
264
214 265 def _stop(self):
215 266 log.debug("Waiting for background thread to finish.")
216 267 self._daemon.shutdown()
217 268 self._callback_thread.join()
218 269 self._daemon = None
219 270 self._callback_thread = None
220 271 if self.txn_id:
221 272 txn_id_file = get_txn_id_data_path(self.txn_id)
222 273 log.debug('Cleaning up TXN ID %s', txn_id_file)
223 274 if os.path.isfile(txn_id_file):
224 275 os.remove(txn_id_file)
225 276
226 277 log.debug("Background thread done.")
227 278
279 def _stop_gevent(self):
280 log.debug("Waiting for background greenlet to finish.")
281
282 # if greenlet exists and is running
283 if self._callback_greenlet and not self._callback_greenlet.dead:
284 # shutdown daemon if it exists
285 if self._daemon:
286 self._daemon.shutdown()
287
288 # kill the greenlet
289 self._callback_greenlet.kill()
290
291 self._daemon = None
292 self._callback_greenlet = None
293
294 if self.txn_id:
295 txn_id_file = get_txn_id_data_path(self.txn_id)
296 log.debug('Cleaning up TXN ID %s', txn_id_file)
297 if os.path.isfile(txn_id_file):
298 os.remove(txn_id_file)
299
300 log.debug("Background greenlet done.")
301
228 302
229 303 def get_txn_id_data_path(txn_id):
230 304 import rhodecode
231 305
232 306 root = rhodecode.CONFIG.get('cache_dir') or tempfile.gettempdir()
233 307 final_dir = os.path.join(root, 'svn_txn_id')
234 308
235 309 if not os.path.isdir(final_dir):
236 310 os.makedirs(final_dir)
237 311 return os.path.join(final_dir, 'rc_txn_id_{}'.format(txn_id))
238 312
239 313
240 314 def store_txn_id_data(txn_id, data_dict):
241 315 if not txn_id:
242 316 log.warning('Cannot store txn_id because it is empty')
243 317 return
244 318
245 319 path = get_txn_id_data_path(txn_id)
246 320 try:
247 321 with open(path, 'wb') as f:
248 322 f.write(json.dumps(data_dict))
249 323 except Exception:
250 324 log.exception('Failed to write txn_id metadata')
251 325
252 326
253 327 def get_txn_id_from_store(txn_id):
254 328 """
255 329 Reads txn_id from store and if present returns the data for callback manager
256 330 """
257 331 path = get_txn_id_data_path(txn_id)
258 332 try:
259 333 with open(path, 'rb') as f:
260 334 return json.loads(f.read())
261 335 except Exception:
262 336 return {}
263 337
264 338
265 339 def prepare_callback_daemon(extras, protocol, host, use_direct_calls, txn_id=None):
266 340 txn_details = get_txn_id_from_store(txn_id)
267 341 port = txn_details.get('port', 0)
268 342 if use_direct_calls:
269 343 callback_daemon = DummyHooksCallbackDaemon()
270 344 extras['hooks_module'] = callback_daemon.hooks_module
271 345 else:
272 346 if protocol == 'http':
273 347 callback_daemon = HttpHooksCallbackDaemon(
274 348 txn_id=txn_id, host=host, port=port)
275 349 else:
276 350 log.error('Unsupported callback daemon protocol "%s"', protocol)
277 351 raise Exception('Unsupported callback daemon protocol.')
278 352
279 353 extras['hooks_uri'] = callback_daemon.hooks_uri
280 354 extras['hooks_protocol'] = protocol
281 355 extras['time'] = time.time()
282 356
283 357 # register txn_id
284 358 extras['txn_id'] = txn_id
285 359 log.debug('Prepared a callback daemon: %s at url `%s`',
286 360 callback_daemon.__class__.__name__, callback_daemon.hooks_uri)
287 361 return callback_daemon, extras
288 362
289 363
290 364 class Hooks(object):
291 365 """
292 366 Exposes the hooks for remote call backs
293 367 """
294 368 def __init__(self, request=None, log_prefix=''):
295 369 self.log_prefix = log_prefix
296 370 self.request = request
297 371
298 372 def repo_size(self, extras):
299 373 log.debug("%sCalled repo_size of %s object", self.log_prefix, self)
300 374 return self._call_hook(hooks_base.repo_size, extras)
301 375
302 376 def pre_pull(self, extras):
303 377 log.debug("%sCalled pre_pull of %s object", self.log_prefix, self)
304 378 return self._call_hook(hooks_base.pre_pull, extras)
305 379
306 380 def post_pull(self, extras):
307 381 log.debug("%sCalled post_pull of %s object", self.log_prefix, self)
308 382 return self._call_hook(hooks_base.post_pull, extras)
309 383
310 384 def pre_push(self, extras):
311 385 log.debug("%sCalled pre_push of %s object", self.log_prefix, self)
312 386 return self._call_hook(hooks_base.pre_push, extras)
313 387
314 388 def post_push(self, extras):
315 389 log.debug("%sCalled post_push of %s object", self.log_prefix, self)
316 390 return self._call_hook(hooks_base.post_push, extras)
317 391
318 392 def _call_hook(self, hook, extras):
319 393 extras = AttributeDict(extras)
320 394 server_url = extras['server_url']
321 395
322 396 extras.request = self.request
323 397
324 398 try:
325 399 result = hook(extras)
326 400 if result is None:
327 401 raise Exception(
328 402 'Failed to obtain hook result from func: {}'.format(hook))
329 403 except HTTPBranchProtected as handled_error:
330 404 # Those special cases doesn't need error reporting. It's a case of
331 405 # locked repo or protected branch
332 406 result = AttributeDict({
333 407 'status': handled_error.code,
334 408 'output': handled_error.explanation
335 409 })
336 410 except (HTTPLockedRC, Exception) as error:
337 411 # locked needs different handling since we need to also
338 412 # handle PULL operations
339 413 exc_tb = ''
340 414 if not isinstance(error, HTTPLockedRC):
341 415 exc_tb = traceback.format_exc()
342 416 log.exception('%sException when handling hook %s', self.log_prefix, hook)
343 417 error_args = error.args
344 418 return {
345 419 'status': 128,
346 420 'output': '',
347 421 'exception': type(error).__name__,
348 422 'exception_traceback': exc_tb,
349 423 'exception_args': error_args,
350 424 }
351 425 finally:
352 426 meta.Session.remove()
353 427
354 428 log.debug('%sGot hook call response %s', self.log_prefix, result)
355 429 return {
356 430 'status': result.status,
357 431 'output': result.output,
358 432 }
359 433
360 434 def __enter__(self):
361 435 return self
362 436
363 437 def __exit__(self, exc_type, exc_val, exc_tb):
364 438 pass
General Comments 0
You need to be logged in to leave comments. Login now