Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 2cd636fe

History | View | Annotate | Download (34.7 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_usercatalogs(self):
174
        return join_urls(self.account_prefix, "user_catalogs")
175

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

    
180
    @property
181
    def api_resources(self):
182
        return join_urls(self.account_prefix, "resources")
183

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

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

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

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

    
200
    @property
201
    def api_feedback(self):
202
        return join_urls(self.account_prefix, "feedback")
203

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

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

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

    
216
    @property
217
    def api_getservices(self):
218
        return join_urls(self.ui_prefix, "get_services")
219

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

    
233
        # Check Input
234
        if headers is None:
235
            headers = {}
236
        if body is None:
237
            body = {}
238
        # Initialize log_request and log_response attributes
239
        self.log_request = None
240
        self.log_response = None
241

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

    
253
        try:
254
            # Get the connection object
255
            with self.conn_class(self.astakos_base_url) as conn:
256
                # Log the request so other clients (like kamaki)
257
                # can use them to produce their own log messages.
258
                self.log_request = dict(method=method, path=request_path)
259
                self.log_request.update(kwargs)
260

    
261
                # Send request
262
                # Used * or ** magic. pylint: disable-msg=W0142
263
                (message, data, status) = \
264
                    _do_request(conn, method, request_path, **kwargs)
265

    
266
                # Log the response so other clients (like kamaki)
267
                # can use them to produce their own log messages.
268
                self.log_response = dict(
269
                    status=status, message=message, data=data)
270
        except Exception as err:
271
            self.logger.error("Failed to send request: %s" % repr(err))
272
            raise AstakosClientException(str(err))
273

    
274
        # Return
275
        self.logger.debug("Request returned with status %s" % status)
276
        if status == 400:
277
            raise BadRequest(message, data)
278
        elif status == 401:
279
            raise Unauthorized(message, data)
280
        elif status == 403:
281
            raise Forbidden(message, data)
282
        elif status == 404:
283
            raise NotFound(message, data)
284
        elif status < 200 or status >= 300:
285
            raise AstakosClientException(message, data, status)
286

    
287
        try:
288
            if data:
289
                return simplejson.loads(unicode(data))
290
            else:
291
                return None
292
        except Exception as err:
293
            msg = "Cannot parse response \"%s\" with simplejson: %s"
294
            self.logger.error(msg % (data, str(err)))
295
            raise InvalidResponse(str(err), data)
296

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

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

317
        Keyword arguments:
318
        uuids   -- list of user ids (list of strings)
319

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

323
        """
324
        return self._uuid_catalog(uuids, self.api_usercatalogs)
325

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

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

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

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

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

368
        Keyword arguments:
369
        display_names   -- list of user names (list of strings)
370

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

374
        """
375
        return self._displayname_catalog(
376
            display_names, self.api_usercatalogs)
377

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

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

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

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

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

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

418
        keyword arguments:
419
        message     -- Feedback message
420
        data        -- Additional information about service client status
421

422
        In case of success return nothing.
423
        Otherwise raise an AstakosClientException
424

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

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

437
        In case of error raise an AstakosClientException.
438

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

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

453
        Keyword arguments:
454
        tenant_name         -- user's uniq id (optional)
455

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

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

462
        In case of error raise an AstakosClientException.
463

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

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

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

484
        """
485
        return self._call_astakos(self.api_quotas)
486

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

492
        Keyword arguments:
493
        user    -- optionally, the uuid of a specific user
494

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

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

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

510
        Keyword arguments:
511
        request -- commision request (dict)
512

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

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

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

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

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

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

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

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

    
573
        return self.issue_commission(request)
574

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

580
        In case of success return a list of pending commissions' ids
581
        (list of integers)
582

583
        """
584
        return self._call_astakos(self.api_commissions)
585

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

591
        Keyword arguments:
592
        serial  -- commission's id (int)
593

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

597
        """
598
        check_input("get_commission_info", self.logger, serial=serial)
599

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

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

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

612
        In case of success return nothing.
613

614
        """
615
        check_input("commission_action", self.logger,
616
                    serial=serial, action=action)
617

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

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

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

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

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

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

645
        """
646
        check_input("resolve_commissions", self.logger,
647
                    accept_serials=accept_serials,
648
                    reject_serials=reject_serials)
649

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

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

663
        Arguments:
664
        name  -- filter by name (optional)
665
        state -- filter by state (optional)
666
        owner -- filter by owner (optional)
667

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

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

688
        Arguments:
689
        project_id -- project identifier
690

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

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

701
        Arguments:
702
        specs -- dict describing a project
703

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

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

717
        Arguments:
718
        project_id -- project identifier
719
        specs      -- dict describing a project
720

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

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

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

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

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

754
        Arguments:
755
        project -- filter by project (optional)
756

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

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

770
        Arguments:
771
        app_id -- application identifier
772

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

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

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

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

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

803
        Arguments:
804
        project -- filter by project (optional)
805

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

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

819
        Arguments:
820
        memb_id -- membership identifier
821

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

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

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

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

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

852
        Arguments:
853
        project_id -- project identifier
854

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

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

868
        Arguments:
869
        project_id -- project identifier
870
        email      -- user identified by email
871

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

    
880

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

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

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

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

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

    
930

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