Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 45c0bcf8

History | View | Annotate | Download (34.5 kB)

1
# Copyright (C) 2012, 2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

    
34
"""
35
Simple and minimal client for the Astakos authentication service
36
"""
37

    
38
import logging
39
import urlparse
40
import urllib
41
import hashlib
42
from copy import copy
43

    
44
import simplejson
45
from astakosclient.utils import \
46
    retry_dec, scheme_to_class, parse_request, check_input, join_urls
47
from astakosclient.errors import \
48
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
49
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints
50

    
51

    
52
# --------------------------------------------------------------------
53
# Astakos Client Class
54

    
55
def get_token_from_cookie(request, cookie_name):
56
    """Extract token from the cookie name provided
57

58
    Cookie should be in the same form as astakos
59
    service sets its cookie contents:
60
        <user_uniq>|<user_token>
61

62
    """
63
    try:
64
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
65
        return cookie_content.split("|")[1]
66
    except BaseException:
67
        return None
68

    
69

    
70
# Too many instance attributes. pylint: disable-msg=R0902
71
# Too many public methods. pylint: disable-msg=R0904
72
class AstakosClient(object):
73
    """AstakosClient Class Implementation"""
74

    
75
    # ----------------------------------
76
    # Initialize AstakosClient Class
77
    # Too many arguments. pylint: disable-msg=R0913
78
    # Too many local variables. pylint: disable-msg=R0914
79
    # Too many statements. pylint: disable-msg=R0915
80
    def __init__(self, token, auth_url,
81
                 retry=0, use_pool=False, pool_size=8, logger=None):
82
        """Initialize AstakosClient Class
83

84
        Keyword arguments:
85
        token       -- user's/service's token (string)
86
        auth_url    -- i.e https://accounts.example.com/identity/v2.0
87
        retry       -- how many time to retry (integer)
88
        use_pool    -- use objpool for http requests (boolean)
89
        pool_size   -- if using pool, define the pool size
90
        logger      -- pass a different logger
91

92
        """
93

    
94
        # Get logger
95
        if logger is None:
96
            logging.basicConfig(
97
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
98
                datefmt='%Y-%m-%d %H:%M:%S',
99
                level=logging.INFO)
100
            logger = logging.getLogger("astakosclient")
101
        logger.debug("Intialize AstakosClient: auth_url = %s, "
102
                     "use_pool = %s, pool_size = %s",
103
                     auth_url, use_pool, pool_size)
104

    
105
        # Check that token and auth_url (mandatory options) are given
106
        check_input("__init__", logger, token=token, auth_url=auth_url)
107

    
108
        # Initialize connection class
109
        parsed_auth_url = urlparse.urlparse(auth_url)
110
        conn_class = \
111
            scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size)
112
        if conn_class is None:
113
            msg = "Unsupported scheme: %s" % parsed_auth_url.scheme
114
            logger.error(msg)
115
            raise BadValue(msg)
116

    
117
        # Save astakos base url, logger, connection class etc in our class
118
        self.retry = retry
119
        self.logger = logger
120
        self.token = token
121
        self.astakos_base_url = parsed_auth_url.netloc
122
        self.scheme = parsed_auth_url.scheme
123
        self.conn_class = conn_class
124

    
125
        # Initialize astakos api prefixes
126
        # API urls under auth_url
127
        self.auth_prefix = parsed_auth_url.path
128
        self.api_tokens = join_urls(self.auth_prefix, "tokens")
129

    
130
    def _fill_endpoints(self, endpoints):
131
        astakos_service_catalog = parse_endpoints(
132
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
133
        self._account_url = \
134
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
135
        parsed_account_url = urlparse.urlparse(self._account_url)
136

    
137
        self._account_prefix = parsed_account_url.path
138
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)
139

    
140
        self._ui_url = \
141
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
142
        parsed_ui_url = urlparse.urlparse(self._ui_url)
143

    
144
        self._ui_prefix = parsed_ui_url.path
145
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)
146

    
147
    def _get_value(self, s):
148
        assert s in ['_account_url', '_account_prefix',
149
                     '_ui_url', '_ui_prefix']
150
        try:
151
            return getattr(self, s)
152
        except AttributeError:
153
            self.get_endpoints()
154
            return getattr(self, s)
155

    
156
    @property
157
    def account_url(self):
158
        return self._get_value('_account_url')
159

    
160
    @property
161
    def account_prefix(self):
162
        return self._get_value('_account_prefix')
163

    
164
    @property
165
    def ui_url(self):
166
        return self._get_value('_ui_url')
167

    
168
    @property
169
    def ui_prefix(self):
170
        return self._get_value('_ui_prefix')
171

    
172
    @property
173
    def api_authenticate(self):
174
        return join_urls(self.account_prefix, "authenticate")
175

    
176
    @property
177
    def api_usercatalogs(self):
178
        return join_urls(self.account_prefix, "user_catalogs")
179

    
180
    @property
181
    def api_service_usercatalogs(self):
182
        return join_urls(self.account_prefix, "service/user_catalogs")
183

    
184
    @property
185
    def api_resources(self):
186
        return join_urls(self.account_prefix, "resources")
187

    
188
    @property
189
    def api_quotas(self):
190
        return join_urls(self.account_prefix, "quotas")
191

    
192
    @property
193
    def api_service_quotas(self):
194
        return join_urls(self.account_prefix, "service_quotas")
195

    
196
    @property
197
    def api_commissions(self):
198
        return join_urls(self.account_prefix, "commissions")
199

    
200
    @property
201
    def api_commissions_action(self):
202
        return join_urls(self.api_commissions, "action")
203

    
204
    @property
205
    def api_feedback(self):
206
        return join_urls(self.account_prefix, "feedback")
207

    
208
    @property
209
    def api_projects(self):
210
        return join_urls(self.account_prefix, "projects")
211

    
212
    @property
213
    def api_applications(self):
214
        return join_urls(self.api_projects, "apps")
215

    
216
    @property
217
    def api_memberships(self):
218
        return join_urls(self.api_projects, "memberships")
219

    
220
    @property
221
    def api_getservices(self):
222
        return join_urls(self.ui_prefix, "get_services")
223

    
224
    # ----------------------------------
225
    @retry_dec
226
    def _call_astakos(self, request_path, headers=None,
227
                      body=None, method="GET", log_body=True):
228
        """Make the actual call to Astakos Service"""
229
        hashed_token = hashlib.sha1()
230
        hashed_token.update(self.token)
231
        self.logger.debug(
232
            "Make a %s request to %s, using token with hash %s, "
233
            "with headers %s and body %s",
234
            method, request_path, hashed_token.hexdigest(), headers,
235
            body if log_body else "(not logged)")
236

    
237
        # Check Input
238
        if headers is None:
239
            headers = {}
240
        if body is None:
241
            body = {}
242

    
243
        # Build request's header and body
244
        kwargs = {}
245
        kwargs['headers'] = copy(headers)
246
        kwargs['headers']['X-Auth-Token'] = self.token
247
        if body:
248
            kwargs['body'] = copy(body)
249
            kwargs['headers'].setdefault(
250
                'content-type', 'application/octet-stream')
251
        kwargs['headers'].setdefault('content-length',
252
                                     len(body) if body else 0)
253

    
254
        try:
255
            # Get the connection object
256
            with self.conn_class(self.astakos_base_url) as conn:
257
                # Send request
258
                # Used * or ** magic. pylint: disable-msg=W0142
259
                (message, data, status) = \
260
                    _do_request(conn, method, request_path, **kwargs)
261
        except Exception as err:
262
            self.logger.error("Failed to send request: %s" % repr(err))
263
            raise AstakosClientException(str(err))
264

    
265
        # Return
266
        self.logger.debug("Request returned with status %s" % status)
267
        if status == 400:
268
            raise BadRequest(message, data)
269
        elif status == 401:
270
            raise Unauthorized(message, data)
271
        elif status == 403:
272
            raise Forbidden(message, data)
273
        elif status == 404:
274
            raise NotFound(message, data)
275
        elif status < 200 or status >= 300:
276
            raise AstakosClientException(message, data, status)
277

    
278
        try:
279
            if data:
280
                return simplejson.loads(unicode(data))
281
            else:
282
                return None
283
        except Exception as err:
284
            msg = "Cannot parse response \"%s\" with simplejson: %s"
285
            self.logger.error(msg % (data, str(err)))
286
            raise InvalidResponse(str(err), data)
287

    
288
    # ------------------------
289
    # do a GET to ``API_AUTHENTICATE``
290
    def get_user_info(self):
291
        """Authenticate user and get user's info as a dictionary
292

293
        In case of success return user information (json parsed format).
294
        Otherwise raise an AstakosClientException.
295

296
        """
297
        return self._call_astakos(self.api_authenticate)
298

    
299
    # ----------------------------------
300
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
301
    #   with {'uuids': uuids}
302
    def _uuid_catalog(self, uuids, req_path):
303
        """Helper function to retrieve uuid catalog"""
304
        req_headers = {'content-type': 'application/json'}
305
        req_body = parse_request({'uuids': uuids}, self.logger)
306
        data = self._call_astakos(req_path, headers=req_headers,
307
                                  body=req_body, method="POST")
308
        if "uuid_catalog" in data:
309
            return data.get("uuid_catalog")
310
        else:
311
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
312
                  % data
313
            self.logger.error(msg)
314
            raise AstakosClientException(msg)
315

    
316
    def get_usernames(self, uuids):
317
        """Return a uuid_catalog dictionary for the given uuids
318

319
        Keyword arguments:
320
        uuids   -- list of user ids (list of strings)
321

322
        The returned uuid_catalog is a dictionary with uuids as
323
        keys and the corresponding user names as values
324

325
        """
326
        return self._uuid_catalog(uuids, self.api_usercatalogs)
327

    
328
    def get_username(self, uuid):
329
        """Return the user name of a uuid (see get_usernames)"""
330
        check_input("get_username", self.logger, uuid=uuid)
331
        uuid_dict = self.get_usernames([uuid])
332
        if uuid in uuid_dict:
333
            return uuid_dict.get(uuid)
334
        else:
335
            raise NoUserName(uuid)
336

    
337
    def service_get_usernames(self, uuids):
338
        """Return a uuid_catalog dict using a service's token"""
339
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
340

    
341
    def service_get_username(self, uuid):
342
        """Return the displayName of a uuid using a service's token"""
343
        check_input("service_get_username", self.logger, uuid=uuid)
344
        uuid_dict = self.service_get_usernames([uuid])
345
        if uuid in uuid_dict:
346
            return uuid_dict.get(uuid)
347
        else:
348
            raise NoUserName(uuid)
349

    
350
    # ----------------------------------
351
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
352
    #   with {'displaynames': display_names}
353
    def _displayname_catalog(self, display_names, req_path):
354
        """Helper function to retrieve display names catalog"""
355
        req_headers = {'content-type': 'application/json'}
356
        req_body = parse_request({'displaynames': display_names}, self.logger)
357
        data = self._call_astakos(req_path, headers=req_headers,
358
                                  body=req_body, method="POST")
359
        if "displayname_catalog" in data:
360
            return data.get("displayname_catalog")
361
        else:
362
            msg = "_displayname_catalog request returned %s. " \
363
                  "No displayname_catalog found" % data
364
            self.logger.error(msg)
365
            raise AstakosClientException(msg)
366

    
367
    def get_uuids(self, display_names):
368
        """Return a displayname_catalog for the given names
369

370
        Keyword arguments:
371
        display_names   -- list of user names (list of strings)
372

373
        The returned displayname_catalog is a dictionary with
374
        the names as keys and the corresponding uuids as values
375

376
        """
377
        return self._displayname_catalog(
378
            display_names, self.api_usercatalogs)
379

    
380
    def get_uuid(self, display_name):
381
        """Return the uuid of a name (see getUUIDs)"""
382
        check_input("get_uuid", self.logger, display_name=display_name)
383
        name_dict = self.get_uuids([display_name])
384
        if display_name in name_dict:
385
            return name_dict.get(display_name)
386
        else:
387
            raise NoUUID(display_name)
388

    
389
    def service_get_uuids(self, display_names):
390
        """Return a display_name catalog using a service's token"""
391
        return self._displayname_catalog(
392
            display_names, self.api_service_usercatalogs)
393

    
394
    def service_get_uuid(self, display_name):
395
        """Return the uuid of a name using a service's token"""
396
        check_input("service_get_uuid", self.logger, display_name=display_name)
397
        name_dict = self.service_get_uuids([display_name])
398
        if display_name in name_dict:
399
            return name_dict.get(display_name)
400
        else:
401
            raise NoUUID(display_name)
402

    
403
    # ----------------------------------
404
    # do a GET to ``API_GETSERVICES``
405
    def get_services(self):
406
        """Return a list of dicts with the registered services"""
407
        return self._call_astakos(self.api_getservices)
408

    
409
    # ----------------------------------
410
    # do a GET to ``API_RESOURCES``
411
    def get_resources(self):
412
        """Return a dict of dicts with the available resources"""
413
        return self._call_astakos(self.api_resources)
414

    
415
    # ----------------------------------
416
    # do a POST to ``API_FEEDBACK``
417
    def send_feedback(self, message, data):
418
        """Send feedback to astakos service
419

420
        keyword arguments:
421
        message     -- Feedback message
422
        data        -- Additional information about service client status
423

424
        In case of success return nothing.
425
        Otherwise raise an AstakosClientException
426

427
        """
428
        check_input("send_feedback", self.logger, message=message, data=data)
429
        req_body = urllib.urlencode(
430
            {'feedback_msg': message, 'feedback_data': data})
431
        self._call_astakos(self.api_feedback, headers=None,
432
                           body=req_body, method="POST")
433

    
434
    # -----------------------------------------
435
    # do a POST to ``API_TOKENS`` with no token
436
    def get_endpoints(self):
437
        """ Get services' endpoints
438

439
        In case of error raise an AstakosClientException.
440

441
        """
442
        req_headers = {'content-type': 'application/json'}
443
        req_body = None
444
        r = self._call_astakos(self.api_tokens, headers=req_headers,
445
                               body=req_body, method="POST",
446
                               log_body=False)
447
        self._fill_endpoints(r)
448
        return r
449

    
450
    # --------------------------------------
451
    # do a POST to ``API_TOKENS`` with a token
452
    def authenticate(self, tenant_name=None):
453
        """ Authenticate and get services' endpoints
454

455
        Keyword arguments:
456
        tenant_name         -- user's uniq id (optional)
457

458
        It returns back the token as well as information about the token
459
        holder and the services he/she can access (in json format).
460

461
        The tenant_name is optional and if it is given it must match the
462
        user's uuid.
463

464
        In case of error raise an AstakosClientException.
465

466
        """
467
        req_headers = {'content-type': 'application/json'}
468
        body = {'auth': {'token': {'id': self.token}}}
469
        if tenant_name is not None:
470
            body['auth']['tenantName'] = tenant_name
471
        req_body = parse_request(body, self.logger)
472
        r = self._call_astakos(self.api_tokens, headers=req_headers,
473
                               body=req_body, method="POST",
474
                               log_body=False)
475
        self._fill_endpoints(r)
476
        return r
477

    
478
    # ----------------------------------
479
    # do a GET to ``API_QUOTAS``
480
    def get_quotas(self):
481
        """Get user's quotas
482

483
        In case of success return a dict of dicts with user's current quotas.
484
        Otherwise raise an AstakosClientException
485

486
        """
487
        return self._call_astakos(self.api_quotas)
488

    
489
    # ----------------------------------
490
    # do a GET to ``API_SERVICE_QUOTAS``
491
    def service_get_quotas(self, user=None):
492
        """Get all quotas for resources associated with the service
493

494
        Keyword arguments:
495
        user    -- optionally, the uuid of a specific user
496

497
        In case of success return a dict of dicts of dicts with current quotas
498
        for all users, or of a specified user, if user argument is set.
499
        Otherwise raise an AstakosClientException
500

501
        """
502
        query = self.api_service_quotas
503
        if user is not None:
504
            query += "?user=" + user
505
        return self._call_astakos(query)
506

    
507
    # ----------------------------------
508
    # do a POST to ``API_COMMISSIONS``
509
    def issue_commission(self, request):
510
        """Issue a commission
511

512
        Keyword arguments:
513
        request -- commision request (dict)
514

515
        In case of success return commission's id (int).
516
        Otherwise raise an AstakosClientException.
517

518
        """
519
        req_headers = {'content-type': 'application/json'}
520
        req_body = parse_request(request, self.logger)
521
        try:
522
            response = self._call_astakos(self.api_commissions,
523
                                          headers=req_headers,
524
                                          body=req_body,
525
                                          method="POST")
526
        except AstakosClientException as err:
527
            if err.status == 413:
528
                raise QuotaLimit(err.message, err.details)
529
            else:
530
                raise
531

    
532
        if "serial" in response:
533
            return response['serial']
534
        else:
535
            msg = "issue_commission_core request returned %s. " + \
536
                  "No serial found" % response
537
            self.logger.error(msg)
538
            raise AstakosClientException(msg)
539

    
540
    def issue_one_commission(self, holder, source, provisions,
541
                             name="", force=False, auto_accept=False):
542
        """Issue one commission (with specific holder and source)
543

544
        keyword arguments:
545
        holder      -- user's id (string)
546
        source      -- commission's source (ex system) (string)
547
        provisions  -- resources with their quantity (dict from string to int)
548
        name        -- description of the commission (string)
549
        force       -- force this commission (boolean)
550
        auto_accept -- auto accept this commission (boolean)
551

552
        In case of success return commission's id (int).
553
        Otherwise raise an AstakosClientException.
554
        (See also issue_commission)
555

556
        """
557
        check_input("issue_one_commission", self.logger,
558
                    holder=holder, source=source,
559
                    provisions=provisions)
560

    
561
        request = {}
562
        request["force"] = force
563
        request["auto_accept"] = auto_accept
564
        request["name"] = name
565
        try:
566
            request["provisions"] = []
567
            for resource, quantity in provisions.iteritems():
568
                prov = {"holder": holder, "source": source,
569
                        "resource": resource, "quantity": quantity}
570
                request["provisions"].append(prov)
571
        except Exception as err:
572
            self.logger.error(str(err))
573
            raise BadValue(str(err))
574

    
575
        return self.issue_commission(request)
576

    
577
    # ----------------------------------
578
    # do a GET to ``API_COMMISSIONS``
579
    def get_pending_commissions(self):
580
        """Get Pending Commissions
581

582
        In case of success return a list of pending commissions' ids
583
        (list of integers)
584

585
        """
586
        return self._call_astakos(self.api_commissions)
587

    
588
    # ----------------------------------
589
    # do a GET to ``API_COMMISSIONS``/<serial>
590
    def get_commission_info(self, serial):
591
        """Get Description of a Commission
592

593
        Keyword arguments:
594
        serial  -- commission's id (int)
595

596
        In case of success return a dict of dicts containing
597
        informations (details) about the requested commission
598

599
        """
600
        check_input("get_commission_info", self.logger, serial=serial)
601

    
602
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
603
        return self._call_astakos(path)
604

    
605
    # ----------------------------------
606
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
607
    def commission_action(self, serial, action):
608
        """Perform a commission action
609

610
        Keyword arguments:
611
        serial  -- commission's id (int)
612
        action  -- action to perform, currently accept/reject (string)
613

614
        In case of success return nothing.
615

616
        """
617
        check_input("commission_action", self.logger,
618
                    serial=serial, action=action)
619

    
620
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
621
        req_headers = {'content-type': 'application/json'}
622
        req_body = parse_request({str(action): ""}, self.logger)
623
        self._call_astakos(path, headers=req_headers,
624
                           body=req_body, method="POST")
625

    
626
    def accept_commission(self, serial):
627
        """Accept a commission (see commission_action)"""
628
        self.commission_action(serial, "accept")
629

    
630
    def reject_commission(self, serial):
631
        """Reject a commission (see commission_action)"""
632
        self.commission_action(serial, "reject")
633

    
634
    # ----------------------------------
635
    # do a POST to ``API_COMMISSIONS_ACTION``
636
    def resolve_commissions(self, accept_serials, reject_serials):
637
        """Resolve multiple commissions at once
638

639
        Keyword arguments:
640
        accept_serials  -- commissions to accept (list of ints)
641
        reject_serials  -- commissions to reject (list of ints)
642

643
        In case of success return a dict of dicts describing which
644
        commissions accepted, which rejected and which failed to
645
        resolved.
646

647
        """
648
        check_input("resolve_commissions", self.logger,
649
                    accept_serials=accept_serials,
650
                    reject_serials=reject_serials)
651

    
652
        req_headers = {'content-type': 'application/json'}
653
        req_body = parse_request({"accept": accept_serials,
654
                                  "reject": reject_serials},
655
                                 self.logger)
656
        return self._call_astakos(self.api_commissions_action,
657
                                  headers=req_headers, body=req_body,
658
                                  method="POST")
659

    
660
    # ----------------------------
661
    # do a GET to ``API_PROJECTS``
662
    def get_projects(self, name=None, state=None, owner=None):
663
        """Retrieve all accessible projects
664

665
        Arguments:
666
        name  -- filter by name (optional)
667
        state -- filter by state (optional)
668
        owner -- filter by owner (optional)
669

670
        In case of success, return a list of project descriptions.
671
        """
672
        filters = {}
673
        if name is not None:
674
            filters["name"] = name
675
        if state is not None:
676
            filters["state"] = state
677
        if owner is not None:
678
            filters["owner"] = owner
679
        req_headers = {'content-type': 'application/json'}
680
        req_body = (parse_request({"filter": filters}, self.logger)
681
                    if filters else None)
682
        return self._call_astakos(self.api_projects,
683
                                  headers=req_headers, body=req_body)
684

    
685
    # -----------------------------------------
686
    # do a GET to ``API_PROJECTS``/<project_id>
687
    def get_project(self, project_id):
688
        """Retrieve project description, if accessible
689

690
        Arguments:
691
        project_id -- project identifier
692

693
        In case of success, return project description.
694
        """
695
        path = join_urls(self.api_projects, str(project_id))
696
        return self._call_astakos(path)
697

    
698
    # -----------------------------
699
    # do a POST to ``API_PROJECTS``
700
    def create_project(self, specs):
701
        """Submit application to create a new project
702

703
        Arguments:
704
        specs -- dict describing a project
705

706
        In case of success, return project and application identifiers.
707
        """
708
        req_headers = {'content-type': 'application/json'}
709
        req_body = parse_request(specs, self.logger)
710
        return self._call_astakos(self.api_projects,
711
                                  headers=req_headers, body=req_body,
712
                                  method="POST")
713

    
714
    # ------------------------------------------
715
    # do a POST to ``API_PROJECTS``/<project_id>
716
    def modify_project(self, project_id, specs):
717
        """Submit application to modify an existing project
718

719
        Arguments:
720
        project_id -- project identifier
721
        specs      -- dict describing a project
722

723
        In case of success, return project and application identifiers.
724
        """
725
        path = join_urls(self.api_projects, str(project_id))
726
        req_headers = {'content-type': 'application/json'}
727
        req_body = parse_request(specs, self.logger)
728
        return self._call_astakos(path, headers=req_headers,
729
                                  body=req_body, method="POST")
730

    
731
    # -------------------------------------------------
732
    # do a POST to ``API_PROJECTS``/<project_id>/action
733
    def project_action(self, project_id, action, reason=""):
734
        """Perform action on a project
735

736
        Arguments:
737
        project_id -- project identifier
738
        action     -- action to perform, one of "suspend", "unsuspend",
739
                      "terminate", "reinstate"
740
        reason     -- reason of performing the action
741

742
        In case of success, return nothing.
743
        """
744
        path = join_urls(self.api_projects, str(project_id))
745
        path = join_urls(path, "action")
746
        req_headers = {'content-type': 'application/json'}
747
        req_body = parse_request({action: reason}, self.logger)
748
        return self._call_astakos(path, headers=req_headers,
749
                                  body=req_body, method="POST")
750

    
751
    # --------------------------------
752
    # do a GET to ``API_APPLICATIONS``
753
    def get_applications(self, project=None):
754
        """Retrieve all accessible applications
755

756
        Arguments:
757
        project -- filter by project (optional)
758

759
        In case of success, return a list of application descriptions.
760
        """
761
        req_headers = {'content-type': 'application/json'}
762
        body = {"project": project} if project is not None else None
763
        req_body = parse_request(body, self.logger) if body else None
764
        return self._call_astakos(self.api_applications,
765
                                  headers=req_headers, body=req_body)
766

    
767
    # -----------------------------------------
768
    # do a GET to ``API_APPLICATIONS``/<app_id>
769
    def get_application(self, app_id):
770
        """Retrieve application description, if accessible
771

772
        Arguments:
773
        app_id -- application identifier
774

775
        In case of success, return application description.
776
        """
777
        path = join_urls(self.api_applications, str(app_id))
778
        return self._call_astakos(path)
779

    
780
    # -------------------------------------------------
781
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
782
    def application_action(self, app_id, action, reason=""):
783
        """Perform action on an application
784

785
        Arguments:
786
        app_id -- application identifier
787
        action -- action to perform, one of "approve", "deny",
788
                  "dismiss", "cancel"
789
        reason -- reason of performing the action
790

791
        In case of success, return nothing.
792
        """
793
        path = join_urls(self.api_applications, str(app_id))
794
        path = join_urls(path, "action")
795
        req_headers = {'content-type': 'application/json'}
796
        req_body = parse_request({action: reason}, self.logger)
797
        return self._call_astakos(path, headers=req_headers,
798
                                  body=req_body, method="POST")
799

    
800
    # -------------------------------
801
    # do a GET to ``API_MEMBERSHIPS``
802
    def get_memberships(self, project=None):
803
        """Retrieve all accessible memberships
804

805
        Arguments:
806
        project -- filter by project (optional)
807

808
        In case of success, return a list of membership descriptions.
809
        """
810
        req_headers = {'content-type': 'application/json'}
811
        body = {"project": project} if project is not None else None
812
        req_body = parse_request(body, self.logger) if body else None
813
        return self._call_astakos(self.api_memberships,
814
                                  headers=req_headers, body=req_body)
815

    
816
    # -----------------------------------------
817
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
818
    def get_membership(self, memb_id):
819
        """Retrieve membership description, if accessible
820

821
        Arguments:
822
        memb_id -- membership identifier
823

824
        In case of success, return membership description.
825
        """
826
        path = join_urls(self.api_memberships, str(memb_id))
827
        return self._call_astakos(path)
828

    
829
    # -------------------------------------------------
830
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
831
    def membership_action(self, memb_id, action, reason=""):
832
        """Perform action on a membership
833

834
        Arguments:
835
        memb_id -- membership identifier
836
        action  -- action to perform, one of "leave", "cancel", "accept",
837
                   "reject", "remove"
838
        reason  -- reason of performing the action
839

840
        In case of success, return nothing.
841
        """
842
        path = join_urls(self.api_memberships, str(memb_id))
843
        path = join_urls(path, "action")
844
        req_headers = {'content-type': 'application/json'}
845
        req_body = parse_request({action: reason}, self.logger)
846
        return self._call_astakos(path, headers=req_headers,
847
                                  body=req_body, method="POST")
848

    
849
    # --------------------------------
850
    # do a POST to ``API_MEMBERSHIPS``
851
    def join_project(self, project_id):
852
        """Join a project
853

854
        Arguments:
855
        project_id -- project identifier
856

857
        In case of success, return membership identifier.
858
        """
859
        req_headers = {'content-type': 'application/json'}
860
        body = {"join": {"project": project_id}}
861
        req_body = parse_request(body, self.logger)
862
        return self._call_astakos(self.api_memberships, headers=req_headers,
863
                                  body=req_body, method="POST")
864

    
865
    # --------------------------------
866
    # do a POST to ``API_MEMBERSHIPS``
867
    def enroll_member(self, project_id, email):
868
        """Enroll a user in a project
869

870
        Arguments:
871
        project_id -- project identifier
872
        email      -- user identified by email
873

874
        In case of success, return membership identifier.
875
        """
876
        req_headers = {'content-type': 'application/json'}
877
        body = {"enroll": {"project": project_id, "user": email}}
878
        req_body = parse_request(body, self.logger)
879
        return self._call_astakos(self.api_memberships, headers=req_headers,
880
                                  body=req_body, method="POST")
881

    
882

    
883
# --------------------------------------------------------------------
884
# parse endpoints
885
def parse_endpoints(endpoints, ep_name=None, ep_type=None,
886
                    ep_region=None, ep_version_id=None):
887
    """Parse endpoints server response and extract the ones needed
888

889
    Keyword arguments:
890
    endpoints     -- the endpoints (json response from get_endpoints)
891
    ep_name       -- return only endpoints with this name (optional)
892
    ep_type       -- return only endpoints with this type (optional)
893
    ep_region     -- return only endpoints with this region (optional)
894
    ep_version_id -- return only endpoints with this versionId (optional)
895

896
    In case one of the `name', `type', `region', `version_id' parameters
897
    is given, return only the endpoints that match all of these criteria.
898
    If no match is found then raise NoEndpoints exception.
899

900
    """
901
    try:
902
        catalog = endpoints['access']['serviceCatalog']
903
        if ep_name is not None:
904
            catalog = \
905
                [c for c in catalog if c['name'] == ep_name]
906
        if ep_type is not None:
907
            catalog = \
908
                [c for c in catalog if c['type'] == ep_type]
909
        if ep_region is not None:
910
            for c in catalog:
911
                c['endpoints'] = [e for e in c['endpoints']
912
                                  if e['region'] == ep_region]
913
            # Remove catalog entries with no endpoints
914
            catalog = \
915
                [c for c in catalog if c['endpoints']]
916
        if ep_version_id is not None:
917
            for c in catalog:
918
                c['endpoints'] = [e for e in c['endpoints']
919
                                  if e['versionId'] == ep_version_id]
920
            # Remove catalog entries with no endpoints
921
            catalog = \
922
                [c for c in catalog if c['endpoints']]
923

    
924
        if not catalog:
925
            raise NoEndpoints(ep_name, ep_type,
926
                              ep_region, ep_version_id)
927
        else:
928
            return catalog
929
    except KeyError:
930
        raise NoEndpoints()
931

    
932

    
933
# --------------------------------------------------------------------
934
# Private functions
935
# We want _do_request to be a distinct function
936
# so that we can replace it during unit tests.
937
def _do_request(conn, method, url, **kwargs):
938
    """The actual request. This function can easily be mocked"""
939
    conn.request(method, url, **kwargs)
940
    response = conn.getresponse()
941
    length = response.getheader('content-length', None)
942
    data = response.read(length)
943
    status = int(response.status)
944
    message = response.reason
945
    return (message, data, status)