Merge lp:~milo/linaro-patchmetrics/crowd-teams into lp:linaro-patchmetrics

Proposed by Milo Casagrande
Status: Merged
Merged at revision: 379
Proposed branch: lp:~milo/linaro-patchmetrics/crowd-teams
Merge into: lp:linaro-patchmetrics
Diff against target: 524 lines (+395/-19)
4 files modified
apps/patchmetrics/bin/get-linaro-membership.py (+25/-7)
apps/patchmetrics/crowd.py (+238/-0)
apps/patchmetrics/utils.py (+122/-7)
apps/settings.py (+10/-5)
To merge this branch: bzr merge lp:~milo/linaro-patchmetrics/crowd-teams
Reviewer Review Type Date Requested Status
Paul Sokolovsky Approve
Linaro Automation & Validation Pending
Review via email: mp+185984@code.launchpad.net

Description of the change

The following MP is the starting work to move away from Launchpad and use Crowd in patchwork/patchmetrics.
Here the Launchpad teams extraction has been reworked in order to interact with Crowd API. Launchpad integration has not been removed, and Crowd integration at the moment is commented out. When we roll out the final changes to patchwork/patchmetrics, it can be enabled.

What has been done:
- Introduced a Crowd class, that handles the connection to the Crowd API and that perform basic queries for the scope needed (users).
- Introduced a CrowdUser class that returns a handy Python object for a Crowd user as extracted from the Crowd API.

Two new variables in settings.py have been introduced: one is necessary to configure the Crowd integration, and it needs a user, password, url, and optional port to connect to the Crowd API. The file is a INI-style file, with a [crowd] section.

The second variable, optional, is to define a text file with whitelisted groups. Since when we retrieve a user information we get all the groups she is a member of, we might end up creating too many groups in patchwork/patchmetrics that we actually need. Also, we might expose groups that are not supposed to be public. With this text file we define a list of valid groups that we want to have in patchwork/patchmetrics. The format is just a plain text file, with a group name per line. If this file does not exists, all groups are considered valid.

There are two things still missing:
- Extracting or constructing a team display_name attribute: this is needed when creating new teams to give them a better visible name. Unfortunately Crowd does not expose that information, and we might need to use LDAP to achieve that.
- Modifying the 'sync_openid_urls' function: this function is called when syncing teams and users, and patchmetrics creates new users. The function patch the DB with a openid login URL from Launchpad. That needs to be modified to match the Linaro Login URL, but it should be done when we switch the authentication to Crowd too.

To post a comment you must log in.
379. By Milo Casagrande

Added Crowd settings.

380. By Milo Casagrande

Merged from trunk.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

I have few concerns with crowd.py:

1. it doesn't follow DRY, for example has "/crowd/rest/usermanagement/1" twice (even rough example for Crowd API access addressed that providing layered functions for API calls).

2. I find "login_with" to be pretty confusing name for a factory method. It rather would be called something like "connect", though I don't understand why that work can't be done in old good constructor.

3.
264 + elif resp.status == 401 or resp.status == 403:
265 + raise CrowdException("Impossible to fulfill the request")

403 is rather important HTTP result code to shove it together with any other code, especially not mentioning "forbidden" explicitly in a result.

4. Crowd credentials storage scheme is not compatible with django-crowd-backend which would be used for user auth support.

I'd suggest to do something about 3. before merging. Otherwise, based on the discussion in the email (i.e. confirming it was tested), this can go in, so we'll definitely need to address 4 later.

review: Approve
Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

"(even rough example for Crowd API access addressed that providing layered functions for API calls)." - I mean example I provided some time ago.

Revision history for this message
Milo Casagrande (milo) wrote :

On Sun, Sep 22, 2013 at 11:12 PM, Paul Sokolovsky
<email address hidden> wrote:
> Review: Approve
>
> I have few concerns with crowd.py:
>
> 1. it doesn't follow DRY, for example has "/crowd/rest/usermanagement/1" twice (even rough example for Crowd API access addressed that providing layered functions for API calls).

Addressed this, even if I think more refactoring might be needed.

> 2. I find "login_with" to be pretty confusing name for a factory method. It rather would be called something like "connect", though I don't understand why that work can't be done in old good constructor.
>
> 3.
> 264 + elif resp.status == 401 or resp.status == 403:
> 265 + raise CrowdException("Impossible to fulfill the request")
>
> 403 is rather important HTTP result code to shove it together with any other code, especially not mentioning "forbidden" explicitly in a result.

Treated 401 and 403 separately: CrowdUnauthorizedException and
CrowdForbiddenException.

> 4. Crowd credentials storage scheme is not compatible with django-crowd-backend which would be used for user auth support.

It is not, and I hope we can reuse it in some way. I didn't want to
jump into it right away, because I would have had to go through the
same studies and tests you have done, spending more time on it.

--
Milo Casagrande | Automation Engineer
Linaro.org <www.linaro.org> │ Open source software for ARM SoCs

381. By Milo Casagrande

Addressed comments from merge proposal.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'apps/patchmetrics/bin/get-linaro-membership.py'
2--- apps/patchmetrics/bin/get-linaro-membership.py 2012-03-29 16:25:26 +0000
3+++ apps/patchmetrics/bin/get-linaro-membership.py 2013-09-23 07:09:44 +0000
4@@ -2,24 +2,27 @@
5
6 import _pythonpath
7 import sys
8+
9+from patchmetrics.crowd import Crowd
10 from django.conf import settings
11 from launchpadlib.launchpad import Launchpad
12 from launchpadlib.uris import LPNET_SERVICE_ROOT
13-from patchwork.models import Person
14 from patchmetrics.utils import (
15 sync_launchpad_memberships_for,
16- )
17+ sync_user_memberships,
18+)
19+from patchwork.models import Person
20
21
22 if __name__ == '__main__':
23- """Maps email addresses to Launchpad users to track Linaro's patches
24+ """Maps email addresses to Linaro personnel to track Linaro's patches
25
26 Pulls all email addresses out of the database, checks to see if each
27- email address is associated with a Launchpad user who is a member of
28+ email address is associated with a Linaro Login user who is a member of
29 a Linaro sub-team, and if they are, add a mapping between that Person
30 object and a User object (many people (email addresses) can be mapped
31- onto a single User (Launchpad identity)). This allows us to map patches
32- by email address on to Launchpad users, and thus Linaro teams.
33+ onto a single User (Linaro identity)). This allows us to map patches
34+ by email address on to Linaro users, and thus Linaro teams.
35
36 This script has been significantly re-written. Previously we pulled all
37 users who were members of a linaro sub-team out of Launchpad and saved
38@@ -28,7 +31,6 @@
39 because someone has emailed a patch to patches@linaro.org. For this reason
40 we don't automatically pick up new Linaro engineers until they have
41 submitted a patch in this way, which is a change vs the old behaviour.
42-
43 """
44 if settings.LPLIB_CREDENTIALS_FILE is None:
45 print 'No LPLIB_CREDENTIALS_FILE defined in settings.py'
46@@ -45,4 +47,20 @@
47
48 linaro = lp.people['linaro']
49
50+ # TODO: crowd stuff, enable when ready.
51+ # if settings.CROWD_CREDENTIALS_FILE is None:
52+ # sys.stderr.write("CROWD_CREDENTIALS_FILE not defined in "
53+ # "settings.py.\n")
54+ # sys.exit(1)
55+
56+ # whitelisted_groups = []
57+ # if settings.CROWD_GROUPS_WHITELIST is None:
58+ # print "No CROWD_GROUPS_WHITELIST defined, all groups will be used."
59+ # else:
60+ # with open(settings.CROWD_GROUPS_WHITELIST) as read_file:
61+ # whitelisted_groups = [x.strip() for x in read_file if x.strip()]
62+
63+ # crwd = Crowd.login_with(settings.CROWD_CREDENTIALS_FILE)
64+ # sync_user_memberships(input_email_addresses, crwd, whitelisted_groups)
65+
66 sync_launchpad_memberships_for(input_email_addresses, linaro, lp)
67
68=== added file 'apps/patchmetrics/crowd.py'
69--- apps/patchmetrics/crowd.py 1970-01-01 00:00:00 +0000
70+++ apps/patchmetrics/crowd.py 2013-09-23 07:09:44 +0000
71@@ -0,0 +1,238 @@
72+# Copyright (C) 2013 Linaro
73+#
74+# Author: Milo Casagrande <milo.casagrande@linaro.org>
75+# This file is part of the Patchmetrics package.
76+#
77+# Patchmetrics is free software; you can redistribute it and/or modify
78+# it under the terms of the GNU General Public License as published by
79+# the Free Software Foundation; either version 2 of the License, or
80+# (at your option) any later version.
81+#
82+# Patchmetrics is distributed in the hope that it will be useful,
83+# but WITHOUT ANY WARRANTY; without even the implied warranty of
84+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
85+# GNU General Public License for more details.
86+#
87+# You should have received a copy of the GNU General Public License
88+# along with Patchwork; if not, write to the Free Software
89+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
90+
91+import base64
92+import httplib
93+import urllib
94+import ConfigParser
95+import json
96+import types
97+
98+
99+class CrowdException(Exception):
100+ """Base class for Crowd exceptions."""
101+
102+
103+class CrowdNotFoundException(CrowdException):
104+ """An exception for 404 status."""
105+
106+
107+class CrowdForbiddenException(CrowdException):
108+ """An exception for 403 status."""
109+
110+
111+class CrowdUnauthorizedException(CrowdException):
112+ """ An exception for 401 status."""
113+
114+
115+class CrowdUser(object):
116+ """An object that depicts a user from the Crowd system.
117+
118+ It has the following properties:
119+ name (str)
120+ display_name (str)
121+ teams (list)
122+ emails (list)
123+ """
124+
125+ def __init__(self):
126+ self._display_name = None
127+ self._emails = None
128+ self._teams = None
129+ self._name = None
130+
131+ @property
132+ def emails(self):
133+ return self._emails
134+
135+ @emails.setter
136+ def emails(self, value):
137+ if isinstance(value, types.StringTypes):
138+ self._emails = [value]
139+ else:
140+ self._emails = list(value)
141+
142+ @property
143+ def teams(self):
144+ return self._teams
145+
146+ @teams.setter
147+ def teams(self, value):
148+ if isinstance(value, types.StringTypes):
149+ self._teams = [value]
150+ else:
151+ self._teams = list(value)
152+
153+ @property
154+ def display_name(self):
155+ return self._display_name
156+
157+ @display_name.setter
158+ def display_name(self, value):
159+ self._display_name = value
160+
161+ @property
162+ def name(self):
163+ return self._name
164+
165+ @name.setter
166+ def name(self, value):
167+ self._name = value
168+
169+ @staticmethod
170+ def _user_from_json(data):
171+ user = CrowdUser()
172+ user.display_name = data['display-name']
173+ user.name = data['name']
174+ user.emails = data['email']
175+ return user
176+
177+ @staticmethod
178+ def from_json(data):
179+ """Creates a CrowdUser from JSON data.
180+
181+ The JSON data has to contain these fields:
182+ display-name
183+ name
184+ email
185+
186+ :param data: The JSON file to load.
187+ """
188+ json_data = json.load(data)
189+ return CrowdUser._user_from_json(json_data)
190+
191+ @staticmethod
192+ def from_json_s(string):
193+ """Creates a CrowdUser from a JSON string.
194+
195+ The JSON data has to contain these fields:
196+ display-name
197+ name
198+ email
199+
200+ :param string: The JSON string.
201+ :type str
202+ """
203+ json_data = json.loads(string)
204+ return CrowdUser._user_from_json(json_data)
205+
206+
207+class Crowd(object):
208+ """A Crowd object used to perform query operations."""
209+
210+ def __init__(self, usr, pwd, url, port=8443):
211+ self._usr = usr
212+ self._pwd = pwd
213+ self._url = url
214+ self._port = port
215+
216+ self._auth = base64.encodestring('{0}:{1}'.format(self._usr,
217+ self._pwd))
218+ self._headers = {
219+ "Authorization": "Basic {0}".format(self._auth),
220+ "Accept": "application/json"
221+ }
222+
223+ def get_user(self, email):
224+ params = {"username": email}
225+
226+ resource = "/user?{0}".format(urllib.urlencode(params))
227+ return CrowdUser.from_json_s(self._get_rest_usermanagement(resource))
228+
229+ def get_user_with_groups(self, email):
230+ """Gets all the groups a user is member of.
231+
232+ :param email: The user email.
233+ :return A CrowdUser object.
234+ """
235+ # First get the user, if it does not exist, we skip all the operations
236+ # here.
237+ user = self.get_user(email)
238+
239+ params = {"username": email}
240+
241+ resource = "/user/group/nested?{0}".format(
242+ urllib.urlencode(params))
243+ data = json.loads(self._get_rest_usermanagement(resource))
244+
245+ teams = []
246+ if data["groups"]:
247+ teams = [x["name"] for x in data["groups"]]
248+ user.teams = teams
249+
250+ return user
251+
252+ def _get_rest_usermanagement(self, resource):
253+ api_url = "/crowd/rest/usermanagement/1{0}".format(resource)
254+ return self._get(api_url)
255+
256+ def _get(self, api_url):
257+ """Performs a GET operation on the API URL.
258+
259+ :param api_url: The URL of the API to use.
260+ :return The response data.
261+ :raise CrowdNotFoundException if the response status is 404,
262+ CrowdForbiddenException if status is 403,
263+ CrowdUnauthorizedException if status is 401, and CrowdException
264+ in other cases.
265+ """
266+ connection = httplib.HTTPSConnection(self._url, self._port)
267+ connection.request("GET", api_url, headers=self._headers)
268+ resp = connection.getresponse()
269+
270+ if resp.status == 200:
271+ return resp.read()
272+ elif resp.status == 404:
273+ raise CrowdNotFoundException("Resource not found")
274+ elif resp.status == 401:
275+ raise CrowdUnauthorizedException(
276+ "Authorization not granted to fulfill the request")
277+ elif resp.status == 403:
278+ raise CrowdForbiddenException(
279+ "Access forbidden to fulfill the request")
280+ else:
281+ raise CrowdException("Unknow Crowd status {0}".format(resp.status))
282+
283+ @staticmethod
284+ def login_with(credentials_file):
285+ """Create a Crowd object with the provided credentials file."""
286+ config = ConfigParser.ConfigParser()
287+ config.read(credentials_file)
288+
289+ crowd = None
290+ if config:
291+ try:
292+ usr = config.get("crowd", "user")
293+ pwd = config.get("crowd", "password")
294+ url = config.get("crowd", "url")
295+ except ConfigParser.NoOptionError:
296+ raise CrowdException("Missing mandatory config parameter.")
297+
298+ try:
299+ port = config.get("crowd", "port")
300+ crowd = Crowd(usr, pwd, url, port=port)
301+ except ConfigParser.NoOptionError:
302+ pass
303+
304+ if crowd is None:
305+ crowd = Crowd(usr, pwd, url)
306+
307+ return crowd
308+ else:
309+ raise CrowdException("Missing Crowd configuration values.")
310
311=== modified file 'apps/patchmetrics/utils.py'
312--- apps/patchmetrics/utils.py 2013-04-30 09:01:32 +0000
313+++ apps/patchmetrics/utils.py 2013-09-23 07:09:44 +0000
314@@ -1,15 +1,18 @@
315 import logging
316+import time
317+import urllib2
318
319+from patchmetrics.crowd import (
320+ CrowdException,
321+ CrowdNotFoundException,
322+)
323 from django.contrib.auth.models import User
324 from django.db.models import Q
325 from django_openid_auth.models import UserOpenID
326+from lazr.restfulclient.errors import HTTPError
327+from patchmetrics.models import Team
328 from patchwork.models import Person
329-from patchmetrics.models import Team
330-import urllib2
331-import time
332 from xml.dom.minidom import parseString
333-from lazr.restfulclient.errors import HTTPError
334-
335
336 logging.basicConfig()
337 logger = logging.getLogger()
338@@ -20,6 +23,61 @@
339 MAX_RETRIES = 3
340
341
342+def sync_crowd_memberships(memberships):
343+ """Make sure the given memberships are represented in the database.
344+
345+ :param memberships: A dict mapping each team to a list of members. Each
346+ member is in turn represented by a CrowdUser object.
347+ For example: {Team: [CrowdUser1, CrowdUser2, ...]}
348+ """
349+ created_memberships = []
350+ for team, members in memberships.iteritems():
351+ try:
352+ db_team = Team.objects.get(name=team)
353+ except Team.DoesNotExist:
354+ # TODO: need to extract the display_name for a team.
355+ logger.info(
356+ 'Creating new team: {0} ({1})'.format(team, team.display_name))
357+ db_team = Team(name=team, display_name=team.display_name)
358+ db_team.save()
359+
360+ for member in members:
361+ people = []
362+ for email in member.emails:
363+ try:
364+ person = Person.objects.get(email=email)
365+ except Person.DoesNotExist:
366+ logger.info("Creating new person: {0} "
367+ "({1})".format(member.name, email))
368+ person = Person(name=member.display_name, email=email)
369+ person.save()
370+ people.append(person)
371+
372+ # Get all Person entries that might represent other email
373+ # addresses of this same user.
374+ people.extend(Person.objects.filter(name=member.display_name))
375+
376+ user = get_user(member.name, member.emails)
377+ if user is None:
378+ user = User.objects.create_user(
379+ member.name, member.emails[0], password=None)
380+
381+ # Now link all the Person entries to the user account.
382+ for person in people:
383+ person.user = user
384+ person.save()
385+
386+ # And finally, make sure the user is a member of the team.
387+ if user not in db_team.members:
388+ logger.info("Adding {0} as a member "
389+ "of {%1}".format(member.name, db_team.name))
390+ membership = db_team.add_member(user)
391+ membership.save()
392+ created_memberships.append(membership)
393+
394+ return created_memberships
395+
396+
397 def sync_memberships(memberships):
398 """Make sure the given memberships are represented in our database.
399
400@@ -133,12 +191,66 @@
401 if users.count() == 1:
402 return users[0]
403 elif users.count() > 1:
404- print "Found more than one user for %s; using the first one" % emails
405+ logger.info("Found more than one user for {0}; "
406+ "using the first one".format(emails))
407 return users[0]
408 else:
409 return None
410
411
412+def sync_user_memberships(email_addresses, crowd, whitelisted_groups=[]):
413+ """If an input email matches one from Linaro Login, add them to the db.
414+
415+ If an email address matches a user in Linaro Login, takes the CrowdUser
416+ object and passes it to sync_crowd_memberships, where CrowdUser are
417+ matched with User objects (many CrowdUser can map to a single User).
418+ The tail call to `sync_openid_urls` then adds OpenID URLs to each User
419+ object.
420+
421+ :param: email_addresses: List of email addresses to look in Linaro Login.
422+ :type list
423+ :param crowd: The Crowd object instance to perform query to Linaro Login.
424+ :type Crowd
425+ :param whitelisted_groups: A list of valid groups/teams to create.
426+ :type list
427+ """
428+ memberships = {}
429+ for email in email_addresses:
430+ email = email.lower()
431+
432+ user = None
433+ try:
434+ user = crowd.get_user_with_groups(email)
435+ except CrowdNotFoundException:
436+ # If there is not a user matching that email addess in Linaro,
437+ # move on.
438+ pass
439+ except CrowdException:
440+ # If something else went wrong, report it.
441+ logger.warning("Error while searching email address "
442+ "'{0}'.".format(email))
443+
444+ # No user with that email, or user is not part of any team.
445+ if user is None or not user.teams:
446+ continue
447+ else:
448+ for team in user.teams:
449+ # If we have a list of valid groups, obey it. Otherwise, all
450+ # groups are valid.
451+ if whitelisted_groups:
452+ if team in whitelisted_groups:
453+ if not team in memberships:
454+ memberships[team] = []
455+ memberships[team].append(user)
456+ else:
457+ if not team in memberships:
458+ memberships[team] = []
459+ memberships[team].append(user)
460+
461+ sync_crowd_memberships(memberships)
462+ sync_openid_urls()
463+
464+
465 def sync_launchpad_memberships_for(input_email_addresses, team, lp):
466 """If an input email matches a LP user, add them to the database.
467
468@@ -161,7 +273,7 @@
469 # https://bugs.launchpad.net/linaro-patchmetrics/+bug/967290
470 person = None # Launchpad lookup failed.
471 logger.warning("Launchpad rejected email lookup and returned "
472- "HTTPError. Offending email address: %s" % email)
473+ "HTTPError. Offending email address: %s" % email)
474
475 if not person:
476 # No Launchpad entry for this person.
477@@ -198,6 +310,9 @@
478 When sync_memberships creates a User it doesn't do this, so this function
479 just patches up the database with that extra information.
480 """
481+ # TODO
482+ # Crowd openid urls are like this:
483+ # https://login.linaro.org:8443/openidserver/users/email@linaro.org
484 for user in User.objects.all():
485 if UserOpenID.objects.filter(user=user).count() == 0:
486 try:
487
488=== modified file 'apps/settings.py'
489--- apps/settings.py 2012-10-18 09:55:07 +0000
490+++ apps/settings.py 2013-09-23 07:09:44 +0000
491@@ -100,7 +100,7 @@
492 "django.core.context_processors.media",
493 "patchwork.context_processors.login_url",
494 "patchwork.context_processors.register_url",
495- )
496+)
497
498 AUTH_PROFILE_MODULE = "patchwork.userprofile"
499
500@@ -127,6 +127,12 @@
501 # means it will use the default, which is ~/.launchpadlib.
502 LPLIB_DIR = None
503
504+# The file containing Crowd credentials to access Linaro Login Crowd API.
505+CROWD_CREDENTIALS_FILE = None
506+# File with a list of groups that should be created in the DB. If other groups
507+# are taken out from Crowd, and they are not here, they will be skipped.
508+CROWD_GROUPS_WHITELIST = None
509+
510 # Where to store the git repos that we scan to identify which patches have
511 # been committed.
512 PATCHWORK_GIT_REPOS_DIR = '/tmp'
513@@ -143,8 +149,7 @@
514 from local_settings import *
515 except ImportError, ex:
516 import sys
517- sys.stderr.write(\
518- ("settings.py: error importing local settings file:\n" + \
519- "\t%s\n" + \
520- "Do you have a local_settings.py module?\n") % str(ex))
521+ sys.stderr.write("settings.py: error importing local settings file:\n"
522+ "\t%s\n"
523+ "Do you have a local_settings.py module?\n" % str(ex))
524 raise

Subscribers

People subscribed via source and target branches