Merge lp:~mwhudson/launchpad/feature-flag-xmlrpc-2 into lp:launchpad

Proposed by Michael Hudson-Doyle
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 14068
Proposed branch: lp:~mwhudson/launchpad/feature-flag-xmlrpc-2
Merge into: lp:launchpad
Diff against target: 569 lines (+268/-39)
13 files modified
configs/development/launchpad-lazr.conf (+1/-0)
lib/canonical/config/schema-lazr.conf (+6/-0)
lib/canonical/launchpad/interfaces/launchpad.py (+2/-0)
lib/canonical/launchpad/xmlrpc/application.py (+6/-1)
lib/lp/services/features/__init__.py (+12/-0)
lib/lp/services/features/configure.zcml (+7/-0)
lib/lp/services/features/scopes.py (+61/-26)
lib/lp/services/features/tests/test_scopes.py (+5/-3)
lib/lp/services/features/tests/test_webapp.py (+5/-5)
lib/lp/services/features/tests/test_xmlrpc.py (+99/-0)
lib/lp/services/features/xmlrpc.py (+59/-0)
lib/lp/testing/factory.py (+2/-2)
utilities/page-performance-report.ini (+3/-2)
To merge this branch: bzr merge lp:~mwhudson/launchpad/feature-flag-xmlrpc-2
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+76493@code.launchpad.net

Commit message

[r=lifeless][bug=853635] Add a private XML-RPC interface to query feature flags

Description of the change

This relands my branch https://code.launchpad.net/~mwhudson/launchpad/feature-flag-xmlrpc/+merge/75673 which broke because feature controllers are installed before authentication happens, so looking at request.principal at that time doesn't work.

The change that fixes the problem is here: http://bazaar.launchpad.net/~mwhudson/launchpad/feature-flag-xmlrpc-2/revision/14013

I haven't added an integration level test, because it would be a lot of work I think (you could register a view with a template that accesses a feature flag and then use testbrowser to load it) and I'm not sure it would have caught this problem anyway (are you really sure that the way testbrowser does authentication is that similar to how it happens in a real appserver? maybe it is, but I don't know). I can add one if the reviewer thinks it's a good idea though.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

Thanks.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'configs/development/launchpad-lazr.conf'
2--- configs/development/launchpad-lazr.conf 2011-09-24 03:03:08 +0000
3+++ configs/development/launchpad-lazr.conf 2011-09-28 03:34:29 +0000
4@@ -158,6 +158,7 @@
5 geonames_identity: lpdev
6 storm_cache: generational
7 storm_cache_size: 100
8+feature_flags_endpoint: http://xmlrpc-private.launchpad.dev:8087/featureflags/
9
10 [launchpad_session]
11 cookie: launchpad_dev
12
13=== modified file 'lib/canonical/config/schema-lazr.conf'
14--- lib/canonical/config/schema-lazr.conf 2011-09-24 02:40:51 +0000
15+++ lib/canonical/config/schema-lazr.conf 2011-09-28 03:34:29 +0000
16@@ -1211,6 +1211,12 @@
17 # The number of items to display:
18 homepage_recent_posts_count: 6
19
20+# The URL of the XML-RPC endpoint that allows querying of feature
21+# flags. This should implement IFeatureFlagApplication.
22+#
23+# datatype: string
24+feature_flags_endpoint:
25+
26 [launchpad_session]
27 # The database connection string.
28 # datatype: pgconnection
29
30=== modified file 'lib/canonical/launchpad/interfaces/launchpad.py'
31--- lib/canonical/launchpad/interfaces/launchpad.py 2011-09-21 14:38:47 +0000
32+++ lib/canonical/launchpad/interfaces/launchpad.py 2011-09-28 03:34:29 +0000
33@@ -152,6 +152,8 @@
34 softwarecenteragent = Attribute(
35 """Software center agent XML-RPC end point.""")
36
37+ featureflags = Attribute("""Feature flag information endpoint""")
38+
39
40 class IAuthServerApplication(ILaunchpadApplication):
41 """Launchpad legacy AuthServer application root."""
42
43=== modified file 'lib/canonical/launchpad/xmlrpc/application.py'
44--- lib/canonical/launchpad/xmlrpc/application.py 2011-09-21 14:38:47 +0000
45+++ lib/canonical/launchpad/xmlrpc/application.py 2011-09-28 03:34:29 +0000
46@@ -36,6 +36,7 @@
47 )
48 from lp.registry.interfaces.mailinglist import IMailingListApplication
49 from lp.registry.interfaces.person import ISoftwareCenterAgentApplication
50+from lp.services.features.xmlrpc import IFeatureFlagApplication
51
52
53 # NOTE: If you add a traversal here, you should update
54@@ -73,6 +74,11 @@
55 """See `IPrivateApplication`."""
56 return getUtility(ISoftwareCenterAgentApplication)
57
58+ @property
59+ def featureflags(self):
60+ """See `IPrivateApplication`."""
61+ return getUtility(IFeatureFlagApplication)
62+
63
64 class ISelfTest(Interface):
65 """XMLRPC external interface for testing the XMLRPC external interface."""
66@@ -127,4 +133,3 @@
67
68 def run_test(self):
69 return "OK"
70-
71
72=== modified file 'lib/lp/services/features/__init__.py'
73--- lib/lp/services/features/__init__.py 2011-09-21 14:38:47 +0000
74+++ lib/lp/services/features/__init__.py 2011-09-28 03:34:29 +0000
75@@ -125,6 +125,18 @@
76 if value:
77 print value
78
79+Checking flags without access to the database
80+=============================================
81+
82+Feature flags can also be checked without access to the database by making use
83+of the 'getFeatureFlag' XML-RPC method.
84+
85+ server_proxy = xmlrpclib.ServerProxy(
86+ config.launchpad.feature_flags_endpoint, allow_none=True)
87+ if server_proxy.getFeatureFlag(
88+ 'codehosting.use_forking_server', ['user:' + user_name]):
89+ pass
90+
91 Debugging feature usage
92 =======================
93
94
95=== modified file 'lib/lp/services/features/configure.zcml'
96--- lib/lp/services/features/configure.zcml 2011-09-21 14:38:47 +0000
97+++ lib/lp/services/features/configure.zcml 2011-09-28 03:34:29 +0000
98@@ -19,4 +19,11 @@
99 handler="lp.services.features.webapp.end_request"
100 />
101
102+ <securedutility
103+ class="lp.services.features.xmlrpc.FeatureFlagApplication"
104+ provides="lp.services.features.xmlrpc.IFeatureFlagApplication">
105+ <allow
106+ interface="lp.services.features.xmlrpc.IFeatureFlagApplication"/>
107+ </securedutility>
108+
109 </configure>
110
111=== modified file 'lib/lp/services/features/scopes.py'
112--- lib/lp/services/features/scopes.py 2011-09-21 14:38:47 +0000
113+++ lib/lp/services/features/scopes.py 2011-09-28 03:34:29 +0000
114@@ -9,9 +9,14 @@
115 """
116
117 __all__ = [
118+ 'DefaultScope',
119+ 'default_scopes',
120+ 'FixedScope',
121 'HANDLERS',
122+ 'MultiScopeHandler',
123 'ScopesForScript',
124 'ScopesFromRequest',
125+ 'TeamScope',
126 'undocumented_scopes',
127 ]
128
129@@ -19,9 +24,7 @@
130
131 import re
132
133-from zope.component import getUtility
134-
135-from canonical.launchpad.webapp.interfaces import ILaunchBag
136+from lp.registry.interfaces.person import IPerson
137 from lp.services.propertycache import cachedproperty
138 import canonical.config
139
140@@ -60,14 +63,7 @@
141 return True
142
143
144-class BaseWebRequestScope(BaseScope):
145- """Base class for scopes that key off web request attributes."""
146-
147- def __init__(self, request):
148- self.request = request
149-
150-
151-class PageScope(BaseWebRequestScope):
152+class PageScope(BaseScope):
153 """The current page ID.
154
155 Pageid scopes are written as 'pageid:' + the pageid to match. Pageids
156@@ -81,6 +77,9 @@
157
158 pattern = r'pageid:'
159
160+ def __init__(self, request):
161+ self._request = request
162+
163 def lookup(self, scope_name):
164 """Is the given scope match the current pageid?"""
165 pageid_scope = scope_name[len('pageid:'):]
166@@ -105,20 +104,32 @@
167 @cachedproperty
168 def _request_pageid_namespace(self):
169 return tuple(self._pageid_to_namespace(
170- self.request._orig_env.get('launchpad.pageid', '')))
171+ self._request._orig_env.get('launchpad.pageid', '')))
172
173
174 class TeamScope(BaseScope):
175- """The current user's team memberships.
176+ """A user's team memberships.
177
178 Team ID scopes are written as 'team:' + the team name to match.
179
180 The scope 'team:launchpad-beta-users' will match members of the team
181 'launchpad-beta-users'.
182+
183+ The constructor takes a callable that returns the currently logged in
184+ person because Scopes are set up very early in the request publication
185+ process -- in particular, before authentication has happened.
186 """
187
188 pattern = r'team:'
189
190+ def __init__(self, get_person):
191+ self._get_person = get_person
192+ self._person = None
193+
194+ @cachedproperty
195+ def person(self):
196+ return self._get_person()
197+
198 def lookup(self, scope_name):
199 """Is the given scope a team membership?
200
201@@ -126,11 +137,9 @@
202 team based scopes in use to a small number. (Person.inTeam could be
203 fixed to reduce this to one query).
204 """
205- team_name = scope_name[len('team:'):]
206- person = getUtility(ILaunchBag).user
207- if person is None:
208- return False
209- return person.inTeam(team_name)
210+ if self.person is not None:
211+ team_name = scope_name[len('team:'):]
212+ return self.person.inTeam(team_name)
213
214
215 class ServerScope(BaseScope):
216@@ -169,6 +178,20 @@
217 return scope_name == self.script_scope
218
219
220+class FixedScope(BaseScope):
221+ """A scope that matches an exact value.
222+
223+ Functionally `ScriptScope` and `DefaultScope` are equivalent to instances
224+ of this class, but their docstings are used on the +feature-info page.
225+ """
226+
227+ def __init__(self, scope):
228+ self.pattern = re.escape(scope) + '$'
229+
230+ def lookup(self, scope_name):
231+ return True
232+
233+
234 # These are the handlers for all of the allowable scopes, listed here so that
235 # we can for example show all of them in an admin page. Any new scope will
236 # need a scope handler and that scope handler has to be added to this list.
237@@ -213,21 +236,33 @@
238 undocumented_scopes.add(scope_name)
239
240
241+default_scopes = (DefaultScope(),)
242+
243+
244 class ScopesFromRequest(MultiScopeHandler):
245- """Identify feature scopes based on request state."""
246+ """Identify feature scopes based on request state.
247+
248+ Because the feature controller is constructed very very early in the
249+ publication process, this needs to be very careful about looking at the
250+ request -- in particular, this is called before authentication happens.
251+ """
252
253 def __init__(self, request):
254- super(ScopesFromRequest, self).__init__([
255- DefaultScope(),
256+ def person_from_request():
257+ return IPerson(request.principal, None)
258+ scopes = list(default_scopes)
259+ scopes.extend([
260 PageScope(request),
261- TeamScope(),
262- ServerScope()])
263+ ServerScope(),
264+ TeamScope(person_from_request)
265+ ])
266+ super(ScopesFromRequest, self).__init__(scopes)
267
268
269 class ScopesForScript(MultiScopeHandler):
270 """Identify feature scopes for a given script."""
271
272 def __init__(self, script_name):
273- super(ScopesForScript, self).__init__([
274- DefaultScope(),
275- ScriptScope(script_name)])
276+ scopes = list(default_scopes)
277+ scopes.append(ScriptScope(script_name))
278+ super(ScopesForScript, self).__init__(scopes)
279
280=== modified file 'lib/lp/services/features/tests/test_scopes.py'
281--- lib/lp/services/features/tests/test_scopes.py 2011-09-21 14:38:47 +0000
282+++ lib/lp/services/features/tests/test_scopes.py 2011-09-28 03:34:29 +0000
283@@ -9,18 +9,20 @@
284 from lp.testing import TestCaseWithFactory
285 from lp.services.features.scopes import (
286 BaseScope,
287- BaseWebRequestScope,
288 MultiScopeHandler,
289 ScopesForScript,
290 ScriptScope,
291 )
292
293
294-class FakeScope(BaseWebRequestScope):
295+class FakeScope(BaseScope):
296 pattern = r'fake:'
297
298+ def __init__(self, name):
299+ self.name = name
300+
301 def lookup(self, scope_name):
302- return scope_name == (self.pattern + self.request)
303+ return scope_name == (self.pattern + self.name)
304
305
306 class TestScopes(TestCaseWithFactory):
307
308=== modified file 'lib/lp/services/features/tests/test_webapp.py'
309--- lib/lp/services/features/tests/test_webapp.py 2011-09-21 14:38:47 +0000
310+++ lib/lp/services/features/tests/test_webapp.py 2011-09-28 03:34:29 +0000
311@@ -24,8 +24,8 @@
312
313 def test_pageid_scope_normal(self):
314 request = LaunchpadTestRequest()
315+ scopes = webapp.ScopesFromRequest(request)
316 request.setInWSGIEnvironment('launchpad.pageid', 'foo:bar')
317- scopes = webapp.ScopesFromRequest(request)
318 self.assertTrue(scopes.lookup('pageid:'))
319 self.assertTrue(scopes.lookup('pageid:foo'))
320 self.assertTrue(scopes.lookup('pageid:foo:bar'))
321@@ -33,8 +33,8 @@
322
323 def test_pageid_scope_collection(self):
324 request = LaunchpadTestRequest()
325+ scopes = webapp.ScopesFromRequest(request)
326 request.setInWSGIEnvironment('launchpad.pageid', 'scoped:thing:#type')
327- scopes = webapp.ScopesFromRequest(request)
328 self.assertTrue(scopes.lookup('pageid:'))
329 self.assertTrue(scopes.lookup('pageid:scoped'))
330 self.assertTrue(scopes.lookup('pageid:scoped:thing'))
331@@ -43,8 +43,8 @@
332
333 def test_pageid_scope_empty(self):
334 request = LaunchpadTestRequest()
335+ scopes = webapp.ScopesFromRequest(request)
336 request.setInWSGIEnvironment('launchpad.pageid', '')
337- scopes = webapp.ScopesFromRequest(request)
338 self.assertTrue(scopes.lookup('pageid:'))
339 self.assertFalse(scopes.lookup('pageid:foo'))
340 self.assertFalse(scopes.lookup('pageid:foo:bar'))
341@@ -87,7 +87,7 @@
342 def test_team_scope_outside_team(self):
343 request = LaunchpadTestRequest()
344 scopes = webapp.ScopesFromRequest(request)
345- self.factory.loginAsAnyone()
346+ self.factory.loginAsAnyone(request)
347 self.assertFalse(scopes.lookup('team:nonexistent'))
348
349 def test_team_scope_in_team(self):
350@@ -95,5 +95,5 @@
351 scopes = webapp.ScopesFromRequest(request)
352 member = self.factory.makePerson()
353 team = self.factory.makeTeam(members=[member])
354- login_as(member)
355+ login_as(member, request)
356 self.assertTrue(scopes.lookup('team:%s' % team.name))
357
358=== added file 'lib/lp/services/features/tests/test_xmlrpc.py'
359--- lib/lp/services/features/tests/test_xmlrpc.py 1970-01-01 00:00:00 +0000
360+++ lib/lp/services/features/tests/test_xmlrpc.py 2011-09-28 03:34:29 +0000
361@@ -0,0 +1,99 @@
362+# Copyright 2011 Canonical Ltd. This software is licensed under the
363+# GNU Affero General Public License version 3 (see the file LICENSE).
364+
365+"""Tests for FeatureFlagApplication."""
366+
367+__metaclass__ = type
368+
369+import xmlrpclib
370+
371+from canonical.testing.layers import DatabaseFunctionalLayer
372+from canonical.config import config
373+from lp.services import features
374+from lp.services.features.flags import FeatureController
375+from lp.services.features.rulesource import StormFeatureRuleSource
376+from lp.services.features.scopes import (
377+ DefaultScope,
378+ FixedScope,
379+ MultiScopeHandler,
380+ )
381+from lp.services.features.xmlrpc import FeatureFlagApplication
382+from lp.testing import (
383+ feature_flags,
384+ set_feature_flag,
385+ TestCaseWithFactory,
386+ )
387+from lp.testing.xmlrpc import XMLRPCTestTransport
388+
389+
390+class TestGetFeatureFlag(TestCaseWithFactory):
391+
392+ layer = DatabaseFunctionalLayer
393+
394+ def setUp(self):
395+ TestCaseWithFactory.setUp(self)
396+ self.endpoint = FeatureFlagApplication()
397+
398+ def installFeatureController(self, feature_controller):
399+ old_features = features.get_relevant_feature_controller()
400+ features.install_feature_controller(feature_controller)
401+ self.addCleanup(
402+ features.install_feature_controller, old_features)
403+
404+ def test_getFeatureFlag_returns_None_by_default(self):
405+ self.assertIs(None, self.endpoint.getFeatureFlag(u'unknown'))
406+
407+ def test_getFeatureFlag_returns_true_for_set_flag(self):
408+ flag_name = u'flag'
409+ with feature_flags():
410+ set_feature_flag(flag_name, u'1')
411+ self.assertEqual(u'1', self.endpoint.getFeatureFlag(flag_name))
412+
413+ def test_getFeatureFlag_ignores_relevant_feature_controller(self):
414+ # getFeatureFlag should only consider the scopes it is asked to
415+ # consider, not any that happen to be active due to the XML-RPC
416+ # request itself.
417+ flag_name = u'flag'
418+ scope_name = u'scope'
419+ self.installFeatureController(
420+ FeatureController(
421+ MultiScopeHandler(
422+ [DefaultScope(), FixedScope(scope_name)]).lookup,
423+ StormFeatureRuleSource()))
424+ set_feature_flag(flag_name, u'1', scope_name)
425+ self.assertEqual(None, self.endpoint.getFeatureFlag(flag_name))
426+
427+ def test_getFeatureFlag_considers_supplied_scope(self):
428+ flag_name = u'flag'
429+ scope_name = u'scope'
430+ with feature_flags():
431+ set_feature_flag(flag_name, u'value', scope_name)
432+ self.assertEqual(
433+ u'value',
434+ self.endpoint.getFeatureFlag(flag_name, [scope_name]))
435+
436+ def test_getFeatureFlag_turns_user_into_team_scope(self):
437+ flag_name = u'flag'
438+ person = self.factory.makePerson()
439+ team = self.factory.makeTeam(members=[person])
440+ with feature_flags():
441+ set_feature_flag(flag_name, u'value', u'team:' + team.name)
442+ self.assertEqual(
443+ u'value',
444+ self.endpoint.getFeatureFlag(
445+ flag_name, ['user:' + person.name]))
446+
447+ def test_xmlrpc_interface_unset(self):
448+ sp = xmlrpclib.ServerProxy(
449+ config.launchpad.feature_flags_endpoint,
450+ transport=XMLRPCTestTransport(), allow_none=True)
451+ self.assertEqual(None, sp.getFeatureFlag(u'flag'))
452+
453+ def test_xmlrpc_interface_set(self):
454+ sp = xmlrpclib.ServerProxy(
455+ config.launchpad.feature_flags_endpoint,
456+ transport=XMLRPCTestTransport(), allow_none=True)
457+ flag_name = u'flag'
458+ with feature_flags():
459+ set_feature_flag(flag_name, u'1')
460+ self.assertEqual(u'1', sp.getFeatureFlag(flag_name))
461
462=== added file 'lib/lp/services/features/xmlrpc.py'
463--- lib/lp/services/features/xmlrpc.py 1970-01-01 00:00:00 +0000
464+++ lib/lp/services/features/xmlrpc.py 2011-09-28 03:34:29 +0000
465@@ -0,0 +1,59 @@
466+# Copyright 2011 Canonical Ltd. This software is licensed under the
467+# GNU Affero General Public License version 3 (see the file LICENSE).
468+
469+"""FeatureFlagApplication allows access to information about feature flags."""
470+
471+__metaclass__ = type
472+__all__ = [
473+ 'IFeatureFlagApplication',
474+ 'FeatureFlagApplication',
475+ ]
476+
477+from zope.component import getUtility
478+from zope.interface import implements
479+
480+from canonical.launchpad.webapp.interfaces import ILaunchpadApplication
481+from lp.registry.interfaces.person import IPersonSet
482+from lp.services.features.flags import FeatureController
483+from lp.services.features.rulesource import StormFeatureRuleSource
484+from lp.services.features.scopes import (
485+ default_scopes,
486+ FixedScope,
487+ MultiScopeHandler,
488+ TeamScope,
489+ )
490+
491+
492+class IFeatureFlagApplication(ILaunchpadApplication):
493+ """Mailing lists application root."""
494+
495+ def getFeatureFlag(flag_name, username=None, scopes=()):
496+ """Return the value of the given feature flag.
497+
498+ :param flag_name: The name of the flag to query.
499+ :param username: If supplied, the name of a Person to use in
500+ evaluating the 'team:' scope.
501+ :param scopes: A list of scopes to consider active. The 'default'
502+ scope is always considered to be active, and does not need to be
503+ included here.
504+ """
505+
506+
507+class FeatureFlagApplication:
508+
509+ implements(IFeatureFlagApplication)
510+
511+ def getFeatureFlag(self, flag_name, active_scopes=()):
512+ scopes = list(default_scopes)
513+ for scope_name in active_scopes:
514+ if scope_name.startswith('user:'):
515+ person = getUtility(IPersonSet).getByName(
516+ scope_name[len('user:'):])
517+ if person is not None:
518+ scopes.append(TeamScope(lambda: person))
519+ else:
520+ scopes.append(FixedScope(scope_name))
521+ flag_name = unicode(flag_name)
522+ controller = FeatureController(
523+ MultiScopeHandler(scopes).lookup, StormFeatureRuleSource())
524+ return controller.getFlag(flag_name)
525
526=== modified file 'lib/lp/testing/factory.py'
527--- lib/lp/testing/factory.py 2011-09-23 13:14:36 +0000
528+++ lib/lp/testing/factory.py 2011-09-28 03:34:29 +0000
529@@ -505,7 +505,7 @@
530 for any other required objects.
531 """
532
533- def loginAsAnyone(self):
534+ def loginAsAnyone(self, participation=None):
535 """Log in as an arbitrary person.
536
537 If you want to log in as a celebrity, including admins, see
538@@ -513,7 +513,7 @@
539 """
540 login(ANONYMOUS)
541 person = self.makePerson()
542- login_as(person)
543+ login_as(person, participation)
544 return person
545
546 @with_celebrity_logged_in('admin')
547
548=== modified file 'utilities/page-performance-report.ini'
549--- utilities/page-performance-report.ini 2011-09-21 14:38:47 +0000
550+++ utilities/page-performance-report.ini 2011-09-28 03:34:29 +0000
551@@ -11,7 +11,8 @@
552 [^/]+($|/
553 (?!\+haproxy|\+opstats|\+access-token
554 |((authserver|bugs|bazaar|codehosting|
555- codeimportscheduler|mailinglists|softwarecenteragent)/\w+$)))
556+ codeimportscheduler|mailinglists|softwarecenteragent|
557+ featureflags)/\w+$)))
558 Other=^/
559
560 Launchpad Frontpage=^https?://launchpad\.[^/]+(/index\.html)?$
561@@ -54,7 +55,7 @@
562 Private XML-RPC=^https://(launchpad|xmlrpc)[^/]+/
563 (authserver|bugs|codehosting|
564 codeimportscheduler|mailinglists|
565- softwarecenteragent)/\w+$
566+ softwarecenteragent|featureflags)/\w+$
567
568 [metrics]
569 ppr_all=All Launchpad except operational pages