# HG changeset patch # User Georges Racinet <georges.racinet@octobus.net> # Date 1587332315 -7200 # Sun Apr 19 23:38:35 2020 +0200 # Node ID 844f9ef3e33e71a69a30f2f19328902ee8ecc715 # Parent d810ba97c1c8693a9dba3631b86b99df9c08f47f GitLab hooks: direct REST API call for pre-receive hook diff --git a/heptapod/gitlab/__init__.py b/heptapod/gitlab/__init__.py --- a/heptapod/gitlab/__init__.py +++ b/heptapod/gitlab/__init__.py @@ -6,11 +6,43 @@ # SPDX-License-Identifier: GPL-2.0-or-later import logging import os +import re import requests import requests_unixsocket logger = logging.getLogger(__name__) +ACTOR_RX_SYMBS = ( + (re.compile(r'key-(\d+)$'), 'key_id'), + (re.compile(r'user-(\d+)$'), 'user_id'), + (re.compile(r'username-(.*)$'), 'username'), +) + +ALLOWED_API_KNOWN_STATUS_CODES = ( + requests.codes.ok, + requests.codes.multiple_choices, + requests.codes.unauthorized, + requests.codes.not_found, + requests.codes.service_unavailable, +) + + +class InvalidActor(ValueError): + pass + + +def parse_actor(actor): + for regexp, symbol in ACTOR_RX_SYMBS: + m = regexp.match(actor) + if m is not None: + return (symbol, m.group(1)) + raise InvalidActor(actor) + + +def sanitize_path(path): + # translated from gitlab-shell/lib/gitlab_net.rb + return path.replace("'", "") + def git_hook_format_changes(changes): """Format Git changes in the way Git hooks expect them in `stdin`. @@ -108,6 +140,7 @@ _hook_methods = { 'post-receive': 'post_receive', + 'pre-receive': 'pre_receive', } def __init__(self, name, repo): @@ -211,6 +244,43 @@ out_lines.extend(format_warnings(warnings)) return '\n'.join(out_lines) + def pre_receive_params(self, changes, env=None): + if env is None: + env = self.environ() + params = dict(action='git-receive-pack', + changes=git_hook_format_changes(changes), + gl_repository=env['GL_REPOSITORY'], + project=sanitize_path(self.repo.root), + # TODO use a dedicated 'hg-git' protocol, and also + # internally + protocol=env['GL_PROTOCOL'], + ) + actor_symb, actor = parse_actor(env['GL_ID']) + params[actor_symb] = actor + return params + + def pre_receive(self, changes, env=None): + """Call GitLab internal API 'allowed' endpoint used for pre-receive + """ + params = self.pre_receive_params(changes, env=env) + resp = self.gitlab_internal_api_request('POST', + subpath='allowed', + params=params) + if resp.status_code not in ALLOWED_API_KNOWN_STATUS_CODES: + return False, '', "API is not accessible" + + as_json = resp.json() + logger.debug("pre_receive for changes %r, response: %r", + changes, as_json) + ok, message = as_json['status'], as_json.get('message') + out = err = '' + if ok: + if message: + out = message + else: + err = message + return ok, out, err + def __call__(self, changes): """Call GitLab Hook for the given changes. diff --git a/heptapod/gitlab/tests/test_hooks.py b/heptapod/gitlab/tests/test_hooks.py --- a/heptapod/gitlab/tests/test_hooks.py +++ b/heptapod/gitlab/tests/test_hooks.py @@ -18,12 +18,31 @@ format_mr_links, git_hook_format_changes, parse_broadcast_msg, + parse_actor, Hook, + InvalidActor, ) ZERO_SHA = "0" * 20 +def test_parse_actor(): + assert parse_actor("key-1") == ('key_id', '1') + assert parse_actor("key-17") == ('key_id', '17') + assert parse_actor("user-3") == ('user_id', '3') + assert parse_actor("user-35") == ('user_id', '35') + assert parse_actor("username-testgr") == ('username', 'testgr') + with pytest.raises(InvalidActor) as exc_info: + parse_actor('key-1x') + assert exc_info.value.args[0] == 'key-1x' + with pytest.raises(InvalidActor) as exc_info: + parse_actor('user-1a') + assert exc_info.value.args[0] == 'user-1a' + with pytest.raises(InvalidActor) as exc_info: + parse_actor('imaginary-12') + assert exc_info.value.args[0] == 'imaginary-12' + + def test_git_hook_format_changes(): changes = {'branch/newbr': (ZERO_SHA, "beef1234dead" + "a" * 8), 'topic/default/zz': ("23cafe45" + "0" * 12, "1234" + "b" * 16), @@ -158,6 +177,29 @@ assert params['gl_repository'] == 'project-1024' +def test_pre_receive_params(tmpdir): + wrapper = make_repo(tmpdir, 'proj.hg') + repo = wrapper.repo + hook = Hook('post-receive', repo) + + repo.ui.environ.update( + HEPTAPOD_USERINFO_ID='2', + HEPTAPOD_PROJECT_ID='3145926', + ) + changes = { + 'topic/default/with-a-dash': (ZERO_SHA, "beef1234dead" + "a" * 8), + 'branch/default': ("23cafe45" + "0" * 12, "1234" + "b" * 16), + } + params = hook.pre_receive_params(changes) + + assert params['user_id'] == '2' + assert params['changes'].splitlines() == [ + '23cafe45000000000000 1234bbbbbbbbbbbbbbbb branch/default', + '00000000000000000000 beef1234deadaaaaaaaa topic/default/with-a-dash'] + assert params['gl_repository'] == 'project-3145926' + assert params['project'] == repo.root + + def test_post_receive_handle_response(tmpdir): wrapper = make_repo(tmpdir, 'proj.hg') repo = wrapper.repo @@ -256,6 +298,54 @@ 'merge_requests/1'] +def test_integration_pre_receive(tmpdir, monkeypatch): + records = [] + responses = [make_json_response({u'message': "yes, granted!", + u'status': True, + }, + status_code=200), + make_json_response({u'status': True}, + status_code=200), + make_json_response({u'message': "go away!", + u'status': False, + }, + status_code=401), + make_response(504, b"Bad gateway :-("), + ] + monkeypatch.setattr(Hook, 'gitlab_internal_api_request', + request_recorder(records, responses)) + + repo_wrapper = make_repo(tmpdir, 'repo.hg') + repo = repo_wrapper.repo + + repo.ui.environ.update( + HEPTAPOD_USERINFO_ID='23', + HEPTAPOD_PROJECT_ID='1024', + ) + + hook = Hook('pre-receive', repo_wrapper.repo) + changes = { + 'branch/newbr': (ZERO_SHA, "beef1234dead" + "a" * 8), + 'topic/default/zz': ("23cafe45" + "0" * 12, "1234" + "b" * 16), + } + + return_code, out, err = hook(changes) + assert return_code == 0 + assert out == 'yes, granted!' + + return_code, out, err = hook(changes) + assert return_code == 0 + assert out == '' + + return_code, out, err = hook(changes) + assert return_code == 1 + assert err == 'go away!' + + return_code, out, err = hook(changes) + assert return_code == 1 + assert err == 'API is not accessible' + + def test_empty_changes(tmpdir): hook = Hook('post-receive', make_repo(tmpdir, 'repo.hg').repo) code, out, err = hook(())