Merge lp:~canonical-isd-hackers/canonical-identity-provider/readonly-improvements into lp:canonical-identity-provider/release

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
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+24707@code.launchpad.net

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.
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

nice work.

review: Approve

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':