Merge lp:~canonical-isd-hackers/canonical-identity-provider/readonly-improvements into lp:canonical-identity-provider/release
- readonly-improvements
- Merge into trunk
Proposed by
Anthony Lenton
Status: | Merged |
---|---|
Merged at revision: | 6 |
Proposed branch: | lp:~canonical-isd-hackers/canonical-identity-provider/readonly-improvements |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
475 lines (+203/-49) 8 files modified
.bzrignore (+3/-0) config.example/settings.py (+4/-1) doctests/stories/api-workflows.txt (+1/-1) identityprovider/management/commands/readonly.py (+5/-3) identityprovider/templates/admin/readonly.txt (+2/-1) identityprovider/tests/test_command_readonly.py (+29/-19) identityprovider/tests/test_readonly.py (+123/-11) identityprovider/views/readonly.py (+36/-13) |
To merge this branch: | bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/readonly-improvements |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ricardo Kirkner (community) | Approve | ||
Review via email: mp+24707@code.launchpad.net |
Commit message
Description of the change
This branch includes fixes for bug #544370, bug #524611 and bug #538014:
- Allow readonly code to connect to https servers, and using virtual hosts.
- The readonly_secret setting has been removed and replaced with the standard secret_key setting.
- app_servers is filtered out for duplicates. And did the same for db_connections, for free.
This is untidy, as it's fixing three bugs at once, I know. I thought it would be better to have them all together as two of them have practically no QA involved, but I should have made three branches still, and eventually deploy them all together to ec2.
Let me know if I should break this up for better review.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2010-05-03 14:35:12 +0000 |
3 | +++ .bzrignore 2010-05-05 16:31:24 +0000 |
4 | @@ -1,2 +1,5 @@ |
5 | .config |
6 | local.cfg |
7 | +coverage |
8 | +.coverage |
9 | +.coverage.* |
10 | |
11 | === modified file 'config.example/settings.py' |
12 | --- config.example/settings.py 2010-04-23 15:37:57 +0000 |
13 | +++ config.example/settings.py 2010-05-05 16:31:24 +0000 |
14 | @@ -41,7 +41,10 @@ |
15 | # api settings |
16 | API_ENABLED = False |
17 | API_HOST = 'openid.localdomain' |
18 | -APP_SERVERS = ['localhost'] |
19 | +APP_SERVERS = [{'SERVER_ID': 'localhost', |
20 | + 'HOST': 'localhost', |
21 | + 'PORT': 8000, |
22 | + }] |
23 | LAZR_RESTFUL_CODE_REVISION = '1' |
24 | LAZR_RESTFUL_HOSTNAME = 'sso' |
25 | LAZR_RESTFUL_PATH_OVERRIDE = None |
26 | |
27 | === modified file 'doctests/stories/api-workflows.txt' |
28 | --- doctests/stories/api-workflows.txt 2010-04-21 15:29:24 +0000 |
29 | +++ doctests/stories/api-workflows.txt 2010-05-05 16:31:24 +0000 |
30 | @@ -148,7 +148,7 @@ |
31 | in return you'll get dictionary: |
32 | |
33 | >>> api.accounts.me() |
34 | - {u'username': ..., u'preferred_email': None, u'displayname': u'', u'unverified_emails': [u'blu@bli.com'], u'verified_emails': [], u'openid_identifier': ...} |
35 | + {u'username': ..., u'preferred_email': None, u'displayname': ..., u'unverified_emails': [u'blu@bli.com'], u'verified_emails': [], u'openid_identifier': ...} |
36 | |
37 | Also you can ask weather given user is a member of list of teams: |
38 | |
39 | |
40 | === modified file 'identityprovider/management/commands/readonly.py' |
41 | --- identityprovider/management/commands/readonly.py 2010-04-21 15:29:24 +0000 |
42 | +++ identityprovider/management/commands/readonly.py 2010-05-05 16:31:24 +0000 |
43 | @@ -52,12 +52,14 @@ |
44 | |
45 | # determine servers to act upon |
46 | if all_servers: |
47 | - servers = settings.APP_SERVERS |
48 | + servers = [app['SERVER_ID'] for app in settings.APP_SERVERS] |
49 | servers.sort() |
50 | else: |
51 | if not args and not list_servers: |
52 | - msg = _('Enter at least one server, ' |
53 | - 'or specify the --all option.') |
54 | + msg = _('Enter at least one server, or specify the' |
55 | + ' --all option.') + '\n ' + _('Use --list to get' |
56 | + ' a list of configured servers.') |
57 | + |
58 | raise CommandError(msg) |
59 | servers = args |
60 | |
61 | |
62 | === modified file 'identityprovider/templates/admin/readonly.txt' |
63 | --- identityprovider/templates/admin/readonly.txt 2010-04-21 15:29:24 +0000 |
64 | +++ identityprovider/templates/admin/readonly.txt 2010-05-05 16:31:24 +0000 |
65 | @@ -1,7 +1,8 @@ |
66 | +{% comment %} |
67 | Copyright 2010 Canonical Ltd. This software is licensed under the |
68 | GNU Affero General Public License version 3 (see the file LICENSE). |
69 | |
70 | -Readonly status per application server |
71 | +{% endcomment %}Readonly status per application server |
72 | ------------------------------------------------------------------------------- |
73 | {% for server in appservers %} |
74 | {{server.name}} -- {% if server.reachable %}{% if server.readonly %}In readonly mode{% else %}Operating normally{% endif %}{% else %}Server is unreachable or out of sync{% endif %}{% if server.reachable %}{% for conn in server.connections %} |
75 | |
76 | === modified file 'identityprovider/tests/test_command_readonly.py' |
77 | --- identityprovider/tests/test_command_readonly.py 2010-04-21 15:29:24 +0000 |
78 | +++ identityprovider/tests/test_command_readonly.py 2010-05-05 16:31:24 +0000 |
79 | @@ -2,7 +2,7 @@ |
80 | # GNU Affero General Public License version 3 (see the file LICENSE). |
81 | |
82 | import StringIO |
83 | -import copy |
84 | +from copy import copy, deepcopy |
85 | import shutil |
86 | import sys |
87 | import tempfile |
88 | @@ -24,22 +24,30 @@ |
89 | sys.stdout = StringIO.StringIO() |
90 | sys.stderr = StringIO.StringIO() |
91 | |
92 | - self.servers = ['localhost:8000', 'localhost:8001'] |
93 | + self.servers = [{'SERVER_ID': 'localhost', |
94 | + 'HOST': 'localhost', |
95 | + 'PORT': '8000', |
96 | + }, |
97 | + {'SERVER_ID': 'otherhost', |
98 | + 'HOST': 'localhost', |
99 | + 'PORT': '8001', |
100 | + } |
101 | + ] |
102 | self._APP_SERVERS = settings.APP_SERVERS |
103 | settings.APP_SERVERS = self.servers |
104 | self._DBFAILOVER_FLAG_DIR = getattr(settings, 'DBFAILOVER_FLAG_DIR', |
105 | None) |
106 | settings.DBFAILOVER_FLAG_DIR = tempfile.mkdtemp() |
107 | - self._DB_CONNECTIONS = settings.DB_CONNECTIONS |
108 | - backup_db = copy.copy(settings.DB_CONNECTIONS[0]) |
109 | + self._DB_CONNECTIONS = deepcopy(settings.DB_CONNECTIONS) |
110 | + backup_db = copy(settings.DB_CONNECTIONS[0]) |
111 | backup_db['DATABASE_ID'] = 'backup' |
112 | settings.DB_CONNECTIONS.append(backup_db) |
113 | |
114 | # setup wsgi intercept mechanism to simulate wsgi server |
115 | urllib2_intercept.install_opener() |
116 | for server in self.servers: |
117 | - host, port = server.split(':') |
118 | - add_wsgi_intercept(host, int(port), WSGIHandler) |
119 | + add_wsgi_intercept(server['HOST'], int(server['PORT']), |
120 | + WSGIHandler) |
121 | |
122 | def tearDown(self): |
123 | # make sure we're clear everything up before leaving |
124 | @@ -48,14 +56,14 @@ |
125 | |
126 | # remove wsgi intercept mechanism |
127 | for server in self.servers: |
128 | - host, port = server.split(':') |
129 | - remove_wsgi_intercept(host, int(port)) |
130 | + remove_wsgi_intercept(server['HOST'], int(server['PORT'])) |
131 | urllib2_intercept.uninstall_opener() |
132 | |
133 | settings.APP_SERVERS = self._APP_SERVERS |
134 | shutil.rmtree(settings.DBFAILOVER_FLAG_DIR, True) |
135 | settings.DBFAILOVER_FLAG_DIR = self._DBFAILOVER_FLAG_DIR |
136 | settings.DB_CONNECTIONS = self._DB_CONNECTIONS |
137 | + readonly_manager.set_db(settings.DB_CONNECTIONS[0]) |
138 | |
139 | sys.stdout = self._stdout |
140 | sys.stderr = self._stderr |
141 | @@ -71,44 +79,46 @@ |
142 | |
143 | def test_readonly_list_all(self): |
144 | output = self.get_status() |
145 | - self.assertTrue(self.servers[0] in output) |
146 | + self.assertTrue(self.servers[0]['SERVER_ID'] in output) |
147 | |
148 | def test_readonly_set(self): |
149 | - call_command('readonly', self.servers[0], action='set') |
150 | + call_command('readonly', self.servers[0]['SERVER_ID'], action='set') |
151 | readonly_manager.check_readonly() |
152 | self.assertTrue(readonly_manager.is_readonly()) |
153 | |
154 | def test_readonly_clear(self): |
155 | - call_command('readonly', self.servers[0], action='clear') |
156 | + call_command('readonly', self.servers[0]['SERVER_ID'], action='clear') |
157 | readonly_manager.check_readonly() |
158 | self.assertFalse(readonly_manager.is_readonly()) |
159 | |
160 | def test_readonly_enable(self): |
161 | # prepare test |
162 | - call_command('readonly', self.servers[0], action='disable', |
163 | - database='master') |
164 | + call_command('readonly', self.servers[0]['SERVER_ID'], |
165 | + action='disable', database='master') |
166 | # perform test |
167 | - call_command('readonly', self.servers[0], action='enable', |
168 | - database='master') |
169 | + call_command('readonly', self.servers[0]['SERVER_ID'], |
170 | + action='enable', database='master') |
171 | self.assertFalse(readonly_manager.is_failed('master')) |
172 | |
173 | def test_readonly_disable(self): |
174 | - call_command('readonly', self.servers[0], action='disable', |
175 | - database='master') |
176 | + call_command('readonly', self.servers[0]['SERVER_ID'], |
177 | + action='disable', database='master') |
178 | self.assertTrue(readonly_manager.is_failed('master')) |
179 | |
180 | def test_readonly_set_all(self): |
181 | call_command('readonly', action='set', all_servers=True) |
182 | output = self.get_status() |
183 | for server in settings.APP_SERVERS: |
184 | - self.assertTrue("%s -- In readonly mode" % server in output) |
185 | + expected = "%s -- In readonly mode" % server['SERVER_ID'] |
186 | + self.assertTrue(expected in output) |
187 | |
188 | def test_readonly_clear_all(self): |
189 | call_command('readonly', self.servers[0], action='clear', |
190 | all_servers=True) |
191 | output = self.get_status() |
192 | for server in settings.APP_SERVERS: |
193 | - self.assertTrue("%s -- Operating normally" % server in output) |
194 | + expected = "%s -- Operating normally" % server['SERVER_ID'] |
195 | + self.assertTrue(expected in output) |
196 | |
197 | def test_readonly_enable_all(self): |
198 | call_command('readonly', action='enable', database='master', |
199 | |
200 | === modified file 'identityprovider/tests/test_readonly.py' |
201 | --- identityprovider/tests/test_readonly.py 2010-04-21 15:29:24 +0000 |
202 | +++ identityprovider/tests/test_readonly.py 2010-05-05 16:31:24 +0000 |
203 | @@ -4,8 +4,12 @@ |
204 | import socket |
205 | import urllib2 |
206 | from StringIO import StringIO |
207 | + |
208 | +from django.conf import settings |
209 | +from django.utils import simplejson |
210 | + |
211 | +from identityprovider.readonly import readonly_manager |
212 | from identityprovider.tests.utils import SQLCachedTestCase |
213 | -from django.conf import settings |
214 | from identityprovider import models |
215 | from identityprovider.backend.base import DatabaseError |
216 | from identityprovider.views.readonly import _remote_req |
217 | @@ -40,7 +44,12 @@ |
218 | self.assertRaises(DatabaseError, p.save) |
219 | |
220 | |
221 | -class ReadOnlyAdminTest(SQLCachedTestCase): |
222 | +class RemoteRequestTest(SQLCachedTestCase): |
223 | + msg = 'hello' |
224 | + host = 'myhost' |
225 | + scheme = 'https' |
226 | + vhost = 'http://foobar.baz' |
227 | + |
228 | def setup_mock_urlopen(self, func): |
229 | self.orig_urlopen = urllib2.urlopen |
230 | urllib2.urlopen = func |
231 | @@ -48,12 +57,115 @@ |
232 | def restore_orig_urlopen(self): |
233 | urllib2.urlopen = self.orig_urlopen |
234 | |
235 | - def test_remote_req(self): |
236 | - msg = 'hello' |
237 | - |
238 | - def mock_urlopen(url, data=None): |
239 | - self.assertEquals(5, socket.getdefaulttimeout()) |
240 | - return StringIO(msg) |
241 | - self.setup_mock_urlopen(mock_urlopen) |
242 | - self.assertEquals(msg, _remote_req('localhost')) |
243 | - self.restore_orig_urlopen() |
244 | + def mock_urlopen(self, req, data=None): |
245 | + self.req = req |
246 | + self.assertEquals(5, socket.getdefaulttimeout()) |
247 | + return StringIO(self.msg) |
248 | + |
249 | + def test_plain_remote_req(self): |
250 | + self.setup_mock_urlopen(self.mock_urlopen) |
251 | + server = {'host': self.host} |
252 | + self.assertEquals(self.msg, _remote_req(**server)) |
253 | + self.assertEquals(self.host, self.req.get_host()) |
254 | + self.restore_orig_urlopen() |
255 | + |
256 | + def test_https_remote_req(self): |
257 | + self.setup_mock_urlopen(self.mock_urlopen) |
258 | + server = {'host': self.host, |
259 | + 'scheme': self.scheme, |
260 | + } |
261 | + self.assertEquals(self.msg, _remote_req(**server)) |
262 | + self.assertEquals(self.scheme, self.req.get_type()) |
263 | + self.assertEquals(self.host, self.req.get_host()) |
264 | + self.restore_orig_urlopen() |
265 | + |
266 | + def test_vhost_remote_req(self): |
267 | + self.setup_mock_urlopen(self.mock_urlopen) |
268 | + server = {'host': self.host, |
269 | + 'scheme': self.scheme, |
270 | + 'virtual_host': self.vhost, |
271 | + } |
272 | + self.assertEquals(self.msg, _remote_req(**server)) |
273 | + headers = {'Host': self.vhost} |
274 | + self.assertEquals(headers, self.req.headers) |
275 | + self.assertEquals(self.scheme, self.req.get_type()) |
276 | + self.assertEquals(self.host, self.req.get_host()) |
277 | + self.restore_orig_urlopen() |
278 | + |
279 | +class ReadOnlyDataTest(SQLCachedTestCase): |
280 | + def setUp(self): |
281 | + self.orig_readonly_secret = settings.READONLY_SECRET |
282 | + settings.READONLY_SECRET = 'test secret' |
283 | + self.orig_db_connections = settings.DB_CONNECTIONS |
284 | + settings.DB_CONNECTIONS = [{'DATABASE_ID': 'master', |
285 | + 'DATABASE_HOST': 'dbhost1', |
286 | + 'DATABASE_NAME': 'masterdb', |
287 | + }, |
288 | + {'DATABASE_ID': 'slave', |
289 | + 'DATABASE_HOST': 'dbhost2', |
290 | + 'DATABASE_NAME': 'slavedb', |
291 | + }] |
292 | + |
293 | + def tearDown(self): |
294 | + settings.READONLY_SECRET = self.orig_readonly_secret |
295 | + settings.DB_CONNECTIONS = self.orig_db_connections |
296 | + readonly_manager.clear_readonly() |
297 | + readonly_manager.clear_failed('master') |
298 | + readonly_manager.set_db(settings.DB_CONNECTIONS[0]) |
299 | + |
300 | + def test_readonly_returns_404_for_get(self): |
301 | + response = self.client.get('/readonlydata') |
302 | + self.assertEquals(404, response.status_code) |
303 | + |
304 | + def test_readonly_returns_404_without_readonly_secret(self): |
305 | + response = self.client.post('/readonlydata') |
306 | + self.assertEquals(404, response.status_code) |
307 | + |
308 | + def test_readonly_returns_404_with_bad_secret(self): |
309 | + data = {'secret': 'something different'} |
310 | + response = self.client.post('/readonlydata', data) |
311 | + self.assertEquals(404, response.status_code) |
312 | + |
313 | + def test_readonly_returns_200_with_right_secret(self): |
314 | + data = {'secret': settings.READONLY_SECRET} |
315 | + response = self.client.post('/readonlydata', data) |
316 | + self.assertEquals(200, response.status_code) |
317 | + |
318 | + def test_readonly_data(self): |
319 | + post = {'secret': settings.READONLY_SECRET} |
320 | + response = self.client.post('/readonlydata', post) |
321 | + data = simplejson.loads(response.content) |
322 | + self.assertTrue('readonly' in data) |
323 | + self.assertTrue('connections' in data) |
324 | + |
325 | + def test_readonly_set_readonly(self): |
326 | + post = {'secret': settings.READONLY_SECRET, 'action': 'set'} |
327 | + response = self.client.post('/readonlydata', post) |
328 | + data = simplejson.loads(response.content) |
329 | + self.assertTrue(data['readonly']) |
330 | + |
331 | + def test_readonly_clear_readonly(self): |
332 | + post = {'secret': settings.READONLY_SECRET, 'action': 'clear'} |
333 | + response = self.client.post('/readonlydata', post) |
334 | + data = simplejson.loads(response.content) |
335 | + self.assertFalse(data['readonly']) |
336 | + |
337 | + def test_readonly_disable_master(self): |
338 | + post = {'secret': settings.READONLY_SECRET, |
339 | + 'action': 'disable', |
340 | + 'conn': 'master'} |
341 | + response = self.client.post('/readonlydata', post) |
342 | + data = simplejson.loads(response.content) |
343 | + self.assertTrue(data['connections'][0]['failed']) |
344 | + self.assertTrue(data['readonly']) |
345 | + |
346 | + def test_readonly_reenable_master(self): |
347 | + post = {'secret': settings.READONLY_SECRET, |
348 | + 'action': 'disable', |
349 | + 'conn': 'master'} |
350 | + response = self.client.post('/readonlydata', post) |
351 | + post['action'] = 'enable' |
352 | + response = self.client.post('/readonlydata', post) |
353 | + data = simplejson.loads(response.content) |
354 | + self.assertFalse(data['connections'][0]['failed']) |
355 | + self.assertTrue(data['readonly']) |
356 | |
357 | === modified file 'identityprovider/views/readonly.py' |
358 | --- identityprovider/views/readonly.py 2010-04-21 15:29:24 +0000 |
359 | +++ identityprovider/views/readonly.py 2010-05-05 16:31:24 +0000 |
360 | @@ -12,18 +12,44 @@ |
361 | import urllib |
362 | import urllib2 |
363 | |
364 | +def lowercase_keys(dicts): |
365 | + """ Convert all keys in a list of dicts to lowercase. """ |
366 | + return [dict((k.lower(), v) for (k, v) in d.items()) for d in dicts] |
367 | |
368 | -def _remote_req(server, post=None): |
369 | +def _remote_req(host, port=None, scheme=None, server_id=None, |
370 | + virtual_host=None, post=None): |
371 | """Makes a request to a specific appserver. |
372 | |
373 | - if post is provided, it should be a sequence of 2-tuples that will be |
374 | + The first five arguments are the same as the keys for each dictionary in |
375 | + the APP_SERVERS setting: |
376 | + |
377 | + * host: The host at which we can reach the app server (can be an IP) |
378 | + * port: The port that should be used (optional) |
379 | + * scheme: The scheme to use to contact this app server (http/https) |
380 | + (defaults to http) |
381 | + * server_id: Some canonical name for this app server (optional) |
382 | + * virtual_host: The virtual host that should be used, for app servers that |
383 | + serve multiple sites (optional). |
384 | + |
385 | + If post is provided, it should be a sequence of 2-tuples that will be |
386 | encoded in to POST data. |
387 | """ |
388 | if post is None: |
389 | post = [] |
390 | post.append(('secret', settings.READONLY_SECRET)) |
391 | post = urllib.urlencode(post) |
392 | - req = urllib2.Request('http://%s/readonlydata' % server, data=post) |
393 | + |
394 | + if scheme is None: |
395 | + scheme = 'http' |
396 | + if port is None: |
397 | + portstr = '' |
398 | + else: |
399 | + portstr = ':' + port |
400 | + url = '%s://%s%s/readonlydata' % (scheme, host, portstr) |
401 | + headers = {} |
402 | + if virtual_host is not None: |
403 | + headers['Host'] = virtual_host |
404 | + req = urllib2.Request(url, headers=headers, data=post) |
405 | try: |
406 | oldtimeout = socket.getdefaulttimeout() |
407 | socket.setdefaulttimeout(5) |
408 | @@ -34,20 +60,18 @@ |
409 | data = None |
410 | return data |
411 | |
412 | - |
413 | @staff_member_required |
414 | def readonly_admin(request): |
415 | atts = get_server_atts(settings.APP_SERVERS) |
416 | return render_to_response('admin/readonly.html', atts) |
417 | |
418 | - |
419 | def get_server_atts(servers): |
420 | """Provides a report about readonly status of all app servers.""" |
421 | appservers = [] |
422 | set_all_readonly = False |
423 | clear_all_readonly = False |
424 | - for server in servers: |
425 | - datastr = _remote_req(server) |
426 | + for server in lowercase_keys(servers): |
427 | + datastr = _remote_req(**server) |
428 | if datastr is None: |
429 | data = {'reachable': False} |
430 | else: |
431 | @@ -63,7 +87,7 @@ |
432 | clear_all_readonly = True |
433 | else: |
434 | set_all_readonly = True |
435 | - data['name'] = server |
436 | + data['name'] = server['server_id'] |
437 | appservers.append(data) |
438 | atts = {'appservers': appservers, |
439 | 'admin_media_prefix': settings.ADMIN_MEDIA_PREFIX, |
440 | @@ -72,7 +96,6 @@ |
441 | } |
442 | return atts |
443 | |
444 | - |
445 | @staff_member_required |
446 | def readonly_confirm(request, action, appserver=None, conn=None): |
447 | if request.method == 'POST': |
448 | @@ -84,23 +107,23 @@ |
449 | } |
450 | return render_to_response('admin/readonly_confirm.html', atts) |
451 | |
452 | - |
453 | def update_server(action, appserver=None, conn=None): |
454 | if appserver is None: |
455 | appservers = settings.APP_SERVERS |
456 | else: |
457 | - appservers = [appserver] |
458 | + appservers = [server for server in settings.APP_SERVERS |
459 | + if server['SERVER_ID'] == appserver] |
460 | + appservers = lowercase_keys(appservers) |
461 | for server in appservers: |
462 | post = [('action', action), |
463 | ('conn', conn), |
464 | ] |
465 | - datastr = _remote_req(server, post) |
466 | + datastr = _remote_req(post=post, **server) |
467 | # If we're leaving readonly mode across all appservers, |
468 | # try to sync up data back from memcached to the DB. |
469 | if appserver is None and action == 'clear': |
470 | sync_memcached_to_db() |
471 | |
472 | - |
473 | def readonly_data(request): |
474 | """Provides data about the readonly status of this app server.""" |
475 | if request.method != 'POST': |
nice work.