##// END OF EJS Templates
core: various cleanups and fixes
super-admin -
r1118:c35e966b python3
parent child Browse files
Show More
@@ -0,0 +1,27 b''
1 # Copyright (C) 2010-2020 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
19 HOOK_REPO_SIZE = 'changegroup.repo_size'
20
21 # HG
22 HOOK_PRE_PULL = 'preoutgoing.pre_pull'
23 HOOK_PULL = 'outgoing.pull_logger'
24 HOOK_PRE_PUSH = 'prechangegroup.pre_push'
25 HOOK_PRETX_PUSH = 'pretxnchangegroup.pre_push'
26 HOOK_PUSH = 'changegroup.push_logger'
27 HOOK_PUSH_KEY = 'pushkey.key_push'
@@ -1,170 +1,168 b''
1 # -*- coding: utf-8 -*-
2
3 1 # Copyright (C) 2010-2020 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import os
22 20 import textwrap
23 21 import string
24 22 import functools
25 23 import logging
26 24 import tempfile
27 25 import logging.config
28 26
29 27 from vcsserver.type_utils import str2bool, aslist
30 28
31 29 log = logging.getLogger(__name__)
32 30
33 31 # skip keys, that are set here, so we don't double process those
34 32 set_keys = {
35 33 '__file__': ''
36 34 }
37 35
38 36
39 37 class SettingsMaker(object):
40 38
41 39 def __init__(self, app_settings):
42 40 self.settings = app_settings
43 41
44 42 @classmethod
45 43 def _bool_func(cls, input_val):
46 44 if isinstance(input_val, bytes):
47 45 # decode to str
48 46 input_val = input_val.decode('utf8')
49 47 return str2bool(input_val)
50 48
51 49 @classmethod
52 50 def _int_func(cls, input_val):
53 51 return int(input_val)
54 52
55 53 @classmethod
56 54 def _list_func(cls, input_val, sep=','):
57 55 return aslist(input_val, sep=sep)
58 56
59 57 @classmethod
60 58 def _string_func(cls, input_val, lower=True):
61 59 if lower:
62 60 input_val = input_val.lower()
63 61 return input_val
64 62
65 63 @classmethod
66 64 def _float_func(cls, input_val):
67 65 return float(input_val)
68 66
69 67 @classmethod
70 68 def _dir_func(cls, input_val, ensure_dir=False, mode=0o755):
71 69
72 70 # ensure we have our dir created
73 71 if not os.path.isdir(input_val) and ensure_dir:
74 os.makedirs(input_val, mode=mode)
72 os.makedirs(input_val, mode=mode, exist_ok=True)
75 73
76 74 if not os.path.isdir(input_val):
77 75 raise Exception('Dir at {} does not exist'.format(input_val))
78 76 return input_val
79 77
80 78 @classmethod
81 79 def _file_path_func(cls, input_val, ensure_dir=False, mode=0o755):
82 80 dirname = os.path.dirname(input_val)
83 81 cls._dir_func(dirname, ensure_dir=ensure_dir)
84 82 return input_val
85 83
86 84 @classmethod
87 85 def _key_transformator(cls, key):
88 86 return "{}_{}".format('RC'.upper(), key.upper().replace('.', '_').replace('-', '_'))
89 87
90 88 def maybe_env_key(self, key):
91 89 # now maybe we have this KEY in env, search and use the value with higher priority.
92 90 transformed_key = self._key_transformator(key)
93 91 envvar_value = os.environ.get(transformed_key)
94 92 if envvar_value:
95 93 log.debug('using `%s` key instead of `%s` key for config', transformed_key, key)
96 94
97 95 return envvar_value
98 96
99 97 def env_expand(self):
100 98 replaced = {}
101 99 for k, v in self.settings.items():
102 100 if k not in set_keys:
103 101 envvar_value = self.maybe_env_key(k)
104 102 if envvar_value:
105 103 replaced[k] = envvar_value
106 104 set_keys[k] = envvar_value
107 105
108 106 # replace ALL keys updated
109 107 self.settings.update(replaced)
110 108
111 109 def enable_logging(self, logging_conf=None, level='INFO', formatter='generic'):
112 110 """
113 111 Helper to enable debug on running instance
114 112 :return:
115 113 """
116 114
117 115 if not str2bool(self.settings.get('logging.autoconfigure')):
118 116 log.info('logging configuration based on main .ini file')
119 117 return
120 118
121 119 if logging_conf is None:
122 120 logging_conf = self.settings.get('logging.logging_conf_file') or ''
123 121
124 122 if not os.path.isfile(logging_conf):
125 123 log.error('Unable to setup logging based on %s, '
126 124 'file does not exist.... specify path using logging.logging_conf_file= config setting. ', logging_conf)
127 125 return
128 126
129 127 with open(logging_conf, 'rt') as f:
130 128 ini_template = textwrap.dedent(f.read())
131 129 ini_template = string.Template(ini_template).safe_substitute(
132 130 RC_LOGGING_LEVEL=os.environ.get('RC_LOGGING_LEVEL', '') or level,
133 131 RC_LOGGING_FORMATTER=os.environ.get('RC_LOGGING_FORMATTER', '') or formatter
134 132 )
135 133
136 134 with tempfile.NamedTemporaryFile(prefix='rc_logging_', suffix='.ini', delete=False) as f:
137 135 log.info('Saved Temporary LOGGING config at %s', f.name)
138 136 f.write(ini_template)
139 137
140 138 logging.config.fileConfig(f.name)
141 139 os.remove(f.name)
142 140
143 141 def make_setting(self, key, default, lower=False, default_when_empty=False, parser=None):
144 142 input_val = self.settings.get(key, default)
145 143
146 144 if default_when_empty and not input_val:
147 145 # use default value when value is set in the config but it is empty
148 146 input_val = default
149 147
150 148 parser_func = {
151 149 'bool': self._bool_func,
152 150 'int': self._int_func,
153 151 'list': self._list_func,
154 152 'list:newline': functools.partial(self._list_func, sep='/n'),
155 153 'list:spacesep': functools.partial(self._list_func, sep=' '),
156 154 'string': functools.partial(self._string_func, lower=lower),
157 155 'dir': self._dir_func,
158 156 'dir:ensured': functools.partial(self._dir_func, ensure_dir=True),
159 157 'file': self._file_path_func,
160 158 'file:ensured': functools.partial(self._file_path_func, ensure_dir=True),
161 159 None: lambda i: i
162 160 }[parser]
163 161
164 162 envvar_value = self.maybe_env_key(key)
165 163 if envvar_value:
166 164 input_val = envvar_value
167 165 set_keys[key] = input_val
168 166
169 167 self.settings[key] = parser_func(input_val)
170 168 return self.settings[key]
@@ -1,204 +1,202 b''
1 # -*- coding: utf-8 -*-
2
3 1 # RhodeCode VCSServer provides access to different vcs backends via network.
4 2 # Copyright (C) 2014-2020 RhodeCode GmbH
5 3 #
6 4 # This program is free software; you can redistribute it and/or modify
7 5 # it under the terms of the GNU General Public License as published by
8 6 # the Free Software Foundation; either version 3 of the License, or
9 7 # (at your option) any later version.
10 8 #
11 9 # This program is distributed in the hope that it will be useful,
12 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 12 # GNU General Public License for more details.
15 13 #
16 14 # You should have received a copy of the GNU General Public License
17 15 # along with this program; if not, write to the Free Software Foundation,
18 16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
19 17
20 18 import re
21 19 import os
22 20 import sys
23 21 import datetime
24 22 import logging
25 23 import pkg_resources
26 24
27 25 import vcsserver
28 26 from vcsserver.str_utils import safe_bytes
29 27
30 28 log = logging.getLogger(__name__)
31 29
32 30
33 31 def get_git_hooks_path(repo_path, bare):
34 32 hooks_path = os.path.join(repo_path, 'hooks')
35 33 if not bare:
36 34 hooks_path = os.path.join(repo_path, '.git', 'hooks')
37 35
38 36 return hooks_path
39 37
40 38
41 39 def install_git_hooks(repo_path, bare, executable=None, force_create=False):
42 40 """
43 41 Creates a RhodeCode hook inside a git repository
44 42
45 43 :param repo_path: path to repository
46 44 :param executable: binary executable to put in the hooks
47 45 :param force_create: Create even if same name hook exists
48 46 """
49 47 executable = executable or sys.executable
50 48 hooks_path = get_git_hooks_path(repo_path, bare)
51 49
52 50 if not os.path.isdir(hooks_path):
53 51 os.makedirs(hooks_path, mode=0o777)
54 52
55 53 tmpl_post = pkg_resources.resource_string(
56 54 'vcsserver', '/'.join(
57 55 ('hook_utils', 'hook_templates', 'git_post_receive.py.tmpl')))
58 56 tmpl_pre = pkg_resources.resource_string(
59 57 'vcsserver', '/'.join(
60 58 ('hook_utils', 'hook_templates', 'git_pre_receive.py.tmpl')))
61 59
62 60 path = '' # not used for now
63 61 timestamp = datetime.datetime.utcnow().isoformat()
64 62
65 63 for h_type, template in [('pre', tmpl_pre), ('post', tmpl_post)]:
66 64 log.debug('Installing git hook in repo %s', repo_path)
67 65 _hook_file = os.path.join(hooks_path, '%s-receive' % h_type)
68 66 _rhodecode_hook = check_rhodecode_hook(_hook_file)
69 67
70 68 if _rhodecode_hook or force_create:
71 69 log.debug('writing git %s hook file at %s !', h_type, _hook_file)
72 70 try:
73 71 with open(_hook_file, 'wb') as f:
74 72 template = template.replace(b'_TMPL_', safe_bytes(vcsserver.__version__))
75 73 template = template.replace(b'_DATE_', safe_bytes(timestamp))
76 74 template = template.replace(b'_ENV_', safe_bytes(executable))
77 75 template = template.replace(b'_PATH_', safe_bytes(path))
78 76 f.write(template)
79 77 os.chmod(_hook_file, 0o755)
80 78 except IOError:
81 79 log.exception('error writing hook file %s', _hook_file)
82 80 else:
83 81 log.debug('skipping writing hook file')
84 82
85 83 return True
86 84
87 85
88 86 def get_svn_hooks_path(repo_path):
89 87 hooks_path = os.path.join(repo_path, 'hooks')
90 88
91 89 return hooks_path
92 90
93 91
94 92 def install_svn_hooks(repo_path, executable=None, force_create=False):
95 93 """
96 94 Creates RhodeCode hooks inside a svn repository
97 95
98 96 :param repo_path: path to repository
99 97 :param executable: binary executable to put in the hooks
100 98 :param force_create: Create even if same name hook exists
101 99 """
102 100 executable = executable or sys.executable
103 101 hooks_path = get_svn_hooks_path(repo_path)
104 102 if not os.path.isdir(hooks_path):
105 103 os.makedirs(hooks_path, mode=0o777)
106 104
107 105 tmpl_post = pkg_resources.resource_string(
108 106 'vcsserver', '/'.join(
109 107 ('hook_utils', 'hook_templates', 'svn_post_commit_hook.py.tmpl')))
110 108 tmpl_pre = pkg_resources.resource_string(
111 109 'vcsserver', '/'.join(
112 110 ('hook_utils', 'hook_templates', 'svn_pre_commit_hook.py.tmpl')))
113 111
114 112 path = '' # not used for now
115 113 timestamp = datetime.datetime.utcnow().isoformat()
116 114
117 115 for h_type, template in [('pre', tmpl_pre), ('post', tmpl_post)]:
118 116 log.debug('Installing svn hook in repo %s', repo_path)
119 117 _hook_file = os.path.join(hooks_path, '%s-commit' % h_type)
120 118 _rhodecode_hook = check_rhodecode_hook(_hook_file)
121 119
122 120 if _rhodecode_hook or force_create:
123 121 log.debug('writing svn %s hook file at %s !', h_type, _hook_file)
124 122
125 123 try:
126 124 with open(_hook_file, 'wb') as f:
127 125 template = template.replace(b'_TMPL_', safe_bytes(vcsserver.__version__))
128 126 template = template.replace(b'_DATE_', safe_bytes(timestamp))
129 127 template = template.replace(b'_ENV_', safe_bytes(executable))
130 128 template = template.replace(b'_PATH_', safe_bytes(path))
131 129
132 130 f.write(template)
133 131 os.chmod(_hook_file, 0o755)
134 132 except IOError:
135 133 log.exception('error writing hook file %s', _hook_file)
136 134 else:
137 135 log.debug('skipping writing hook file')
138 136
139 137 return True
140 138
141 139
142 140 def get_version_from_hook(hook_path):
143 141 version = b''
144 142 hook_content = read_hook_content(hook_path)
145 143 matches = re.search(rb'(?:RC_HOOK_VER)\s*=\s*(.*)', hook_content)
146 144 if matches:
147 145 try:
148 146 version = matches.groups()[0]
149 147 log.debug('got version %s from hooks.', version)
150 148 except Exception:
151 149 log.exception("Exception while reading the hook version.")
152 150 return version.replace(b"'", b"")
153 151
154 152
155 153 def check_rhodecode_hook(hook_path):
156 154 """
157 155 Check if the hook was created by RhodeCode
158 156 """
159 157 if not os.path.exists(hook_path):
160 158 return True
161 159
162 160 log.debug('hook exists, checking if it is from RhodeCode')
163 161
164 162 version = get_version_from_hook(hook_path)
165 163 if version:
166 164 return True
167 165
168 166 return False
169 167
170 168
171 169 def read_hook_content(hook_path) -> bytes:
172 170 content = b''
173 171 if os.path.isfile(hook_path):
174 172 with open(hook_path, 'rb') as f:
175 173 content = f.read()
176 174 return content
177 175
178 176
179 177 def get_git_pre_hook_version(repo_path, bare):
180 178 hooks_path = get_git_hooks_path(repo_path, bare)
181 179 _hook_file = os.path.join(hooks_path, 'pre-receive')
182 180 version = get_version_from_hook(_hook_file)
183 181 return version
184 182
185 183
186 184 def get_git_post_hook_version(repo_path, bare):
187 185 hooks_path = get_git_hooks_path(repo_path, bare)
188 186 _hook_file = os.path.join(hooks_path, 'post-receive')
189 187 version = get_version_from_hook(_hook_file)
190 188 return version
191 189
192 190
193 191 def get_svn_pre_hook_version(repo_path):
194 192 hooks_path = get_svn_hooks_path(repo_path)
195 193 _hook_file = os.path.join(hooks_path, 'pre-commit')
196 194 version = get_version_from_hook(_hook_file)
197 195 return version
198 196
199 197
200 198 def get_svn_post_hook_version(repo_path):
201 199 hooks_path = get_svn_hooks_path(repo_path)
202 200 _hook_file = os.path.join(hooks_path, 'post-commit')
203 201 version = get_version_from_hook(_hook_file)
204 202 return version
@@ -1,777 +1,779 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-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 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 importlib
24 24 import base64
25 25 import msgpack
26 26 import dataclasses
27 27 import pygit2
28 28
29 29 import http.client
30 30
31 31
32 32 import mercurial.scmutil
33 33 import mercurial.node
34 34
35 35 from vcsserver.lib.rc_json import json
36 36 from vcsserver import exceptions, subprocessio, settings
37 37 from vcsserver.str_utils import ascii_str, safe_str
38 38 from vcsserver.remote.git import Repository
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 class HooksHttpClient(object):
44 44 proto = 'msgpack.v1'
45 45 connection = None
46 46
47 47 def __init__(self, hooks_uri):
48 48 self.hooks_uri = hooks_uri
49 49
50 50 def __repr__(self):
51 51 return f'{self.__class__}(hook_uri={self.hooks_uri}, proto={self.proto})'
52 52
53 53 def __call__(self, method, extras):
54 54 connection = http.client.HTTPConnection(self.hooks_uri)
55 55 # binary msgpack body
56 56 headers, body = self._serialize(method, extras)
57 57 log.debug('Doing a new hooks call using HTTPConnection to %s', self.hooks_uri)
58 58
59 59 try:
60 60 try:
61 61 connection.request('POST', '/', body, headers)
62 62 except Exception as error:
63 63 log.error('Hooks calling Connection failed on %s, org error: %s', connection.__dict__, error)
64 64 raise
65 65
66 66 response = connection.getresponse()
67 67 try:
68 68 return msgpack.load(response)
69 69 except Exception:
70 70 response_data = response.read()
71 71 log.exception('Failed to decode hook response json data. '
72 72 'response_code:%s, raw_data:%s',
73 73 response.status, response_data)
74 74 raise
75 75 finally:
76 76 connection.close()
77 77
78 78 @classmethod
79 79 def _serialize(cls, hook_name, extras):
80 80 data = {
81 81 'method': hook_name,
82 82 'extras': extras
83 83 }
84 84 headers = {
85 85 "rc-hooks-protocol": cls.proto,
86 86 "Connection": "keep-alive"
87 87 }
88 88 return headers, msgpack.packb(data)
89 89
90 90
91 91 class HooksDummyClient(object):
92 92 def __init__(self, hooks_module):
93 93 self._hooks_module = importlib.import_module(hooks_module)
94 94
95 95 def __call__(self, hook_name, extras):
96 96 with self._hooks_module.Hooks() as hooks:
97 97 return getattr(hooks, hook_name)(extras)
98 98
99 99
100 100 class HooksShadowRepoClient(object):
101 101
102 102 def __call__(self, hook_name, extras):
103 103 return {'output': '', 'status': 0}
104 104
105 105
106 106 class RemoteMessageWriter(object):
107 107 """Writer base class."""
108 108 def write(self, message):
109 109 raise NotImplementedError()
110 110
111 111
112 112 class HgMessageWriter(RemoteMessageWriter):
113 113 """Writer that knows how to send messages to mercurial clients."""
114 114
115 115 def __init__(self, ui):
116 116 self.ui = ui
117 117
118 118 def write(self, message: str):
119 119 # TODO: Check why the quiet flag is set by default.
120 120 old = self.ui.quiet
121 121 self.ui.quiet = False
122 122 self.ui.status(message.encode('utf-8'))
123 123 self.ui.quiet = old
124 124
125 125
126 126 class GitMessageWriter(RemoteMessageWriter):
127 127 """Writer that knows how to send messages to git clients."""
128 128
129 129 def __init__(self, stdout=None):
130 130 self.stdout = stdout or sys.stdout
131 131
132 132 def write(self, message: str):
133 133 self.stdout.write(message)
134 134
135 135
136 136 class SvnMessageWriter(RemoteMessageWriter):
137 137 """Writer that knows how to send messages to svn clients."""
138 138
139 139 def __init__(self, stderr=None):
140 140 # SVN needs data sent to stderr for back-to-client messaging
141 141 self.stderr = stderr or sys.stderr
142 142
143 143 def write(self, message):
144 144 self.stderr.write(message.encode('utf-8'))
145 145
146 146
147 147 def _handle_exception(result):
148 148 exception_class = result.get('exception')
149 149 exception_traceback = result.get('exception_traceback')
150 log.debug('Handling hook-call exception: %s', exception_class)
150 151
151 152 if exception_traceback:
152 153 log.error('Got traceback from remote call:%s', exception_traceback)
153 154
154 155 if exception_class == 'HTTPLockedRC':
155 156 raise exceptions.RepositoryLockedException()(*result['exception_args'])
156 157 elif exception_class == 'HTTPBranchProtected':
157 158 raise exceptions.RepositoryBranchProtectedException()(*result['exception_args'])
158 159 elif exception_class == 'RepositoryError':
159 160 raise exceptions.VcsException()(*result['exception_args'])
160 161 elif exception_class:
161 162 raise Exception(
162 163 f"""Got remote exception "{exception_class}" with args "{result['exception_args']}" """
163 164 )
164 165
165 166
166 167 def _get_hooks_client(extras):
167 168 hooks_uri = extras.get('hooks_uri')
168 169 is_shadow_repo = extras.get('is_shadow_repo')
170
169 171 if hooks_uri:
170 172 return HooksHttpClient(extras['hooks_uri'])
171 173 elif is_shadow_repo:
172 174 return HooksShadowRepoClient()
173 175 else:
174 176 return HooksDummyClient(extras['hooks_module'])
175 177
176 178
177 179 def _call_hook(hook_name, extras, writer):
178 180 hooks_client = _get_hooks_client(extras)
179 181 log.debug('Hooks, using client:%s', hooks_client)
180 182 result = hooks_client(hook_name, extras)
181 183 log.debug('Hooks got result: %s', result)
182 184 _handle_exception(result)
183 185 writer.write(result['output'])
184 186
185 187 return result['status']
186 188
187 189
188 190 def _extras_from_ui(ui):
189 191 hook_data = ui.config(b'rhodecode', b'RC_SCM_DATA')
190 192 if not hook_data:
191 193 # maybe it's inside environ ?
192 194 env_hook_data = os.environ.get('RC_SCM_DATA')
193 195 if env_hook_data:
194 196 hook_data = env_hook_data
195 197
196 198 extras = {}
197 199 if hook_data:
198 200 extras = json.loads(hook_data)
199 201 return extras
200 202
201 203
202 204 def _rev_range_hash(repo, node, check_heads=False):
203 205 from vcsserver.hgcompat import get_ctx
204 206
205 207 commits = []
206 208 revs = []
207 209 start = get_ctx(repo, node).rev()
208 210 end = len(repo)
209 211 for rev in range(start, end):
210 212 revs.append(rev)
211 213 ctx = get_ctx(repo, rev)
212 214 commit_id = ascii_str(mercurial.node.hex(ctx.node()))
213 215 branch = safe_str(ctx.branch())
214 216 commits.append((commit_id, branch))
215 217
216 218 parent_heads = []
217 219 if check_heads:
218 220 parent_heads = _check_heads(repo, start, end, revs)
219 221 return commits, parent_heads
220 222
221 223
222 224 def _check_heads(repo, start, end, commits):
223 225 from vcsserver.hgcompat import get_ctx
224 226 changelog = repo.changelog
225 227 parents = set()
226 228
227 229 for new_rev in commits:
228 230 for p in changelog.parentrevs(new_rev):
229 231 if p == mercurial.node.nullrev:
230 232 continue
231 233 if p < start:
232 234 parents.add(p)
233 235
234 236 for p in parents:
235 237 branch = get_ctx(repo, p).branch()
236 238 # The heads descending from that parent, on the same branch
237 239 parent_heads = {p}
238 240 reachable = {p}
239 241 for x in range(p + 1, end):
240 242 if get_ctx(repo, x).branch() != branch:
241 243 continue
242 244 for pp in changelog.parentrevs(x):
243 245 if pp in reachable:
244 246 reachable.add(x)
245 247 parent_heads.discard(pp)
246 248 parent_heads.add(x)
247 249 # More than one head? Suggest merging
248 250 if len(parent_heads) > 1:
249 251 return list(parent_heads)
250 252
251 253 return []
252 254
253 255
254 256 def _get_git_env():
255 257 env = {}
256 258 for k, v in os.environ.items():
257 259 if k.startswith('GIT'):
258 260 env[k] = v
259 261
260 262 # serialized version
261 263 return [(k, v) for k, v in env.items()]
262 264
263 265
264 266 def _get_hg_env(old_rev, new_rev, txnid, repo_path):
265 267 env = {}
266 268 for k, v in os.environ.items():
267 269 if k.startswith('HG'):
268 270 env[k] = v
269 271
270 272 env['HG_NODE'] = old_rev
271 273 env['HG_NODE_LAST'] = new_rev
272 274 env['HG_TXNID'] = txnid
273 275 env['HG_PENDING'] = repo_path
274 276
275 277 return [(k, v) for k, v in env.items()]
276 278
277 279
278 280 def repo_size(ui, repo, **kwargs):
279 281 extras = _extras_from_ui(ui)
280 282 return _call_hook('repo_size', extras, HgMessageWriter(ui))
281 283
282 284
283 285 def pre_pull(ui, repo, **kwargs):
284 286 extras = _extras_from_ui(ui)
285 287 return _call_hook('pre_pull', extras, HgMessageWriter(ui))
286 288
287 289
288 290 def pre_pull_ssh(ui, repo, **kwargs):
289 291 extras = _extras_from_ui(ui)
290 292 if extras and extras.get('SSH'):
291 293 return pre_pull(ui, repo, **kwargs)
292 294 return 0
293 295
294 296
295 297 def post_pull(ui, repo, **kwargs):
296 298 extras = _extras_from_ui(ui)
297 299 return _call_hook('post_pull', extras, HgMessageWriter(ui))
298 300
299 301
300 302 def post_pull_ssh(ui, repo, **kwargs):
301 303 extras = _extras_from_ui(ui)
302 304 if extras and extras.get('SSH'):
303 305 return post_pull(ui, repo, **kwargs)
304 306 return 0
305 307
306 308
307 309 def pre_push(ui, repo, node=None, **kwargs):
308 310 """
309 311 Mercurial pre_push hook
310 312 """
311 313 extras = _extras_from_ui(ui)
312 314 detect_force_push = extras.get('detect_force_push')
313 315
314 316 rev_data = []
315 317 hook_type: str = safe_str(kwargs.get('hooktype'))
316 318
317 319 if node and hook_type == 'pretxnchangegroup':
318 320 branches = collections.defaultdict(list)
319 321 commits, _heads = _rev_range_hash(repo, node, check_heads=detect_force_push)
320 322 for commit_id, branch in commits:
321 323 branches[branch].append(commit_id)
322 324
323 325 for branch, commits in branches.items():
324 326 old_rev = ascii_str(kwargs.get('node_last')) or commits[0]
325 327 rev_data.append({
326 328 'total_commits': len(commits),
327 329 'old_rev': old_rev,
328 330 'new_rev': commits[-1],
329 331 'ref': '',
330 332 'type': 'branch',
331 333 'name': branch,
332 334 })
333 335
334 336 for push_ref in rev_data:
335 337 push_ref['multiple_heads'] = _heads
336 338
337 339 repo_path = os.path.join(
338 340 extras.get('repo_store', ''), extras.get('repository', ''))
339 341 push_ref['hg_env'] = _get_hg_env(
340 342 old_rev=push_ref['old_rev'],
341 343 new_rev=push_ref['new_rev'], txnid=ascii_str(kwargs.get('txnid')),
342 344 repo_path=repo_path)
343 345
344 346 extras['hook_type'] = hook_type or 'pre_push'
345 347 extras['commit_ids'] = rev_data
346 348
347 349 return _call_hook('pre_push', extras, HgMessageWriter(ui))
348 350
349 351
350 352 def pre_push_ssh(ui, repo, node=None, **kwargs):
351 353 extras = _extras_from_ui(ui)
352 354 if extras.get('SSH'):
353 355 return pre_push(ui, repo, node, **kwargs)
354 356
355 357 return 0
356 358
357 359
358 360 def pre_push_ssh_auth(ui, repo, node=None, **kwargs):
359 361 """
360 362 Mercurial pre_push hook for SSH
361 363 """
362 364 extras = _extras_from_ui(ui)
363 365 if extras.get('SSH'):
364 366 permission = extras['SSH_PERMISSIONS']
365 367
366 368 if 'repository.write' == permission or 'repository.admin' == permission:
367 369 return 0
368 370
369 371 # non-zero ret code
370 372 return 1
371 373
372 374 return 0
373 375
374 376
375 377 def post_push(ui, repo, node, **kwargs):
376 378 """
377 379 Mercurial post_push hook
378 380 """
379 381 extras = _extras_from_ui(ui)
380 382
381 383 commit_ids = []
382 384 branches = []
383 385 bookmarks = []
384 386 tags = []
385 387 hook_type: str = safe_str(kwargs.get('hooktype'))
386 388
387 389 commits, _heads = _rev_range_hash(repo, node)
388 390 for commit_id, branch in commits:
389 391 commit_ids.append(commit_id)
390 392 if branch not in branches:
391 393 branches.append(branch)
392 394
393 395 if hasattr(ui, '_rc_pushkey_bookmarks'):
394 396 bookmarks = ui._rc_pushkey_bookmarks
395 397
396 398 extras['hook_type'] = hook_type or 'post_push'
397 399 extras['commit_ids'] = commit_ids
398 400
399 401 extras['new_refs'] = {
400 402 'branches': branches,
401 403 'bookmarks': bookmarks,
402 404 'tags': tags
403 405 }
404 406
405 407 return _call_hook('post_push', extras, HgMessageWriter(ui))
406 408
407 409
408 410 def post_push_ssh(ui, repo, node, **kwargs):
409 411 """
410 412 Mercurial post_push hook for SSH
411 413 """
412 414 if _extras_from_ui(ui).get('SSH'):
413 415 return post_push(ui, repo, node, **kwargs)
414 416 return 0
415 417
416 418
417 419 def key_push(ui, repo, **kwargs):
418 420 from vcsserver.hgcompat import get_ctx
419 421
420 422 if kwargs['new'] != b'0' and kwargs['namespace'] == b'bookmarks':
421 423 # store new bookmarks in our UI object propagated later to post_push
422 424 ui._rc_pushkey_bookmarks = get_ctx(repo, kwargs['key']).bookmarks()
423 425 return
424 426
425 427
426 428 # backward compat
427 429 log_pull_action = post_pull
428 430
429 431 # backward compat
430 432 log_push_action = post_push
431 433
432 434
433 435 def handle_git_pre_receive(unused_repo_path, unused_revs, unused_env):
434 436 """
435 437 Old hook name: keep here for backward compatibility.
436 438
437 439 This is only required when the installed git hooks are not upgraded.
438 440 """
439 441 pass
440 442
441 443
442 444 def handle_git_post_receive(unused_repo_path, unused_revs, unused_env):
443 445 """
444 446 Old hook name: keep here for backward compatibility.
445 447
446 448 This is only required when the installed git hooks are not upgraded.
447 449 """
448 450 pass
449 451
450 452
451 453 @dataclasses.dataclass
452 454 class HookResponse:
453 455 status: int
454 456 output: str
455 457
456 458
457 459 def git_pre_pull(extras) -> HookResponse:
458 460 """
459 461 Pre pull hook.
460 462
461 463 :param extras: dictionary containing the keys defined in simplevcs
462 464 :type extras: dict
463 465
464 466 :return: status code of the hook. 0 for success.
465 467 :rtype: int
466 468 """
467 469
468 470 if 'pull' not in extras['hooks']:
469 471 return HookResponse(0, '')
470 472
471 473 stdout = io.StringIO()
472 474 try:
473 475 status_code = _call_hook('pre_pull', extras, GitMessageWriter(stdout))
474 476
475 477 except Exception as error:
476 478 log.exception('Failed to call pre_pull hook')
477 479 status_code = 128
478 480 stdout.write(f'ERROR: {error}\n')
479 481
480 482 return HookResponse(status_code, stdout.getvalue())
481 483
482 484
483 485 def git_post_pull(extras) -> HookResponse:
484 486 """
485 487 Post pull hook.
486 488
487 489 :param extras: dictionary containing the keys defined in simplevcs
488 490 :type extras: dict
489 491
490 492 :return: status code of the hook. 0 for success.
491 493 :rtype: int
492 494 """
493 495 if 'pull' not in extras['hooks']:
494 496 return HookResponse(0, '')
495 497
496 498 stdout = io.StringIO()
497 499 try:
498 500 status = _call_hook('post_pull', extras, GitMessageWriter(stdout))
499 501 except Exception as error:
500 502 status = 128
501 503 stdout.write(f'ERROR: {error}\n')
502 504
503 505 return HookResponse(status, stdout.getvalue())
504 506
505 507
506 508 def _parse_git_ref_lines(revision_lines):
507 509 rev_data = []
508 510 for revision_line in revision_lines or []:
509 511 old_rev, new_rev, ref = revision_line.strip().split(' ')
510 512 ref_data = ref.split('/', 2)
511 513 if ref_data[1] in ('tags', 'heads'):
512 514 rev_data.append({
513 515 # NOTE(marcink):
514 516 # we're unable to tell total_commits for git at this point
515 517 # but we set the variable for consistency with GIT
516 518 'total_commits': -1,
517 519 'old_rev': old_rev,
518 520 'new_rev': new_rev,
519 521 'ref': ref,
520 522 'type': ref_data[1],
521 523 'name': ref_data[2],
522 524 })
523 525 return rev_data
524 526
525 527
526 528 def git_pre_receive(unused_repo_path, revision_lines, env) -> int:
527 529 """
528 530 Pre push hook.
529 531
530 532 :return: status code of the hook. 0 for success.
531 533 """
532 534 extras = json.loads(env['RC_SCM_DATA'])
533 535 rev_data = _parse_git_ref_lines(revision_lines)
534 536 if 'push' not in extras['hooks']:
535 537 return 0
536 538 empty_commit_id = '0' * 40
537 539
538 540 detect_force_push = extras.get('detect_force_push')
539 541
540 542 for push_ref in rev_data:
541 543 # store our git-env which holds the temp store
542 544 push_ref['git_env'] = _get_git_env()
543 545 push_ref['pruned_sha'] = ''
544 546 if not detect_force_push:
545 547 # don't check for forced-push when we don't need to
546 548 continue
547 549
548 550 type_ = push_ref['type']
549 551 new_branch = push_ref['old_rev'] == empty_commit_id
550 552 delete_branch = push_ref['new_rev'] == empty_commit_id
551 553 if type_ == 'heads' and not (new_branch or delete_branch):
552 554 old_rev = push_ref['old_rev']
553 555 new_rev = push_ref['new_rev']
554 556 cmd = [settings.GIT_EXECUTABLE, 'rev-list', old_rev, f'^{new_rev}']
555 557 stdout, stderr = subprocessio.run_command(
556 558 cmd, env=os.environ.copy())
557 559 # means we're having some non-reachable objects, this forced push was used
558 560 if stdout:
559 561 push_ref['pruned_sha'] = stdout.splitlines()
560 562
561 563 extras['hook_type'] = 'pre_receive'
562 564 extras['commit_ids'] = rev_data
563 565
564 566 stdout = sys.stdout
565 567 status_code = _call_hook('pre_push', extras, GitMessageWriter(stdout))
566 568
567 569 return status_code
568 570
569 571
570 572 def git_post_receive(unused_repo_path, revision_lines, env) -> int:
571 573 """
572 574 Post push hook.
573 575
574 576 :return: status code of the hook. 0 for success.
575 577 """
576 578 extras = json.loads(env['RC_SCM_DATA'])
577 579 if 'push' not in extras['hooks']:
578 580 return 0
579 581
580 582 rev_data = _parse_git_ref_lines(revision_lines)
581 583
582 584 git_revs = []
583 585
584 586 # N.B.(skreft): it is ok to just call git, as git before calling a
585 587 # subcommand sets the PATH environment variable so that it point to the
586 588 # correct version of the git executable.
587 589 empty_commit_id = '0' * 40
588 590 branches = []
589 591 tags = []
590 592 for push_ref in rev_data:
591 593 type_ = push_ref['type']
592 594
593 595 if type_ == 'heads':
594 596 # starting new branch case
595 597 if push_ref['old_rev'] == empty_commit_id:
596 598 push_ref_name = push_ref['name']
597 599
598 600 if push_ref_name not in branches:
599 601 branches.append(push_ref_name)
600 602
601 603 need_head_set = ''
602 604 with Repository(os.getcwd()) as repo:
603 605 try:
604 606 repo.head
605 607 except pygit2.GitError:
606 608 need_head_set = f'refs/heads/{push_ref_name}'
607 609
608 610 if need_head_set:
609 611 repo.set_head(need_head_set)
610 612 print(f"Setting default branch to {push_ref_name}")
611 613
612 614 cmd = [settings.GIT_EXECUTABLE, 'for-each-ref', '--format=%(refname)', 'refs/heads/*']
613 615 stdout, stderr = subprocessio.run_command(
614 616 cmd, env=os.environ.copy())
615 617 heads = safe_str(stdout)
616 618 heads = heads.replace(push_ref['ref'], '')
617 619 heads = ' '.join(head for head
618 620 in heads.splitlines() if head) or '.'
619 621 cmd = [settings.GIT_EXECUTABLE, 'log', '--reverse',
620 622 '--pretty=format:%H', '--', push_ref['new_rev'],
621 623 '--not', heads]
622 624 stdout, stderr = subprocessio.run_command(
623 625 cmd, env=os.environ.copy())
624 626 git_revs.extend(list(map(ascii_str, stdout.splitlines())))
625 627
626 628 # delete branch case
627 629 elif push_ref['new_rev'] == empty_commit_id:
628 630 git_revs.append('delete_branch=>%s' % push_ref['name'])
629 631 else:
630 632 if push_ref['name'] not in branches:
631 633 branches.append(push_ref['name'])
632 634
633 635 cmd = [settings.GIT_EXECUTABLE, 'log',
634 636 '{old_rev}..{new_rev}'.format(**push_ref),
635 637 '--reverse', '--pretty=format:%H']
636 638 stdout, stderr = subprocessio.run_command(
637 639 cmd, env=os.environ.copy())
638 640 # we get bytes from stdout, we need str to be consistent
639 641 log_revs = list(map(ascii_str, stdout.splitlines()))
640 642 git_revs.extend(log_revs)
641 643
642 644 # Pure pygit2 impl. but still 2-3x slower :/
643 645 # results = []
644 646 #
645 647 # with Repository(os.getcwd()) as repo:
646 648 # repo_new_rev = repo[push_ref['new_rev']]
647 649 # repo_old_rev = repo[push_ref['old_rev']]
648 650 # walker = repo.walk(repo_new_rev.id, pygit2.GIT_SORT_TOPOLOGICAL)
649 651 #
650 652 # for commit in walker:
651 653 # if commit.id == repo_old_rev.id:
652 654 # break
653 655 # results.append(commit.id.hex)
654 656 # # reverse the order, can't use GIT_SORT_REVERSE
655 657 # log_revs = results[::-1]
656 658
657 659 elif type_ == 'tags':
658 660 if push_ref['name'] not in tags:
659 661 tags.append(push_ref['name'])
660 662 git_revs.append('tag=>%s' % push_ref['name'])
661 663
662 664 extras['hook_type'] = 'post_receive'
663 665 extras['commit_ids'] = git_revs
664 666 extras['new_refs'] = {
665 667 'branches': branches,
666 668 'bookmarks': [],
667 669 'tags': tags,
668 670 }
669 671
670 672 stdout = sys.stdout
671 673
672 674 if 'repo_size' in extras['hooks']:
673 675 try:
674 676 _call_hook('repo_size', extras, GitMessageWriter(stdout))
675 677 except Exception:
676 678 pass
677 679
678 680 status_code = _call_hook('post_push', extras, GitMessageWriter(stdout))
679 681 return status_code
680 682
681 683
682 684 def _get_extras_from_txn_id(path, txn_id):
683 685 extras = {}
684 686 try:
685 687 cmd = [settings.SVNLOOK_EXECUTABLE, 'pget',
686 688 '-t', txn_id,
687 689 '--revprop', path, 'rc-scm-extras']
688 690 stdout, stderr = subprocessio.run_command(
689 691 cmd, env=os.environ.copy())
690 692 extras = json.loads(base64.urlsafe_b64decode(stdout))
691 693 except Exception:
692 694 log.exception('Failed to extract extras info from txn_id')
693 695
694 696 return extras
695 697
696 698
697 699 def _get_extras_from_commit_id(commit_id, path):
698 700 extras = {}
699 701 try:
700 702 cmd = [settings.SVNLOOK_EXECUTABLE, 'pget',
701 703 '-r', commit_id,
702 704 '--revprop', path, 'rc-scm-extras']
703 705 stdout, stderr = subprocessio.run_command(
704 706 cmd, env=os.environ.copy())
705 707 extras = json.loads(base64.urlsafe_b64decode(stdout))
706 708 except Exception:
707 709 log.exception('Failed to extract extras info from commit_id')
708 710
709 711 return extras
710 712
711 713
712 714 def svn_pre_commit(repo_path, commit_data, env):
713 715 path, txn_id = commit_data
714 716 branches = []
715 717 tags = []
716 718
717 719 if env.get('RC_SCM_DATA'):
718 720 extras = json.loads(env['RC_SCM_DATA'])
719 721 else:
720 722 # fallback method to read from TXN-ID stored data
721 723 extras = _get_extras_from_txn_id(path, txn_id)
722 724 if not extras:
723 725 return 0
724 726
725 727 extras['hook_type'] = 'pre_commit'
726 728 extras['commit_ids'] = [txn_id]
727 729 extras['txn_id'] = txn_id
728 730 extras['new_refs'] = {
729 731 'total_commits': 1,
730 732 'branches': branches,
731 733 'bookmarks': [],
732 734 'tags': tags,
733 735 }
734 736
735 737 return _call_hook('pre_push', extras, SvnMessageWriter())
736 738
737 739
738 740 def svn_post_commit(repo_path, commit_data, env):
739 741 """
740 742 commit_data is path, rev, txn_id
741 743 """
742 744 if len(commit_data) == 3:
743 745 path, commit_id, txn_id = commit_data
744 746 elif len(commit_data) == 2:
745 747 log.error('Failed to extract txn_id from commit_data using legacy method. '
746 748 'Some functionality might be limited')
747 749 path, commit_id = commit_data
748 750 txn_id = None
749 751
750 752 branches = []
751 753 tags = []
752 754
753 755 if env.get('RC_SCM_DATA'):
754 756 extras = json.loads(env['RC_SCM_DATA'])
755 757 else:
756 758 # fallback method to read from TXN-ID stored data
757 759 extras = _get_extras_from_commit_id(commit_id, path)
758 760 if not extras:
759 761 return 0
760 762
761 763 extras['hook_type'] = 'post_commit'
762 764 extras['commit_ids'] = [commit_id]
763 765 extras['txn_id'] = txn_id
764 766 extras['new_refs'] = {
765 767 'branches': branches,
766 768 'bookmarks': [],
767 769 'tags': tags,
768 770 'total_commits': 1,
769 771 }
770 772
771 773 if 'repo_size' in extras['hooks']:
772 774 try:
773 775 _call_hook('repo_size', extras, SvnMessageWriter())
774 776 except Exception:
775 777 pass
776 778
777 779 return _call_hook('post_push', extras, SvnMessageWriter())
@@ -1,414 +1,417 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-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 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 """Handles the Git smart protocol."""
19 19
20 20 import os
21 21 import socket
22 22 import logging
23 23
24 24 import dulwich.protocol
25 25 from dulwich.protocol import CAPABILITY_SIDE_BAND, CAPABILITY_SIDE_BAND_64K
26 26 from webob import Request, Response, exc
27 27
28 28 from vcsserver.lib.rc_json import json
29 29 from vcsserver import hooks, subprocessio
30 30 from vcsserver.str_utils import ascii_bytes
31 31
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class FileWrapper(object):
37 37 """File wrapper that ensures how much data is read from it."""
38 38
39 39 def __init__(self, fd, content_length):
40 40 self.fd = fd
41 41 self.content_length = content_length
42 42 self.remain = content_length
43 43
44 44 def read(self, size):
45 45 if size <= self.remain:
46 46 try:
47 47 data = self.fd.read(size)
48 48 except socket.error:
49 49 raise IOError(self)
50 50 self.remain -= size
51 51 elif self.remain:
52 52 data = self.fd.read(self.remain)
53 53 self.remain = 0
54 54 else:
55 55 data = None
56 56 return data
57 57
58 58 def __repr__(self):
59 59 return '<FileWrapper {} len: {}, read: {}>'.format(
60 60 self.fd, self.content_length, self.content_length - self.remain
61 61 )
62 62
63 63
64 64 class GitRepository(object):
65 65 """WSGI app for handling Git smart protocol endpoints."""
66 66
67 67 git_folder_signature = frozenset(('config', 'head', 'info', 'objects', 'refs'))
68 68 commands = frozenset(('git-upload-pack', 'git-receive-pack'))
69 69 valid_accepts = frozenset(f'application/x-{c}-result' for c in commands)
70 70
71 71 # The last bytes are the SHA1 of the first 12 bytes.
72 72 EMPTY_PACK = (
73 73 b'PACK\x00\x00\x00\x02\x00\x00\x00\x00\x02\x9d\x08' +
74 74 b'\x82;\xd8\xa8\xea\xb5\x10\xadj\xc7\\\x82<\xfd>\xd3\x1e'
75 75 )
76 76 FLUSH_PACKET = b"0000"
77 77
78 78 SIDE_BAND_CAPS = frozenset((CAPABILITY_SIDE_BAND, CAPABILITY_SIDE_BAND_64K))
79 79
80 80 def __init__(self, repo_name, content_path, git_path, update_server_info, extras):
81 81 files = frozenset(f.lower() for f in os.listdir(content_path))
82 82 valid_dir_signature = self.git_folder_signature.issubset(files)
83 83
84 84 if not valid_dir_signature:
85 raise OSError('%s missing git signature' % content_path)
85 raise OSError(f'{content_path} missing git signature')
86 86
87 87 self.content_path = content_path
88 88 self.repo_name = repo_name
89 89 self.extras = extras
90 90 self.git_path = git_path
91 91 self.update_server_info = update_server_info
92 92
93 93 def _get_fixedpath(self, path):
94 94 """
95 95 Small fix for repo_path
96 96
97 97 :param path:
98 98 """
99 99 path = path.split(self.repo_name, 1)[-1]
100 100 if path.startswith('.git'):
101 101 # for bare repos we still get the .git prefix inside, we skip it
102 102 # here, and remove from the service command
103 103 path = path[4:]
104 104
105 105 return path.strip('/')
106 106
107 107 def inforefs(self, request, unused_environ):
108 108 """
109 109 WSGI Response producer for HTTP GET Git Smart
110 110 HTTP /info/refs request.
111 111 """
112 112
113 113 git_command = request.GET.get('service')
114 114 if git_command not in self.commands:
115 115 log.debug('command %s not allowed', git_command)
116 116 return exc.HTTPForbidden()
117 117
118 118 # please, resist the urge to add '\n' to git capture and increment
119 119 # line count by 1.
120 120 # by git docs: Documentation/technical/http-protocol.txt#L214 \n is
121 121 # a part of protocol.
122 122 # The code in Git client not only does NOT need '\n', but actually
123 123 # blows up if you sprinkle "flush" (0000) as "0001\n".
124 124 # It reads binary, per number of bytes specified.
125 125 # if you do add '\n' as part of data, count it.
126 126 server_advert = '# service=%s\n' % git_command
127 127 packet_len = hex(len(server_advert) + 4)[2:].rjust(4, '0').lower()
128 128 try:
129 129 gitenv = dict(os.environ)
130 130 # forget all configs
131 131 gitenv['RC_SCM_DATA'] = json.dumps(self.extras)
132 132 command = [self.git_path, git_command[4:], '--stateless-rpc',
133 133 '--advertise-refs', self.content_path]
134 134 out = subprocessio.SubprocessIOChunker(
135 135 command,
136 136 env=gitenv,
137 137 starting_values=[ascii_bytes(packet_len + server_advert) + self.FLUSH_PACKET],
138 138 shell=False
139 139 )
140 140 except OSError:
141 141 log.exception('Error processing command')
142 142 raise exc.HTTPExpectationFailed()
143 143
144 144 resp = Response()
145 145 resp.content_type = f'application/x-{git_command}-advertisement'
146 146 resp.charset = None
147 147 resp.app_iter = out
148 148
149 149 return resp
150 150
151 151 def _get_want_capabilities(self, request):
152 152 """Read the capabilities found in the first want line of the request."""
153 153 pos = request.body_file_seekable.tell()
154 154 first_line = request.body_file_seekable.readline()
155 155 request.body_file_seekable.seek(pos)
156 156
157 157 return frozenset(
158 158 dulwich.protocol.extract_want_line_capabilities(first_line)[1])
159 159
160 160 def _build_failed_pre_pull_response(self, capabilities, pre_pull_messages):
161 161 """
162 162 Construct a response with an empty PACK file.
163 163
164 164 We use an empty PACK file, as that would trigger the failure of the pull
165 165 or clone command.
166 166
167 167 We also print in the error output a message explaining why the command
168 168 was aborted.
169 169
170 170 If additionally, the user is accepting messages we send them the output
171 171 of the pre-pull hook.
172 172
173 173 Note that for clients not supporting side-band we just send them the
174 174 emtpy PACK file.
175 175 """
176 176
177 177 if self.SIDE_BAND_CAPS.intersection(capabilities):
178 178 response = []
179 179 proto = dulwich.protocol.Protocol(None, response.append)
180 180 proto.write_pkt_line(dulwich.protocol.NAK_LINE)
181 181
182 182 self._write_sideband_to_proto(proto, ascii_bytes(pre_pull_messages, allow_bytes=True), capabilities)
183 183 # N.B.(skreft): Do not change the sideband channel to 3, as that
184 184 # produces a fatal error in the client:
185 185 # fatal: error in sideband demultiplexer
186 186 proto.write_sideband(
187 187 dulwich.protocol.SIDE_BAND_CHANNEL_PROGRESS,
188 188 ascii_bytes('Pre pull hook failed: aborting\n', allow_bytes=True))
189 189 proto.write_sideband(
190 190 dulwich.protocol.SIDE_BAND_CHANNEL_DATA,
191 191 ascii_bytes(self.EMPTY_PACK, allow_bytes=True))
192 192
193 193 # writes b"0000" as default
194 194 proto.write_pkt_line(None)
195 195
196 196 return response
197 197 else:
198 198 return [ascii_bytes(self.EMPTY_PACK, allow_bytes=True)]
199 199
200 200 def _build_post_pull_response(self, response, capabilities, start_message, end_message):
201 201 """
202 202 Given a list response we inject the post-pull messages.
203 203
204 204 We only inject the messages if the client supports sideband, and the
205 205 response has the format:
206 206 0008NAK\n...0000
207 207
208 208 Note that we do not check the no-progress capability as by default, git
209 209 sends it, which effectively would block all messages.
210 210 """
211 211
212 212 if not self.SIDE_BAND_CAPS.intersection(capabilities):
213 213 return response
214 214
215 215 if not start_message and not end_message:
216 216 return response
217 217
218 218 try:
219 219 iter(response)
220 220 # iterator probably will work, we continue
221 221 except TypeError:
222 222 raise TypeError(f'response must be an iterator: got {type(response)}')
223 223 if isinstance(response, (list, tuple)):
224 224 raise TypeError(f'response must be an iterator: got {type(response)}')
225 225
226 226 def injected_response():
227 227
228 228 do_loop = 1
229 229 header_injected = 0
230 230 next_item = None
231 231 has_item = False
232 item = b''
233
232 234 while do_loop:
233 235
234 236 try:
235 237 next_item = next(response)
236 238 except StopIteration:
237 239 do_loop = 0
238 240
239 241 if has_item:
240 242 # last item ! alter it now
241 243 if do_loop == 0 and item.endswith(self.FLUSH_PACKET):
242 244 new_response = [item[:-4]]
243 245 new_response.extend(self._get_messages(end_message, capabilities))
244 246 new_response.append(self.FLUSH_PACKET)
245 247 item = b''.join(new_response)
246 248
247 249 yield item
250
248 251 has_item = True
249 252 item = next_item
250 253
251 254 # alter item if it's the initial chunk
252 255 if not header_injected and item.startswith(b'0008NAK\n'):
253 256 new_response = [b'0008NAK\n']
254 257 new_response.extend(self._get_messages(start_message, capabilities))
255 258 new_response.append(item[8:])
256 259 item = b''.join(new_response)
257 260 header_injected = 1
258 261
259 262 return injected_response()
260 263
261 264 def _write_sideband_to_proto(self, proto, data, capabilities):
262 265 """
263 266 Write the data to the proto's sideband number 2 == SIDE_BAND_CHANNEL_PROGRESS
264 267
265 268 We do not use dulwich's write_sideband directly as it only supports
266 269 side-band-64k.
267 270 """
268 271 if not data:
269 272 return
270 273
271 274 # N.B.(skreft): The values below are explained in the pack protocol
272 275 # documentation, section Packfile Data.
273 276 # https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt
274 277 if CAPABILITY_SIDE_BAND_64K in capabilities:
275 278 chunk_size = 65515
276 279 elif CAPABILITY_SIDE_BAND in capabilities:
277 280 chunk_size = 995
278 281 else:
279 282 return
280 283
281 284 chunker = (data[i:i + chunk_size] for i in range(0, len(data), chunk_size))
282 285
283 286 for chunk in chunker:
284 287 proto.write_sideband(dulwich.protocol.SIDE_BAND_CHANNEL_PROGRESS, ascii_bytes(chunk, allow_bytes=True))
285 288
286 289 def _get_messages(self, data, capabilities):
287 290 """Return a list with packets for sending data in sideband number 2."""
288 291 response = []
289 292 proto = dulwich.protocol.Protocol(None, response.append)
290 293
291 294 self._write_sideband_to_proto(proto, data, capabilities)
292 295
293 296 return response
294 297
295 298 def backend(self, request, environ):
296 299 """
297 300 WSGI Response producer for HTTP POST Git Smart HTTP requests.
298 301 Reads commands and data from HTTP POST's body.
299 302 returns an iterator obj with contents of git command's
300 303 response to stdout
301 304 """
302 305 # TODO(skreft): think how we could detect an HTTPLockedException, as
303 306 # we probably want to have the same mechanism used by mercurial and
304 307 # simplevcs.
305 308 # For that we would need to parse the output of the command looking for
306 309 # some signs of the HTTPLockedError, parse the data and reraise it in
307 310 # pygrack. However, that would interfere with the streaming.
308 311 #
309 312 # Now the output of a blocked push is:
310 313 # Pushing to http://test_regular:test12@127.0.0.1:5001/vcs_test_git
311 314 # POST git-receive-pack (1047 bytes)
312 315 # remote: ERROR: Repository `vcs_test_git` locked by user `test_admin`. Reason:`lock_auto`
313 316 # To http://test_regular:test12@127.0.0.1:5001/vcs_test_git
314 317 # ! [remote rejected] master -> master (pre-receive hook declined)
315 318 # error: failed to push some refs to 'http://test_regular:test12@127.0.0.1:5001/vcs_test_git'
316 319
317 320 git_command = self._get_fixedpath(request.path_info)
318 321 if git_command not in self.commands:
319 322 log.debug('command %s not allowed', git_command)
320 323 return exc.HTTPForbidden()
321 324
322 325 capabilities = None
323 326 if git_command == 'git-upload-pack':
324 327 capabilities = self._get_want_capabilities(request)
325 328
326 329 if 'CONTENT_LENGTH' in environ:
327 330 inputstream = FileWrapper(request.body_file_seekable,
328 331 request.content_length)
329 332 else:
330 333 inputstream = request.body_file_seekable
331 334
332 335 resp = Response()
333 336 resp.content_type = f'application/x-{git_command}-result'
334 337 resp.charset = None
335 338
336 339 pre_pull_messages = ''
337 340 # Upload-pack == clone
338 341 if git_command == 'git-upload-pack':
339 342 hook_response = hooks.git_pre_pull(self.extras)
340 343 if hook_response.status != 0:
341 344 pre_pull_messages = hook_response.output
342 345 resp.app_iter = self._build_failed_pre_pull_response(
343 346 capabilities, pre_pull_messages)
344 347 return resp
345 348
346 349 gitenv = dict(os.environ)
347 350 # forget all configs
348 351 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
349 352 gitenv['RC_SCM_DATA'] = json.dumps(self.extras)
350 353 cmd = [self.git_path, git_command[4:], '--stateless-rpc',
351 354 self.content_path]
352 355 log.debug('handling cmd %s', cmd)
353 356
354 357 out = subprocessio.SubprocessIOChunker(
355 358 cmd,
356 359 input_stream=inputstream,
357 360 env=gitenv,
358 361 cwd=self.content_path,
359 362 shell=False,
360 363 fail_on_stderr=False,
361 364 fail_on_return_code=False
362 365 )
363 366
364 367 if self.update_server_info and git_command == 'git-receive-pack':
365 368 # We need to fully consume the iterator here, as the
366 369 # update-server-info command needs to be run after the push.
367 370 out = list(out)
368 371
369 372 # Updating refs manually after each push.
370 373 # This is required as some clients are exposing Git repos internally
371 374 # with the dumb protocol.
372 375 cmd = [self.git_path, 'update-server-info']
373 376 log.debug('handling cmd %s', cmd)
374 377 output = subprocessio.SubprocessIOChunker(
375 378 cmd,
376 379 input_stream=inputstream,
377 380 env=gitenv,
378 381 cwd=self.content_path,
379 382 shell=False,
380 383 fail_on_stderr=False,
381 384 fail_on_return_code=False
382 385 )
383 386 # Consume all the output so the subprocess finishes
384 387 for _ in output:
385 388 pass
386 389
387 390 # Upload-pack == clone
388 391 if git_command == 'git-upload-pack':
389 392 hook_response = hooks.git_post_pull(self.extras)
390 393 post_pull_messages = hook_response.output
391 394 resp.app_iter = self._build_post_pull_response(out, capabilities, pre_pull_messages, post_pull_messages)
392 395 else:
393 396 resp.app_iter = out
394 397
395 398 return resp
396 399
397 400 def __call__(self, environ, start_response):
398 401 request = Request(environ)
399 402 _path = self._get_fixedpath(request.path_info)
400 403 if _path.startswith('info/refs'):
401 404 app = self.inforefs
402 405 else:
403 406 app = self.backend
404 407
405 408 try:
406 409 resp = app(request, environ)
407 410 except exc.HTTPException as error:
408 411 log.exception('HTTP Error')
409 412 resp = error
410 413 except Exception:
411 414 log.exception('Unknown error')
412 415 resp = exc.HTTPInternalServerError()
413 416
414 417 return resp(environ, start_response)
@@ -1,19 +1,17 b''
1 # -*- coding: utf-8 -*-
2
3 1 # Copyright (C) 2016-2020 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
@@ -1,62 +1,67 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-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 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 import logging
20 20
21 21 log = logging.getLogger(__name__)
22 22
23 23
24 24 def str2bool(str_):
25 25 """
26 26 returns True/False value from given string, it tries to translate the
27 27 string into boolean
28 28
29 29 :param str_: string value to translate into boolean
30 30 :rtype: boolean
31 31 :returns: boolean from given string
32 32 """
33 33 if str_ is None:
34 34 return False
35 35 if str_ in (True, False):
36 36 return str_
37 37 str_ = str(str_).strip().lower()
38 38 return str_ in ('t', 'true', 'y', 'yes', 'on', '1')
39 39
40 40
41 41 def aslist(obj, sep=None, strip=True) -> list:
42 42 """
43 43 Returns given string separated by sep as list
44 44
45 45 :param obj:
46 46 :param sep:
47 47 :param strip:
48 48 """
49 49 if isinstance(obj, str):
50 50 if obj in ['', ""]:
51 51 return []
52 52
53 53 lst = obj.split(sep)
54 54 if strip:
55 55 lst = [v.strip() for v in lst]
56 56 return lst
57 57 elif isinstance(obj, (list, tuple)):
58 58 return obj
59 59 elif obj is None:
60 60 return []
61 61 else:
62 return [obj] No newline at end of file
62 return [obj]
63
64
65 def assert_bytes(input_type, expected_types=(bytes,)):
66 if not isinstance(input_type, expected_types):
67 raise ValueError(f'input_types should be one of {expected_types} got {type(input_type)} instead')
@@ -1,47 +1,47 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-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 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 from vcsserver.lib import rc_cache
19 19
20 20
21 21 class RemoteBase(object):
22 22 EMPTY_COMMIT = '0' * 40
23 23
24 24 def _region(self, wire):
25 25 cache_repo_id = wire.get('cache_repo_id', '')
26 26 cache_namespace_uid = f'cache_repo.{cache_repo_id}'
27 27 return rc_cache.get_or_create_region('repo_object', cache_namespace_uid)
28 28
29 29 def _cache_on(self, wire):
30 30 context = wire.get('context', '')
31 31 context_uid = f'{context}'
32 32 repo_id = wire.get('repo_id', '')
33 33 cache = wire.get('cache', True)
34 34 cache_on = context and cache
35 35 return cache_on, context_uid, repo_id
36 36
37 37 def vcsserver_invalidate_cache(self, wire, delete):
38 38 from vcsserver.lib import rc_cache
39 39 repo_id = wire.get('repo_id', '')
40 40 cache_repo_id = wire.get('cache_repo_id', '')
41 41 cache_namespace_uid = f'cache_repo.{cache_repo_id}'
42 42
43 43 if delete:
44 44 rc_cache.clear_cache_namespace(
45 'repo_object', cache_namespace_uid, invalidate=True)
45 'repo_object', cache_namespace_uid, method=rc_cache.CLEAR_DELETE)
46 46
47 47 return {'invalidated': {'repo_id': repo_id, 'delete': delete}}
General Comments 0
You need to be logged in to leave comments. Login now