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
=== modified file 'apps/patchmetrics/bin/get-linaro-membership.py'
--- apps/patchmetrics/bin/get-linaro-membership.py 2012-03-29 16:25:26 +0000
+++ apps/patchmetrics/bin/get-linaro-membership.py 2013-09-23 07:09:44 +0000
@@ -2,24 +2,27 @@
22
3import _pythonpath3import _pythonpath
4import sys4import sys
5
6from patchmetrics.crowd import Crowd
5from django.conf import settings7from django.conf import settings
6from launchpadlib.launchpad import Launchpad8from launchpadlib.launchpad import Launchpad
7from launchpadlib.uris import LPNET_SERVICE_ROOT9from launchpadlib.uris import LPNET_SERVICE_ROOT
8from patchwork.models import Person
9from patchmetrics.utils import (10from patchmetrics.utils import (
10 sync_launchpad_memberships_for,11 sync_launchpad_memberships_for,
11 )12 sync_user_memberships,
13)
14from patchwork.models import Person
1215
1316
14if __name__ == '__main__':17if __name__ == '__main__':
15 """Maps email addresses to Launchpad users to track Linaro's patches18 """Maps email addresses to Linaro personnel to track Linaro's patches
1619
17 Pulls all email addresses out of the database, checks to see if each20 Pulls all email addresses out of the database, checks to see if each
18 email address is associated with a Launchpad user who is a member of21 email address is associated with a Linaro Login user who is a member of
19 a Linaro sub-team, and if they are, add a mapping between that Person22 a Linaro sub-team, and if they are, add a mapping between that Person
20 object and a User object (many people (email addresses) can be mapped23 object and a User object (many people (email addresses) can be mapped
21 onto a single User (Launchpad identity)). This allows us to map patches24 onto a single User (Linaro identity)). This allows us to map patches
22 by email address on to Launchpad users, and thus Linaro teams.25 by email address on to Linaro users, and thus Linaro teams.
2326
24 This script has been significantly re-written. Previously we pulled all27 This script has been significantly re-written. Previously we pulled all
25 users who were members of a linaro sub-team out of Launchpad and saved28 users who were members of a linaro sub-team out of Launchpad and saved
@@ -28,7 +31,6 @@
28 because someone has emailed a patch to patches@linaro.org. For this reason31 because someone has emailed a patch to patches@linaro.org. For this reason
29 we don't automatically pick up new Linaro engineers until they have32 we don't automatically pick up new Linaro engineers until they have
30 submitted a patch in this way, which is a change vs the old behaviour.33 submitted a patch in this way, which is a change vs the old behaviour.
31
32 """34 """
33 if settings.LPLIB_CREDENTIALS_FILE is None:35 if settings.LPLIB_CREDENTIALS_FILE is None:
34 print 'No LPLIB_CREDENTIALS_FILE defined in settings.py'36 print 'No LPLIB_CREDENTIALS_FILE defined in settings.py'
@@ -45,4 +47,20 @@
4547
46 linaro = lp.people['linaro']48 linaro = lp.people['linaro']
4749
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
48 sync_launchpad_memberships_for(input_email_addresses, linaro, lp)66 sync_launchpad_memberships_for(input_email_addresses, linaro, lp)
4967
=== added file 'apps/patchmetrics/crowd.py'
--- apps/patchmetrics/crowd.py 1970-01-01 00:00:00 +0000
+++ apps/patchmetrics/crowd.py 2013-09-23 07:09:44 +0000
@@ -0,0 +1,238 @@
1# Copyright (C) 2013 Linaro
2#
3# Author: Milo Casagrande <milo.casagrande@linaro.org>
4# This file is part of the Patchmetrics package.
5#
6# Patchmetrics is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# Patchmetrics is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Patchwork; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19
20import base64
21import httplib
22import urllib
23import ConfigParser
24import json
25import types
26
27
28class CrowdException(Exception):
29 """Base class for Crowd exceptions."""
30
31
32class CrowdNotFoundException(CrowdException):
33 """An exception for 404 status."""
34
35
36class CrowdForbiddenException(CrowdException):
37 """An exception for 403 status."""
38
39
40class CrowdUnauthorizedException(CrowdException):
41 """ An exception for 401 status."""
42
43
44class CrowdUser(object):
45 """An object that depicts a user from the Crowd system.
46
47 It has the following properties:
48 name (str)
49 display_name (str)
50 teams (list)
51 emails (list)
52 """
53
54 def __init__(self):
55 self._display_name = None
56 self._emails = None
57 self._teams = None
58 self._name = None
59
60 @property
61 def emails(self):
62 return self._emails
63
64 @emails.setter
65 def emails(self, value):
66 if isinstance(value, types.StringTypes):
67 self._emails = [value]
68 else:
69 self._emails = list(value)
70
71 @property
72 def teams(self):
73 return self._teams
74
75 @teams.setter
76 def teams(self, value):
77 if isinstance(value, types.StringTypes):
78 self._teams = [value]
79 else:
80 self._teams = list(value)
81
82 @property
83 def display_name(self):
84 return self._display_name
85
86 @display_name.setter
87 def display_name(self, value):
88 self._display_name = value
89
90 @property
91 def name(self):
92 return self._name
93
94 @name.setter
95 def name(self, value):
96 self._name = value
97
98 @staticmethod
99 def _user_from_json(data):
100 user = CrowdUser()
101 user.display_name = data['display-name']
102 user.name = data['name']
103 user.emails = data['email']
104 return user
105
106 @staticmethod
107 def from_json(data):
108 """Creates a CrowdUser from JSON data.
109
110 The JSON data has to contain these fields:
111 display-name
112 name
113 email
114
115 :param data: The JSON file to load.
116 """
117 json_data = json.load(data)
118 return CrowdUser._user_from_json(json_data)
119
120 @staticmethod
121 def from_json_s(string):
122 """Creates a CrowdUser from a JSON string.
123
124 The JSON data has to contain these fields:
125 display-name
126 name
127 email
128
129 :param string: The JSON string.
130 :type str
131 """
132 json_data = json.loads(string)
133 return CrowdUser._user_from_json(json_data)
134
135
136class Crowd(object):
137 """A Crowd object used to perform query operations."""
138
139 def __init__(self, usr, pwd, url, port=8443):
140 self._usr = usr
141 self._pwd = pwd
142 self._url = url
143 self._port = port
144
145 self._auth = base64.encodestring('{0}:{1}'.format(self._usr,
146 self._pwd))
147 self._headers = {
148 "Authorization": "Basic {0}".format(self._auth),
149 "Accept": "application/json"
150 }
151
152 def get_user(self, email):
153 params = {"username": email}
154
155 resource = "/user?{0}".format(urllib.urlencode(params))
156 return CrowdUser.from_json_s(self._get_rest_usermanagement(resource))
157
158 def get_user_with_groups(self, email):
159 """Gets all the groups a user is member of.
160
161 :param email: The user email.
162 :return A CrowdUser object.
163 """
164 # First get the user, if it does not exist, we skip all the operations
165 # here.
166 user = self.get_user(email)
167
168 params = {"username": email}
169
170 resource = "/user/group/nested?{0}".format(
171 urllib.urlencode(params))
172 data = json.loads(self._get_rest_usermanagement(resource))
173
174 teams = []
175 if data["groups"]:
176 teams = [x["name"] for x in data["groups"]]
177 user.teams = teams
178
179 return user
180
181 def _get_rest_usermanagement(self, resource):
182 api_url = "/crowd/rest/usermanagement/1{0}".format(resource)
183 return self._get(api_url)
184
185 def _get(self, api_url):
186 """Performs a GET operation on the API URL.
187
188 :param api_url: The URL of the API to use.
189 :return The response data.
190 :raise CrowdNotFoundException if the response status is 404,
191 CrowdForbiddenException if status is 403,
192 CrowdUnauthorizedException if status is 401, and CrowdException
193 in other cases.
194 """
195 connection = httplib.HTTPSConnection(self._url, self._port)
196 connection.request("GET", api_url, headers=self._headers)
197 resp = connection.getresponse()
198
199 if resp.status == 200:
200 return resp.read()
201 elif resp.status == 404:
202 raise CrowdNotFoundException("Resource not found")
203 elif resp.status == 401:
204 raise CrowdUnauthorizedException(
205 "Authorization not granted to fulfill the request")
206 elif resp.status == 403:
207 raise CrowdForbiddenException(
208 "Access forbidden to fulfill the request")
209 else:
210 raise CrowdException("Unknow Crowd status {0}".format(resp.status))
211
212 @staticmethod
213 def login_with(credentials_file):
214 """Create a Crowd object with the provided credentials file."""
215 config = ConfigParser.ConfigParser()
216 config.read(credentials_file)
217
218 crowd = None
219 if config:
220 try:
221 usr = config.get("crowd", "user")
222 pwd = config.get("crowd", "password")
223 url = config.get("crowd", "url")
224 except ConfigParser.NoOptionError:
225 raise CrowdException("Missing mandatory config parameter.")
226
227 try:
228 port = config.get("crowd", "port")
229 crowd = Crowd(usr, pwd, url, port=port)
230 except ConfigParser.NoOptionError:
231 pass
232
233 if crowd is None:
234 crowd = Crowd(usr, pwd, url)
235
236 return crowd
237 else:
238 raise CrowdException("Missing Crowd configuration values.")
0239
=== modified file 'apps/patchmetrics/utils.py'
--- apps/patchmetrics/utils.py 2013-04-30 09:01:32 +0000
+++ apps/patchmetrics/utils.py 2013-09-23 07:09:44 +0000
@@ -1,15 +1,18 @@
1import logging1import logging
2import time
3import urllib2
24
5from patchmetrics.crowd import (
6 CrowdException,
7 CrowdNotFoundException,
8)
3from django.contrib.auth.models import User9from django.contrib.auth.models import User
4from django.db.models import Q10from django.db.models import Q
5from django_openid_auth.models import UserOpenID11from django_openid_auth.models import UserOpenID
12from lazr.restfulclient.errors import HTTPError
13from patchmetrics.models import Team
6from patchwork.models import Person14from patchwork.models import Person
7from patchmetrics.models import Team
8import urllib2
9import time
10from xml.dom.minidom import parseString15from xml.dom.minidom import parseString
11from lazr.restfulclient.errors import HTTPError
12
1316
14logging.basicConfig()17logging.basicConfig()
15logger = logging.getLogger()18logger = logging.getLogger()
@@ -20,6 +23,61 @@
20MAX_RETRIES = 323MAX_RETRIES = 3
2124
2225
26def sync_crowd_memberships(memberships):
27 """Make sure the given memberships are represented in the database.
28
29 :param memberships: A dict mapping each team to a list of members. Each
30 member is in turn represented by a CrowdUser object.
31 For example: {Team: [CrowdUser1, CrowdUser2, ...]}
32 """
33 created_memberships = []
34 for team, members in memberships.iteritems():
35 try:
36 db_team = Team.objects.get(name=team)
37 except Team.DoesNotExist:
38 # TODO: need to extract the display_name for a team.
39 logger.info(
40 'Creating new team: {0} ({1})'.format(team, team.display_name))
41 db_team = Team(name=team, display_name=team.display_name)
42 db_team.save()
43
44 for member in members:
45 people = []
46 for email in member.emails:
47 try:
48 person = Person.objects.get(email=email)
49 except Person.DoesNotExist:
50 logger.info("Creating new person: {0} "
51 "({1})".format(member.name, email))
52 person = Person(name=member.display_name, email=email)
53 person.save()
54 people.append(person)
55
56 # Get all Person entries that might represent other email
57 # addresses of this same user.
58 people.extend(Person.objects.filter(name=member.display_name))
59
60 user = get_user(member.name, member.emails)
61 if user is None:
62 user = User.objects.create_user(
63 member.name, member.emails[0], password=None)
64
65 # Now link all the Person entries to the user account.
66 for person in people:
67 person.user = user
68 person.save()
69
70 # And finally, make sure the user is a member of the team.
71 if user not in db_team.members:
72 logger.info("Adding {0} as a member "
73 "of {%1}".format(member.name, db_team.name))
74 membership = db_team.add_member(user)
75 membership.save()
76 created_memberships.append(membership)
77
78 return created_memberships
79
80
23def sync_memberships(memberships):81def sync_memberships(memberships):
24 """Make sure the given memberships are represented in our database.82 """Make sure the given memberships are represented in our database.
2583
@@ -133,12 +191,66 @@
133 if users.count() == 1:191 if users.count() == 1:
134 return users[0]192 return users[0]
135 elif users.count() > 1:193 elif users.count() > 1:
136 print "Found more than one user for %s; using the first one" % emails194 logger.info("Found more than one user for {0}; "
195 "using the first one".format(emails))
137 return users[0]196 return users[0]
138 else:197 else:
139 return None198 return None
140199
141200
201def sync_user_memberships(email_addresses, crowd, whitelisted_groups=[]):
202 """If an input email matches one from Linaro Login, add them to the db.
203
204 If an email address matches a user in Linaro Login, takes the CrowdUser
205 object and passes it to sync_crowd_memberships, where CrowdUser are
206 matched with User objects (many CrowdUser can map to a single User).
207 The tail call to `sync_openid_urls` then adds OpenID URLs to each User
208 object.
209
210 :param: email_addresses: List of email addresses to look in Linaro Login.
211 :type list
212 :param crowd: The Crowd object instance to perform query to Linaro Login.
213 :type Crowd
214 :param whitelisted_groups: A list of valid groups/teams to create.
215 :type list
216 """
217 memberships = {}
218 for email in email_addresses:
219 email = email.lower()
220
221 user = None
222 try:
223 user = crowd.get_user_with_groups(email)
224 except CrowdNotFoundException:
225 # If there is not a user matching that email addess in Linaro,
226 # move on.
227 pass
228 except CrowdException:
229 # If something else went wrong, report it.
230 logger.warning("Error while searching email address "
231 "'{0}'.".format(email))
232
233 # No user with that email, or user is not part of any team.
234 if user is None or not user.teams:
235 continue
236 else:
237 for team in user.teams:
238 # If we have a list of valid groups, obey it. Otherwise, all
239 # groups are valid.
240 if whitelisted_groups:
241 if team in whitelisted_groups:
242 if not team in memberships:
243 memberships[team] = []
244 memberships[team].append(user)
245 else:
246 if not team in memberships:
247 memberships[team] = []
248 memberships[team].append(user)
249
250 sync_crowd_memberships(memberships)
251 sync_openid_urls()
252
253
142def sync_launchpad_memberships_for(input_email_addresses, team, lp):254def sync_launchpad_memberships_for(input_email_addresses, team, lp):
143 """If an input email matches a LP user, add them to the database.255 """If an input email matches a LP user, add them to the database.
144256
@@ -161,7 +273,7 @@
161 # https://bugs.launchpad.net/linaro-patchmetrics/+bug/967290273 # https://bugs.launchpad.net/linaro-patchmetrics/+bug/967290
162 person = None # Launchpad lookup failed.274 person = None # Launchpad lookup failed.
163 logger.warning("Launchpad rejected email lookup and returned "275 logger.warning("Launchpad rejected email lookup and returned "
164 "HTTPError. Offending email address: %s" % email)276 "HTTPError. Offending email address: %s" % email)
165277
166 if not person:278 if not person:
167 # No Launchpad entry for this person.279 # No Launchpad entry for this person.
@@ -198,6 +310,9 @@
198 When sync_memberships creates a User it doesn't do this, so this function310 When sync_memberships creates a User it doesn't do this, so this function
199 just patches up the database with that extra information.311 just patches up the database with that extra information.
200 """312 """
313 # TODO
314 # Crowd openid urls are like this:
315 # https://login.linaro.org:8443/openidserver/users/email@linaro.org
201 for user in User.objects.all():316 for user in User.objects.all():
202 if UserOpenID.objects.filter(user=user).count() == 0:317 if UserOpenID.objects.filter(user=user).count() == 0:
203 try:318 try:
204319
=== modified file 'apps/settings.py'
--- apps/settings.py 2012-10-18 09:55:07 +0000
+++ apps/settings.py 2013-09-23 07:09:44 +0000
@@ -100,7 +100,7 @@
100 "django.core.context_processors.media",100 "django.core.context_processors.media",
101 "patchwork.context_processors.login_url",101 "patchwork.context_processors.login_url",
102 "patchwork.context_processors.register_url",102 "patchwork.context_processors.register_url",
103 )103)
104104
105AUTH_PROFILE_MODULE = "patchwork.userprofile"105AUTH_PROFILE_MODULE = "patchwork.userprofile"
106106
@@ -127,6 +127,12 @@
127# means it will use the default, which is ~/.launchpadlib.127# means it will use the default, which is ~/.launchpadlib.
128LPLIB_DIR = None128LPLIB_DIR = None
129129
130# The file containing Crowd credentials to access Linaro Login Crowd API.
131CROWD_CREDENTIALS_FILE = None
132# File with a list of groups that should be created in the DB. If other groups
133# are taken out from Crowd, and they are not here, they will be skipped.
134CROWD_GROUPS_WHITELIST = None
135
130# Where to store the git repos that we scan to identify which patches have136# Where to store the git repos that we scan to identify which patches have
131# been committed.137# been committed.
132PATCHWORK_GIT_REPOS_DIR = '/tmp'138PATCHWORK_GIT_REPOS_DIR = '/tmp'
@@ -143,8 +149,7 @@
143 from local_settings import *149 from local_settings import *
144except ImportError, ex:150except ImportError, ex:
145 import sys151 import sys
146 sys.stderr.write(\152 sys.stderr.write("settings.py: error importing local settings file:\n"
147 ("settings.py: error importing local settings file:\n" + \153 "\t%s\n"
148 "\t%s\n" + \154 "Do you have a local_settings.py module?\n" % str(ex))
149 "Do you have a local_settings.py module?\n") % str(ex))
150 raise155 raise

Subscribers

People subscribed via source and target branches