##// END OF EJS Templates
integrations: expose actor user_id, and username in webhook integration templates args....
marcink -
r1709:48d816ce default
parent child Browse files
Show More
@@ -1,100 +1,103 b''
1 1 # Copyright (C) 2016-2017 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18 import logging
19 19
20 20 from datetime import datetime
21 21 from pyramid.threadlocal import get_current_request
22 22 from rhodecode.lib.utils2 import AttributeDict
23 23
24 24
25 25 # this is a user object to be used for events caused by the system (eg. shell)
26 26 SYSTEM_USER = AttributeDict(dict(
27 username='__SYSTEM__'
27 username='__SYSTEM__',
28 user_id='__SYSTEM_ID__'
28 29 ))
29 30
30 31 log = logging.getLogger(__name__)
31 32
32 33
33 34 class RhodecodeEvent(object):
34 35 """
35 36 Base event class for all Rhodecode events
36 37 """
37 38 name = "RhodeCodeEvent"
38 39
39 40 def __init__(self):
40 41 self.request = get_current_request()
41 42 self.utc_timestamp = datetime.utcnow()
42 43
43 44 @property
44 45 def auth_user(self):
45 46 if not self.request:
46 47 return
47 48
48 49 user = getattr(self.request, 'user', None)
49 50 if user:
50 51 return user
51 52
52 53 api_user = getattr(self.request, 'rpc_user', None)
53 54 if api_user:
54 55 return api_user
55 56
56 57 @property
57 58 def actor(self):
58 59 auth_user = self.auth_user
59 60
60 61 if auth_user:
61 62 instance = auth_user.get_instance()
62 63 if not instance:
63 64 return AttributeDict(dict(
64 username=auth_user.username
65 username=auth_user.username,
66 user_id=auth_user.user_id,
65 67 ))
66 68 return instance
67 69
68 70 return SYSTEM_USER
69 71
70 72 @property
71 73 def actor_ip(self):
72 74 auth_user = self.auth_user
73 75 if auth_user:
74 76 return auth_user.ip_addr
75 77 return '<no ip available>'
76 78
77 79 @property
78 80 def server_url(self):
79 81 default = '<no server_url available>'
80 82 if self.request:
81 83 from rhodecode.lib import helpers as h
82 84 try:
83 85 return h.url('home', qualified=True)
84 86 except Exception:
85 87 log.exception('Failed to fetch URL for server')
86 88 return default
87 89
88 90 return default
89 91
90 92 def as_dict(self):
91 93 data = {
92 94 'name': self.name,
93 95 'utc_timestamp': self.utc_timestamp,
94 96 'actor_ip': self.actor_ip,
95 97 'actor': {
96 'username': self.actor.username
98 'username': self.actor.username,
99 'user_id': self.actor.user_id
97 100 },
98 101 'server_url': self.server_url
99 102 }
100 103 return data
@@ -1,265 +1,271 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-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 from __future__ import unicode_literals
22 22 import string
23 23 from collections import OrderedDict
24 24
25 25 import deform
26 26 import logging
27 27 import requests
28 28 import colander
29 29 from celery.task import task
30 30 from requests.packages.urllib3.util.retry import Retry
31 31
32 32 from rhodecode import events
33 33 from rhodecode.translation import _
34 34 from rhodecode.integrations.types.base import IntegrationTypeBase
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 # updating this required to update the `base_vars` passed in url calling func
38 # updating this required to update the `common_vars` passed in url calling func
39 39 WEBHOOK_URL_VARS = [
40 40 'repo_name',
41 41 'repo_type',
42 42 'repo_id',
43 43 'repo_url',
44 44
45 45 # special attrs below that we handle, using multi-call
46 46 'branch',
47 47 'commit_id',
48 48
49 49 # pr events vars
50 50 'pull_request_id',
51 51 'pull_request_url',
52 52
53 # user who triggers the call
54 'username',
55 'user_id',
56
53 57 ]
54 58 URL_VARS = ', '.join('${' + x + '}' for x in WEBHOOK_URL_VARS)
55 59
56 60
57 61 class WebhookHandler(object):
58 62 def __init__(self, template_url, secret_token):
59 63 self.template_url = template_url
60 64 self.secret_token = secret_token
61 65
62 66 def get_base_parsed_template(self, data):
63 67 """
64 68 initially parses the passed in template with some common variables
65 69 available on ALL calls
66 70 """
67 71 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
68 72 common_vars = {
69 73 'repo_name': data['repo']['repo_name'],
70 74 'repo_type': data['repo']['repo_type'],
71 75 'repo_id': data['repo']['repo_id'],
72 76 'repo_url': data['repo']['url'],
77 'username': data['actor']['username'],
78 'user_id': data['actor']['user_id']
73 79 }
74 80
75 81 return string.Template(
76 82 self.template_url).safe_substitute(**common_vars)
77 83
78 84 def repo_push_event_handler(self, event, data):
79 85 url = self.get_base_parsed_template(data)
80 86 url_cals = []
81 87 branch_data = OrderedDict()
82 88 for obj in data['push']['branches']:
83 89 branch_data[obj['name']] = obj
84 90
85 91 branches_commits = OrderedDict()
86 92 for commit in data['push']['commits']:
87 93 if commit['branch'] not in branches_commits:
88 94 branch_commits = {'branch': branch_data[commit['branch']],
89 95 'commits': []}
90 96 branches_commits[commit['branch']] = branch_commits
91 97
92 98 branch_commits = branches_commits[commit['branch']]
93 99 branch_commits['commits'].append(commit)
94 100
95 101 if '${branch}' in url:
96 102 # call it multiple times, for each branch if used in variables
97 103 for branch, commit_ids in branches_commits.items():
98 104 branch_url = string.Template(url).safe_substitute(branch=branch)
99 105 # call further down for each commit if used
100 106 if '${commit_id}' in branch_url:
101 107 for commit_data in commit_ids['commits']:
102 108 commit_id = commit_data['raw_id']
103 109 commit_url = string.Template(branch_url).safe_substitute(
104 110 commit_id=commit_id)
105 111 # register per-commit call
106 112 log.debug(
107 113 'register webhook call(%s) to url %s', event, commit_url)
108 114 url_cals.append((commit_url, self.secret_token, data))
109 115
110 116 else:
111 117 # register per-branch call
112 118 log.debug(
113 119 'register webhook call(%s) to url %s', event, branch_url)
114 120 url_cals.append((branch_url, self.secret_token, data))
115 121
116 122 else:
117 123 log.debug(
118 124 'register webhook call(%s) to url %s', event, url)
119 125 url_cals.append((url, self.secret_token, data))
120 126
121 127 return url_cals
122 128
123 129 def repo_create_event_handler(self, event, data):
124 130 url = self.get_base_parsed_template(data)
125 131 log.debug(
126 132 'register webhook call(%s) to url %s', event, url)
127 133 return [(url, self.secret_token, data)]
128 134
129 135 def pull_request_event_handler(self, event, data):
130 136 url = self.get_base_parsed_template(data)
131 137 log.debug(
132 138 'register webhook call(%s) to url %s', event, url)
133 139 url = string.Template(url).safe_substitute(
134 140 pull_request_id=data['pullrequest']['pull_request_id'],
135 141 pull_request_url=data['pullrequest']['url'])
136 142 return [(url, self.secret_token, data)]
137 143
138 144 def __call__(self, event, data):
139 145 if isinstance(event, events.RepoPushEvent):
140 146 return self.repo_push_event_handler(event, data)
141 147 elif isinstance(event, events.RepoCreateEvent):
142 148 return self.repo_create_event_handler(event, data)
143 149 elif isinstance(event, events.PullRequestEvent):
144 150 return self.pull_request_event_handler(event, data)
145 151 else:
146 152 raise ValueError('event type not supported: %s' % events)
147 153
148 154
149 155 class WebhookSettingsSchema(colander.Schema):
150 156 url = colander.SchemaNode(
151 157 colander.String(),
152 158 title=_('Webhook URL'),
153 159 description=
154 160 _('URL of the webhook to receive POST event. Following variables '
155 161 'are allowed to be used: {vars}. Some of the variables would '
156 162 'trigger multiple calls, like ${{branch}} or ${{commit_id}}. '
157 163 'Webhook will be called as many times as unique objects in '
158 164 'data in such cases.').format(vars=URL_VARS),
159 165 missing=colander.required,
160 166 required=True,
161 167 validator=colander.url,
162 168 widget=deform.widget.TextInputWidget(
163 169 placeholder='https://www.example.com/webhook'
164 170 ),
165 171 )
166 172 secret_token = colander.SchemaNode(
167 173 colander.String(),
168 174 title=_('Secret Token'),
169 175 description=_('String used to validate received payloads.'),
170 176 default='',
171 177 missing='',
172 178 widget=deform.widget.TextInputWidget(
173 179 placeholder='secret_token'
174 180 ),
175 181 )
176 182 method_type = colander.SchemaNode(
177 183 colander.String(),
178 184 title=_('Call Method'),
179 185 description=_('Select if the webhook call should be made '
180 186 'with POST or GET.'),
181 187 default='post',
182 188 missing='',
183 189 widget=deform.widget.RadioChoiceWidget(
184 190 values=[('get', 'GET'), ('post', 'POST')],
185 191 inline=True
186 192 ),
187 193 )
188 194
189 195
190 196 class WebhookIntegrationType(IntegrationTypeBase):
191 197 key = 'webhook'
192 198 display_name = _('Webhook')
193 199 description = _('Post json events to a webhook endpoint')
194 200 icon = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 239" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M119.540432,100.502743 C108.930124,118.338815 98.7646301,135.611455 88.3876025,152.753617 C85.7226696,157.154315 84.4040417,160.738531 86.5332204,166.333309 C92.4107024,181.787152 84.1193605,196.825836 68.5350381,200.908244 C53.8383677,204.759349 39.5192953,195.099955 36.6032893,179.365384 C34.0194114,165.437749 44.8274148,151.78491 60.1824106,149.608284 C61.4694072,149.424428 62.7821041,149.402681 64.944891,149.240571 C72.469175,136.623655 80.1773157,123.700312 88.3025935,110.073173 C73.611854,95.4654658 64.8677898,78.3885437 66.803227,57.2292132 C68.1712787,42.2715849 74.0527146,29.3462646 84.8033863,18.7517722 C105.393354,-1.53572199 136.805164,-4.82141828 161.048542,10.7510424 C184.333097,25.7086706 194.996783,54.8450075 185.906752,79.7822957 C179.052655,77.9239597 172.151111,76.049808 164.563565,73.9917997 C167.418285,60.1274266 165.306899,47.6765751 155.95591,37.0109123 C149.777932,29.9690049 141.850349,26.2780332 132.835442,24.9178894 C114.764113,22.1877169 97.0209573,33.7983633 91.7563309,51.5355878 C85.7800012,71.6669027 94.8245623,88.1111998 119.540432,100.502743 L119.540432,100.502743 Z" fill="#C73A63"></path><path d="M149.841194,79.4106285 C157.316054,92.5969067 164.905578,105.982857 172.427885,119.246236 C210.44865,107.483365 239.114472,128.530009 249.398582,151.063322 C261.81978,178.282014 253.328765,210.520191 228.933162,227.312431 C203.893073,244.551464 172.226236,241.605803 150.040866,219.46195 C155.694953,214.729124 161.376716,209.974552 167.44794,204.895759 C189.360489,219.088306 208.525074,218.420096 222.753207,201.614016 C234.885769,187.277151 234.622834,165.900356 222.138374,151.863988 C207.730339,135.66681 188.431321,135.172572 165.103273,150.721309 C155.426087,133.553447 145.58086,116.521995 136.210101,99.2295848 C133.05093,93.4015266 129.561608,90.0209366 122.440622,88.7873178 C110.547271,86.7253555 102.868785,76.5124151 102.408155,65.0698097 C101.955433,53.7537294 108.621719,43.5249733 119.04224,39.5394355 C129.363912,35.5914599 141.476705,38.7783085 148.419765,47.554004 C154.093621,54.7244134 155.896602,62.7943365 152.911402,71.6372484 C152.081082,74.1025091 151.00562,76.4886916 149.841194,79.4106285 L149.841194,79.4106285 Z" fill="#4B4B4B"></path><path d="M167.706921,187.209935 L121.936499,187.209935 C117.54964,205.253587 108.074103,219.821756 91.7464461,229.085759 C79.0544063,236.285822 65.3738898,238.72736 50.8136292,236.376762 C24.0061432,232.053165 2.08568567,207.920497 0.156179306,180.745298 C-2.02835403,149.962159 19.1309765,122.599149 47.3341915,116.452801 C49.2814904,123.524363 51.2485589,130.663141 53.1958579,137.716911 C27.3195169,150.919004 18.3639187,167.553089 25.6054984,188.352614 C31.9811726,206.657224 50.0900643,216.690262 69.7528413,212.809503 C89.8327554,208.847688 99.9567329,192.160226 98.7211371,165.37844 C117.75722,165.37844 136.809118,165.180745 155.847178,165.475311 C163.280522,165.591951 169.019617,164.820939 174.620326,158.267339 C183.840836,147.48306 200.811003,148.455721 210.741239,158.640984 C220.88894,169.049642 220.402609,185.79839 209.663799,195.768166 C199.302587,205.38802 182.933414,204.874012 173.240413,194.508846 C171.247644,192.37176 169.677943,189.835329 167.706921,187.209935 L167.706921,187.209935 Z" fill="#4A4A4A"></path></g></svg>'''
195 201
196 202 valid_events = [
197 203 events.PullRequestCloseEvent,
198 204 events.PullRequestMergeEvent,
199 205 events.PullRequestUpdateEvent,
200 206 events.PullRequestCommentEvent,
201 207 events.PullRequestReviewEvent,
202 208 events.PullRequestCreateEvent,
203 209 events.RepoPushEvent,
204 210 events.RepoCreateEvent,
205 211 ]
206 212
207 213 def settings_schema(self):
208 214 schema = WebhookSettingsSchema()
209 215 schema.add(colander.SchemaNode(
210 216 colander.Set(),
211 217 widget=deform.widget.CheckboxChoiceWidget(
212 218 values=sorted(
213 219 [(e.name, e.display_name) for e in self.valid_events]
214 220 )
215 221 ),
216 222 description="Events activated for this integration",
217 223 name='events'
218 224 ))
219 225 return schema
220 226
221 227 def send_event(self, event):
222 228 log.debug('handling event %s with webhook integration %s',
223 229 event.name, self)
224 230
225 231 if event.__class__ not in self.valid_events:
226 232 log.debug('event not valid: %r' % event)
227 233 return
228 234
229 235 if event.name not in self.settings['events']:
230 236 log.debug('event ignored: %r' % event)
231 237 return
232 238
233 239 data = event.as_dict()
234 240 template_url = self.settings['url']
235 241
236 242 handler = WebhookHandler(template_url, self.settings['secret_token'])
237 243 url_calls = handler(event, data)
238 244 log.debug('webhook: calling following urls: %s',
239 245 [x[0] for x in url_calls])
240 246 post_to_webhook(url_calls, self.settings)
241 247
242 248
243 249 @task(ignore_result=True)
244 250 def post_to_webhook(url_calls, settings):
245 251 max_retries = 3
246 252 for url, token, data in url_calls:
247 253 # retry max N times
248 254 retries = Retry(
249 255 total=max_retries,
250 256 backoff_factor=0.15,
251 257 status_forcelist=[500, 502, 503, 504])
252 258 req_session = requests.Session()
253 259 req_session.mount(
254 260 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
255 261
256 262 method = settings.get('method_type') or 'post'
257 263 call_method = getattr(req_session, method)
258 264
259 265 log.debug('calling WEBHOOK with method: %s', call_method)
260 266 resp = call_method(url, json={
261 267 'token': token,
262 268 'event': data
263 269 })
264 270 log.debug('Got WEBHOOK response: %s', resp)
265 271 resp.raise_for_status() # raise exception on a failed request
@@ -1,93 +1,97 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 pytest
22 22
23 23 from rhodecode import events
24 24 from rhodecode.lib.utils2 import AttributeDict
25 25 from rhodecode.integrations.types.webhook import WebhookHandler
26 26
27 27
28 28 @pytest.fixture
29 29 def base_data():
30 30 return {
31 31 'repo': {
32 32 'repo_name': 'foo',
33 33 'repo_type': 'hg',
34 34 'repo_id': '12',
35 'url': 'http://repo.url/foo'
35 'url': 'http://repo.url/foo',
36 },
37 'actor': {
38 'username': 'actor_name',
39 'user_id': 1
36 40 }
37 41 }
38 42
39 43
40 44 def test_webhook_parse_url_invalid_event():
41 45 template_url = 'http://server.com/${repo_name}/build'
42 46 handler = WebhookHandler(template_url, 'secret_token')
43 47 with pytest.raises(ValueError) as err:
44 48 handler(events.RepoDeleteEvent(''), {})
45 49 assert str(err.value).startswith('event type not supported')
46 50
47 51
48 52 @pytest.mark.parametrize('template,expected_urls', [
49 53 ('http://server.com/${repo_name}/build', ['http://server.com/foo/build']),
50 54 ('http://server.com/${repo_name}/${repo_type}', ['http://server.com/foo/hg']),
51 55 ('http://${server}.com/${repo_name}/${repo_id}', ['http://${server}.com/foo/12']),
52 56 ('http://server.com/${branch}/build', ['http://server.com/${branch}/build']),
53 57 ])
54 58 def test_webook_parse_url_for_create_event(base_data, template, expected_urls):
55 59 handler = WebhookHandler(template, 'secret_token')
56 60 urls = handler(events.RepoCreateEvent(''), base_data)
57 61 assert urls == [(url, 'secret_token', base_data) for url in expected_urls]
58 62
59 63
60 64 @pytest.mark.parametrize('template,expected_urls', [
61 65 ('http://server.com/${repo_name}/${pull_request_id}', ['http://server.com/foo/999']),
62 66 ('http://server.com/${repo_name}/${pull_request_url}', ['http://server.com/foo/http://pr-url.com']),
63 67 ])
64 68 def test_webook_parse_url_for_pull_request_event(base_data, template, expected_urls):
65 69 base_data['pullrequest'] = {
66 70 'pull_request_id': 999,
67 71 'url': 'http://pr-url.com',
68 72 }
69 73 handler = WebhookHandler(template, 'secret_token')
70 74 urls = handler(events.PullRequestCreateEvent(
71 75 AttributeDict({'target_repo': 'foo'})), base_data)
72 76 assert urls == [(url, 'secret_token', base_data) for url in expected_urls]
73 77
74 78
75 79 @pytest.mark.parametrize('template,expected_urls', [
76 80 ('http://server.com/${branch}/build', ['http://server.com/stable/build',
77 81 'http://server.com/dev/build']),
78 82 ('http://server.com/${branch}/${commit_id}', ['http://server.com/stable/stable-xxx',
79 83 'http://server.com/stable/stable-yyy',
80 84 'http://server.com/dev/dev-xxx',
81 85 'http://server.com/dev/dev-yyy']),
82 86 ])
83 87 def test_webook_parse_url_for_push_event(pylonsapp, repo_push_event, base_data, template, expected_urls):
84 88 base_data['push'] = {
85 89 'branches': [{'name': 'stable'}, {'name': 'dev'}],
86 90 'commits': [{'branch': 'stable', 'raw_id': 'stable-xxx'},
87 91 {'branch': 'stable', 'raw_id': 'stable-yyy'},
88 92 {'branch': 'dev', 'raw_id': 'dev-xxx'},
89 93 {'branch': 'dev', 'raw_id': 'dev-yyy'}]
90 94 }
91 95 handler = WebhookHandler(template, 'secret_token')
92 96 urls = handler(repo_push_event, base_data)
93 97 assert urls == [(url, 'secret_token', base_data) for url in expected_urls]
General Comments 0
You need to be logged in to leave comments. Login now