##// END OF EJS Templates
hooks: added debug logs.
marcink -
r2135:d1c488ba default
parent child Browse files
Show More
@@ -1,239 +1,241 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 import json
22 22 import logging
23 23 import traceback
24 24 import threading
25 25 from BaseHTTPServer import BaseHTTPRequestHandler
26 26 from SocketServer import TCPServer
27 27
28 28 import rhodecode
29 29 from rhodecode.model import meta
30 30 from rhodecode.lib.base import bootstrap_request
31 31 from rhodecode.lib import hooks_base
32 32 from rhodecode.lib.utils2 import AttributeDict
33 33
34 34
35 35 log = logging.getLogger(__name__)
36 36
37 37
38 38 class HooksHttpHandler(BaseHTTPRequestHandler):
39 39 def do_POST(self):
40 40 method, extras = self._read_request()
41 41 try:
42 42 result = self._call_hook(method, extras)
43 43 except Exception as e:
44 44 exc_tb = traceback.format_exc()
45 45 result = {
46 46 'exception': e.__class__.__name__,
47 47 'exception_traceback': exc_tb,
48 48 'exception_args': e.args
49 49 }
50 50 self._write_response(result)
51 51
52 52 def _read_request(self):
53 53 length = int(self.headers['Content-Length'])
54 54 body = self.rfile.read(length).decode('utf-8')
55 55 data = json.loads(body)
56 56 return data['method'], data['extras']
57 57
58 58 def _write_response(self, result):
59 59 self.send_response(200)
60 60 self.send_header("Content-type", "text/json")
61 61 self.end_headers()
62 62 self.wfile.write(json.dumps(result))
63 63
64 64 def _call_hook(self, method, extras):
65 65 hooks = Hooks()
66 66 try:
67 67 result = getattr(hooks, method)(extras)
68 68 finally:
69 69 meta.Session.remove()
70 70 return result
71 71
72 72 def log_message(self, format, *args):
73 73 """
74 74 This is an overridden method of BaseHTTPRequestHandler which logs using
75 75 logging library instead of writing directly to stderr.
76 76 """
77 77
78 78 message = format % args
79 79
80 80 # TODO: mikhail: add different log levels support
81 81 log.debug(
82 82 "%s - - [%s] %s", self.client_address[0],
83 83 self.log_date_time_string(), message)
84 84
85 85
86 86 class DummyHooksCallbackDaemon(object):
87 87 def __init__(self):
88 88 self.hooks_module = Hooks.__module__
89 89
90 90 def __enter__(self):
91 91 log.debug('Running dummy hooks callback daemon')
92 92 return self
93 93
94 94 def __exit__(self, exc_type, exc_val, exc_tb):
95 95 log.debug('Exiting dummy hooks callback daemon')
96 96
97 97
98 98 class ThreadedHookCallbackDaemon(object):
99 99
100 100 _callback_thread = None
101 101 _daemon = None
102 102 _done = False
103 103
104 104 def __init__(self):
105 105 self._prepare()
106 106
107 107 def __enter__(self):
108 108 self._run()
109 109 return self
110 110
111 111 def __exit__(self, exc_type, exc_val, exc_tb):
112 log.debug('Callback daemon exiting now...')
112 113 self._stop()
113 114
114 115 def _prepare(self):
115 116 raise NotImplementedError()
116 117
117 118 def _run(self):
118 119 raise NotImplementedError()
119 120
120 121 def _stop(self):
121 122 raise NotImplementedError()
122 123
123 124
124 125 class HttpHooksCallbackDaemon(ThreadedHookCallbackDaemon):
125 126 """
126 127 Context manager which will run a callback daemon in a background thread.
127 128 """
128 129
129 130 hooks_uri = None
130 131
131 132 IP_ADDRESS = '127.0.0.1'
132 133
133 134 # From Python docs: Polling reduces our responsiveness to a shutdown
134 135 # request and wastes cpu at all other times.
135 136 POLL_INTERVAL = 0.1
136 137
137 138 def _prepare(self):
138 log.debug("Preparing callback daemon and registering hook object")
139 log.debug("Preparing HTTP callback daemon and registering hook object")
139 140
140 141 self._done = False
141 142 self._daemon = TCPServer((self.IP_ADDRESS, 0), HooksHttpHandler)
142 143 _, port = self._daemon.server_address
143 144 self.hooks_uri = '{}:{}'.format(self.IP_ADDRESS, port)
144 145
145 146 log.debug("Hooks uri is: %s", self.hooks_uri)
146 147
147 148 def _run(self):
148 149 log.debug("Running event loop of callback daemon in background thread")
149 150 callback_thread = threading.Thread(
150 151 target=self._daemon.serve_forever,
151 152 kwargs={'poll_interval': self.POLL_INTERVAL})
152 153 callback_thread.daemon = True
153 154 callback_thread.start()
154 155 self._callback_thread = callback_thread
155 156
156 157 def _stop(self):
157 158 log.debug("Waiting for background thread to finish.")
158 159 self._daemon.shutdown()
159 160 self._callback_thread.join()
160 161 self._daemon = None
161 162 self._callback_thread = None
162 163
163 164
164 165 def prepare_callback_daemon(extras, protocol, use_direct_calls):
165 166 callback_daemon = None
166 167
167 168 if use_direct_calls:
168 169 callback_daemon = DummyHooksCallbackDaemon()
169 170 extras['hooks_module'] = callback_daemon.hooks_module
170 171 else:
171 172 if protocol == 'http':
172 173 callback_daemon = HttpHooksCallbackDaemon()
173 174 else:
174 175 log.error('Unsupported callback daemon protocol "%s"', protocol)
175 176 raise Exception('Unsupported callback daemon protocol.')
176 177
177 178 extras['hooks_uri'] = callback_daemon.hooks_uri
178 179 extras['hooks_protocol'] = protocol
179 180
181 log.debug('Prepared a callback daemon: %s', callback_daemon)
180 182 return callback_daemon, extras
181 183
182 184
183 185 class Hooks(object):
184 186 """
185 187 Exposes the hooks for remote call backs
186 188 """
187 189
188 190 def repo_size(self, extras):
189 191 log.debug("Called repo_size of %s object", self)
190 192 return self._call_hook(hooks_base.repo_size, extras)
191 193
192 194 def pre_pull(self, extras):
193 195 log.debug("Called pre_pull of %s object", self)
194 196 return self._call_hook(hooks_base.pre_pull, extras)
195 197
196 198 def post_pull(self, extras):
197 199 log.debug("Called post_pull of %s object", self)
198 200 return self._call_hook(hooks_base.post_pull, extras)
199 201
200 202 def pre_push(self, extras):
201 203 log.debug("Called pre_push of %s object", self)
202 204 return self._call_hook(hooks_base.pre_push, extras)
203 205
204 206 def post_push(self, extras):
205 207 log.debug("Called post_push of %s object", self)
206 208 return self._call_hook(hooks_base.post_push, extras)
207 209
208 210 def _call_hook(self, hook, extras):
209 211 extras = AttributeDict(extras)
210 212
211 213 extras.request = bootstrap_request(
212 214 application_url=extras['server_url'])
213 215
214 216 try:
215 217 result = hook(extras)
216 218 except Exception as error:
217 219 exc_tb = traceback.format_exc()
218 220 log.exception('Exception when handling hook %s', hook)
219 221 error_args = error.args
220 222 return {
221 223 'status': 128,
222 224 'output': '',
223 225 'exception': type(error).__name__,
224 226 'exception_traceback': exc_tb,
225 227 'exception_args': error_args,
226 228 }
227 229 finally:
228 230 meta.Session.remove()
229 231
230 232 return {
231 233 'status': result.status,
232 234 'output': result.output,
233 235 }
234 236
235 237 def __enter__(self):
236 238 return self
237 239
238 240 def __exit__(self, exc_type, exc_val, exc_tb):
239 241 pass
@@ -1,321 +1,321 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 import json
22 22 import logging
23 23 from StringIO import StringIO
24 24
25 25 import mock
26 26 import pytest
27 27
28 28 from rhodecode.lib import hooks_daemon
29 29 from rhodecode.tests.utils import assert_message_in_log
30 30
31 31
32 32 class TestDummyHooksCallbackDaemon(object):
33 33 def test_hooks_module_path_set_properly(self):
34 34 daemon = hooks_daemon.DummyHooksCallbackDaemon()
35 35 assert daemon.hooks_module == 'rhodecode.lib.hooks_daemon'
36 36
37 37 def test_logs_entering_the_hook(self):
38 38 daemon = hooks_daemon.DummyHooksCallbackDaemon()
39 39 with mock.patch.object(hooks_daemon.log, 'debug') as log_mock:
40 40 with daemon as return_value:
41 41 log_mock.assert_called_once_with(
42 42 'Running dummy hooks callback daemon')
43 43 assert return_value == daemon
44 44
45 45 def test_logs_exiting_the_hook(self):
46 46 daemon = hooks_daemon.DummyHooksCallbackDaemon()
47 47 with mock.patch.object(hooks_daemon.log, 'debug') as log_mock:
48 48 with daemon:
49 49 pass
50 50 log_mock.assert_called_with('Exiting dummy hooks callback daemon')
51 51
52 52
53 53 class TestHooks(object):
54 54 def test_hooks_can_be_used_as_a_context_processor(self):
55 55 hooks = hooks_daemon.Hooks()
56 56 with hooks as return_value:
57 57 pass
58 58 assert hooks == return_value
59 59
60 60
61 61 class TestHooksHttpHandler(object):
62 62 def test_read_request_parses_method_name_and_arguments(self):
63 63 data = {
64 64 'method': 'test',
65 65 'extras': {
66 66 'param1': 1,
67 67 'param2': 'a'
68 68 }
69 69 }
70 70 request = self._generate_post_request(data)
71 71 hooks_patcher = mock.patch.object(
72 72 hooks_daemon.Hooks, data['method'], create=True, return_value=1)
73 73
74 74 with hooks_patcher as hooks_mock:
75 75 MockServer(hooks_daemon.HooksHttpHandler, request)
76 76
77 77 hooks_mock.assert_called_once_with(data['extras'])
78 78
79 79 def test_hooks_serialized_result_is_returned(self):
80 80 request = self._generate_post_request({})
81 81 rpc_method = 'test'
82 82 hook_result = {
83 83 'first': 'one',
84 84 'second': 2
85 85 }
86 86 read_patcher = mock.patch.object(
87 87 hooks_daemon.HooksHttpHandler, '_read_request',
88 88 return_value=(rpc_method, {}))
89 89 hooks_patcher = mock.patch.object(
90 90 hooks_daemon.Hooks, rpc_method, create=True,
91 91 return_value=hook_result)
92 92
93 93 with read_patcher, hooks_patcher:
94 94 server = MockServer(hooks_daemon.HooksHttpHandler, request)
95 95
96 96 expected_result = json.dumps(hook_result)
97 97 assert server.request.output_stream.buflist[-1] == expected_result
98 98
99 99 def test_exception_is_returned_in_response(self):
100 100 request = self._generate_post_request({})
101 101 rpc_method = 'test'
102 102 read_patcher = mock.patch.object(
103 103 hooks_daemon.HooksHttpHandler, '_read_request',
104 104 return_value=(rpc_method, {}))
105 105 hooks_patcher = mock.patch.object(
106 106 hooks_daemon.Hooks, rpc_method, create=True,
107 107 side_effect=Exception('Test exception'))
108 108
109 109 with read_patcher, hooks_patcher:
110 110 server = MockServer(hooks_daemon.HooksHttpHandler, request)
111 111
112 112 org_exc = json.loads(server.request.output_stream.buflist[-1])
113 113 expected_result = {
114 114 'exception': 'Exception',
115 115 'exception_traceback': org_exc['exception_traceback'],
116 116 'exception_args': ['Test exception']
117 117 }
118 118 assert org_exc == expected_result
119 119
120 120 def test_log_message_writes_to_debug_log(self, caplog):
121 121 ip_port = ('0.0.0.0', 8888)
122 122 handler = hooks_daemon.HooksHttpHandler(
123 123 MockRequest('POST /'), ip_port, mock.Mock())
124 124 fake_date = '1/Nov/2015 00:00:00'
125 125 date_patcher = mock.patch.object(
126 126 handler, 'log_date_time_string', return_value=fake_date)
127 127 with date_patcher, caplog.at_level(logging.DEBUG):
128 128 handler.log_message('Some message %d, %s', 123, 'string')
129 129
130 130 expected_message = '{} - - [{}] Some message 123, string'.format(
131 131 ip_port[0], fake_date)
132 132 assert_message_in_log(
133 133 caplog.records, expected_message,
134 134 levelno=logging.DEBUG, module='hooks_daemon')
135 135
136 136 def _generate_post_request(self, data):
137 137 payload = json.dumps(data)
138 138 return 'POST / HTTP/1.0\nContent-Length: {}\n\n{}'.format(
139 139 len(payload), payload)
140 140
141 141
142 142 class ThreadedHookCallbackDaemon(object):
143 143 def test_constructor_calls_prepare(self):
144 144 prepare_daemon_patcher = mock.patch.object(
145 145 hooks_daemon.ThreadedHookCallbackDaemon, '_prepare')
146 146 with prepare_daemon_patcher as prepare_daemon_mock:
147 147 hooks_daemon.ThreadedHookCallbackDaemon()
148 148 prepare_daemon_mock.assert_called_once_with()
149 149
150 150 def test_run_is_called_on_context_start(self):
151 151 patchers = mock.patch.multiple(
152 152 hooks_daemon.ThreadedHookCallbackDaemon,
153 153 _run=mock.DEFAULT, _prepare=mock.DEFAULT, __exit__=mock.DEFAULT)
154 154
155 155 with patchers as mocks:
156 156 daemon = hooks_daemon.ThreadedHookCallbackDaemon()
157 157 with daemon as daemon_context:
158 158 pass
159 159 mocks['_run'].assert_called_once_with()
160 160 assert daemon_context == daemon
161 161
162 162 def test_stop_is_called_on_context_exit(self):
163 163 patchers = mock.patch.multiple(
164 164 hooks_daemon.ThreadedHookCallbackDaemon,
165 165 _run=mock.DEFAULT, _prepare=mock.DEFAULT, _stop=mock.DEFAULT)
166 166
167 167 with patchers as mocks:
168 168 daemon = hooks_daemon.ThreadedHookCallbackDaemon()
169 169 with daemon as daemon_context:
170 170 assert mocks['_stop'].call_count == 0
171 171
172 172 mocks['_stop'].assert_called_once_with()
173 173 assert daemon_context == daemon
174 174
175 175
176 176 class TestHttpHooksCallbackDaemon(object):
177 177 def test_prepare_inits_daemon_variable(self, tcp_server, caplog):
178 178 with self._tcp_patcher(tcp_server), caplog.at_level(logging.DEBUG):
179 179 daemon = hooks_daemon.HttpHooksCallbackDaemon()
180 180 assert daemon._daemon == tcp_server
181 181
182 182 assert_message_in_log(
183 183 caplog.records,
184 'Preparing callback daemon and registering hook object',
184 'Preparing HTTP callback daemon and registering hook object',
185 185 levelno=logging.DEBUG, module='hooks_daemon')
186 186
187 187 def test_prepare_inits_hooks_uri_and_logs_it(
188 188 self, tcp_server, caplog):
189 189 with self._tcp_patcher(tcp_server), caplog.at_level(logging.DEBUG):
190 190 daemon = hooks_daemon.HttpHooksCallbackDaemon()
191 191
192 192 _, port = tcp_server.server_address
193 193 expected_uri = '{}:{}'.format(daemon.IP_ADDRESS, port)
194 194 assert daemon.hooks_uri == expected_uri
195 195
196 196 assert_message_in_log(
197 197 caplog.records, 'Hooks uri is: {}'.format(expected_uri),
198 198 levelno=logging.DEBUG, module='hooks_daemon')
199 199
200 200 def test_run_creates_a_thread(self, tcp_server):
201 201 thread = mock.Mock()
202 202
203 203 with self._tcp_patcher(tcp_server):
204 204 daemon = hooks_daemon.HttpHooksCallbackDaemon()
205 205
206 206 with self._thread_patcher(thread) as thread_mock:
207 207 daemon._run()
208 208
209 209 thread_mock.assert_called_once_with(
210 210 target=tcp_server.serve_forever,
211 211 kwargs={'poll_interval': daemon.POLL_INTERVAL})
212 212 assert thread.daemon is True
213 213 thread.start.assert_called_once_with()
214 214
215 215 def test_run_logs(self, tcp_server, caplog):
216 216
217 217 with self._tcp_patcher(tcp_server):
218 218 daemon = hooks_daemon.HttpHooksCallbackDaemon()
219 219
220 220 with self._thread_patcher(mock.Mock()), caplog.at_level(logging.DEBUG):
221 221 daemon._run()
222 222
223 223 assert_message_in_log(
224 224 caplog.records,
225 225 'Running event loop of callback daemon in background thread',
226 226 levelno=logging.DEBUG, module='hooks_daemon')
227 227
228 228 def test_stop_cleans_up_the_connection(self, tcp_server, caplog):
229 229 thread = mock.Mock()
230 230
231 231 with self._tcp_patcher(tcp_server):
232 232 daemon = hooks_daemon.HttpHooksCallbackDaemon()
233 233
234 234 with self._thread_patcher(thread), caplog.at_level(logging.DEBUG):
235 235 with daemon:
236 236 assert daemon._daemon == tcp_server
237 237 assert daemon._callback_thread == thread
238 238
239 239 assert daemon._daemon is None
240 240 assert daemon._callback_thread is None
241 241 tcp_server.shutdown.assert_called_with()
242 242 thread.join.assert_called_once_with()
243 243
244 244 assert_message_in_log(
245 245 caplog.records, 'Waiting for background thread to finish.',
246 246 levelno=logging.DEBUG, module='hooks_daemon')
247 247
248 248 def _tcp_patcher(self, tcp_server):
249 249 return mock.patch.object(
250 250 hooks_daemon, 'TCPServer', return_value=tcp_server)
251 251
252 252 def _thread_patcher(self, thread):
253 253 return mock.patch.object(
254 254 hooks_daemon.threading, 'Thread', return_value=thread)
255 255
256 256
257 257 class TestPrepareHooksDaemon(object):
258 258 @pytest.mark.parametrize('protocol', ('http',))
259 259 def test_returns_dummy_hooks_callback_daemon_when_using_direct_calls(
260 260 self, protocol):
261 261 expected_extras = {'extra1': 'value1'}
262 262 callback, extras = hooks_daemon.prepare_callback_daemon(
263 263 expected_extras.copy(), protocol=protocol, use_direct_calls=True)
264 264 assert isinstance(callback, hooks_daemon.DummyHooksCallbackDaemon)
265 265 expected_extras['hooks_module'] = 'rhodecode.lib.hooks_daemon'
266 266 assert extras == expected_extras
267 267
268 268 @pytest.mark.parametrize('protocol, expected_class', (
269 269 ('http', hooks_daemon.HttpHooksCallbackDaemon),
270 270 ))
271 271 def test_returns_real_hooks_callback_daemon_when_protocol_is_specified(
272 272 self, protocol, expected_class):
273 273 expected_extras = {
274 274 'extra1': 'value1',
275 275 'hooks_protocol': protocol.lower()
276 276 }
277 277 callback, extras = hooks_daemon.prepare_callback_daemon(
278 278 expected_extras.copy(), protocol=protocol, use_direct_calls=False)
279 279 assert isinstance(callback, expected_class)
280 280 hooks_uri = extras.pop('hooks_uri')
281 281 assert extras == expected_extras
282 282
283 283 @pytest.mark.parametrize('protocol', (
284 284 'invalid',
285 285 'Http',
286 286 'HTTP',
287 287 ))
288 288 def test_raises_on_invalid_protocol(self, protocol):
289 289 expected_extras = {
290 290 'extra1': 'value1',
291 291 'hooks_protocol': protocol.lower()
292 292 }
293 293 with pytest.raises(Exception):
294 294 callback, extras = hooks_daemon.prepare_callback_daemon(
295 295 expected_extras.copy(),
296 296 protocol=protocol,
297 297 use_direct_calls=False)
298 298
299 299
300 300 class MockRequest(object):
301 301 def __init__(self, request):
302 302 self.request = request
303 303 self.input_stream = StringIO(b'{}'.format(self.request))
304 304 self.output_stream = StringIO()
305 305
306 306 def makefile(self, mode, *args, **kwargs):
307 307 return self.output_stream if mode == 'wb' else self.input_stream
308 308
309 309
310 310 class MockServer(object):
311 311 def __init__(self, Handler, request):
312 312 ip_port = ('0.0.0.0', 8888)
313 313 self.request = MockRequest(request)
314 314 self.handler = Handler(self.request, ip_port, self)
315 315
316 316
317 317 @pytest.fixture
318 318 def tcp_server():
319 319 server = mock.Mock()
320 320 server.server_address = ('127.0.0.1', 8881)
321 321 return server
General Comments 0
You need to be logged in to leave comments. Login now