Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ fe7d0186

History | View | Annotate | Download (36.9 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 base64 import b64encode
43
from copy import copy
44

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

    
52

    
53
# --------------------------------------------------------------------
54
# Astakos Client Class
55

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

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

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

    
70

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

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

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

93
        """
94

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

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

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

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

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

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

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

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

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

    
148
        oauth2_service_catalog = parse_endpoints(endpoints,
149
                                                 ep_name="astakos_oauth2")
150
        self._oauth2_url = \
151
            oauth2_service_catalog[0]['endpoints'][0]['publicURL']
152
        parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
153
        self._oauth2_prefix = parsed_oauth2_url.path
154

    
155
    def _get_value(self, s):
156
        assert s in ['_account_url', '_account_prefix',
157
                     '_ui_url', '_ui_prefix',
158
                     '_oauth2_url', '_oauth2_prefix']
159
        try:
160
            return getattr(self, s)
161
        except AttributeError:
162
            self.get_endpoints()
163
            return getattr(self, s)
164

    
165
    @property
166
    def account_url(self):
167
        return self._get_value('_account_url')
168

    
169
    @property
170
    def account_prefix(self):
171
        return self._get_value('_account_prefix')
172

    
173
    @property
174
    def ui_url(self):
175
        return self._get_value('_ui_url')
176

    
177
    @property
178
    def ui_prefix(self):
179
        return self._get_value('_ui_prefix')
180

    
181
    @property
182
    def oauth2_url(self):
183
        return self._get_value('_oauth2_url')
184

    
185
    @property
186
    def oauth2_prefix(self):
187
        return self._get_value('_oauth2_prefix')
188

    
189
    @property
190
    def api_usercatalogs(self):
191
        return join_urls(self.account_prefix, "user_catalogs")
192

    
193
    @property
194
    def api_service_usercatalogs(self):
195
        return join_urls(self.account_prefix, "service/user_catalogs")
196

    
197
    @property
198
    def api_resources(self):
199
        return join_urls(self.account_prefix, "resources")
200

    
201
    @property
202
    def api_quotas(self):
203
        return join_urls(self.account_prefix, "quotas")
204

    
205
    @property
206
    def api_service_quotas(self):
207
        return join_urls(self.account_prefix, "service_quotas")
208

    
209
    @property
210
    def api_commissions(self):
211
        return join_urls(self.account_prefix, "commissions")
212

    
213
    @property
214
    def api_commissions_action(self):
215
        return join_urls(self.api_commissions, "action")
216

    
217
    @property
218
    def api_feedback(self):
219
        return join_urls(self.account_prefix, "feedback")
220

    
221
    @property
222
    def api_projects(self):
223
        return join_urls(self.account_prefix, "projects")
224

    
225
    @property
226
    def api_applications(self):
227
        return join_urls(self.api_projects, "apps")
228

    
229
    @property
230
    def api_memberships(self):
231
        return join_urls(self.api_projects, "memberships")
232

    
233
    @property
234
    def api_getservices(self):
235
        return join_urls(self.ui_prefix, "get_services")
236

    
237
    @property
238
    def api_oauth2_auth(self):
239
        return join_urls(self.oauth2_prefix, "auth")
240

    
241
    @property
242
    def api_oauth2_token(self):
243
        return join_urls(self.oauth2_prefix, "token")
244

    
245
    # ----------------------------------
246
    @retry_dec
247
    def _call_astakos(self, request_path, headers=None,
248
                      body=None, method="GET", log_body=True):
249
        """Make the actual call to Astakos Service"""
250
        hashed_token = hashlib.sha1()
251
        hashed_token.update(self.token)
252
        self.logger.debug(
253
            "Make a %s request to %s, using token with hash %s, "
254
            "with headers %s and body %s",
255
            method, request_path, hashed_token.hexdigest(), headers,
256
            body if log_body else "(not logged)")
257

    
258
        # Check Input
259
        if headers is None:
260
            headers = {}
261
        if body is None:
262
            body = {}
263
        # Initialize log_request and log_response attributes
264
        self.log_request = None
265
        self.log_response = None
266

    
267
        # Build request's header and body
268
        kwargs = {}
269
        kwargs['headers'] = copy(headers)
270
        kwargs['headers']['X-Auth-Token'] = self.token
271
        if body:
272
            kwargs['body'] = copy(body)
273
            kwargs['headers'].setdefault(
274
                'content-type', 'application/octet-stream')
275
        kwargs['headers'].setdefault('content-length',
276
                                     len(body) if body else 0)
277

    
278
        try:
279
            # Get the connection object
280
            with self.conn_class(self.astakos_base_url) as conn:
281
                # Log the request so other clients (like kamaki)
282
                # can use them to produce their own log messages.
283
                self.log_request = dict(method=method, path=request_path)
284
                self.log_request.update(kwargs)
285

    
286
                # Send request
287
                # Used * or ** magic. pylint: disable-msg=W0142
288
                (message, data, status) = \
289
                    _do_request(conn, method, request_path, **kwargs)
290

    
291
                # Log the response so other clients (like kamaki)
292
                # can use them to produce their own log messages.
293
                self.log_response = dict(
294
                    status=status, message=message, data=data)
295
        except Exception as err:
296
            self.logger.error("Failed to send request: %s" % repr(err))
297
            raise AstakosClientException(str(err))
298

    
299
        # Return
300
        self.logger.debug("Request returned with status %s" % status)
301
        if status == 400:
302
            raise BadRequest(message, data)
303
        elif status == 401:
304
            raise Unauthorized(message, data)
305
        elif status == 403:
306
            raise Forbidden(message, data)
307
        elif status == 404:
308
            raise NotFound(message, data)
309
        elif status < 200 or status >= 300:
310
            raise AstakosClientException(message, data, status)
311

    
312
        try:
313
            if data:
314
                return simplejson.loads(unicode(data))
315
            else:
316
                return None
317
        except Exception as err:
318
            msg = "Cannot parse response \"%s\" with simplejson: %s"
319
            self.logger.error(msg % (data, str(err)))
320
            raise InvalidResponse(str(err), data)
321

    
322
    # ----------------------------------
323
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
324
    #   with {'uuids': uuids}
325
    def _uuid_catalog(self, uuids, req_path):
326
        """Helper function to retrieve uuid catalog"""
327
        req_headers = {'content-type': 'application/json'}
328
        req_body = parse_request({'uuids': uuids}, self.logger)
329
        data = self._call_astakos(req_path, headers=req_headers,
330
                                  body=req_body, method="POST")
331
        if "uuid_catalog" in data:
332
            return data.get("uuid_catalog")
333
        else:
334
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
335
                  % data
336
            self.logger.error(msg)
337
            raise AstakosClientException(msg)
338

    
339
    def get_usernames(self, uuids):
340
        """Return a uuid_catalog dictionary for the given uuids
341

342
        Keyword arguments:
343
        uuids   -- list of user ids (list of strings)
344

345
        The returned uuid_catalog is a dictionary with uuids as
346
        keys and the corresponding user names as values
347

348
        """
349
        return self._uuid_catalog(uuids, self.api_usercatalogs)
350

    
351
    def get_username(self, uuid):
352
        """Return the user name of a uuid (see get_usernames)"""
353
        check_input("get_username", self.logger, uuid=uuid)
354
        uuid_dict = self.get_usernames([uuid])
355
        if uuid in uuid_dict:
356
            return uuid_dict.get(uuid)
357
        else:
358
            raise NoUserName(uuid)
359

    
360
    def service_get_usernames(self, uuids):
361
        """Return a uuid_catalog dict using a service's token"""
362
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
363

    
364
    def service_get_username(self, uuid):
365
        """Return the displayName of a uuid using a service's token"""
366
        check_input("service_get_username", self.logger, uuid=uuid)
367
        uuid_dict = self.service_get_usernames([uuid])
368
        if uuid in uuid_dict:
369
            return uuid_dict.get(uuid)
370
        else:
371
            raise NoUserName(uuid)
372

    
373
    # ----------------------------------
374
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
375
    #   with {'displaynames': display_names}
376
    def _displayname_catalog(self, display_names, req_path):
377
        """Helper function to retrieve display names catalog"""
378
        req_headers = {'content-type': 'application/json'}
379
        req_body = parse_request({'displaynames': display_names}, self.logger)
380
        data = self._call_astakos(req_path, headers=req_headers,
381
                                  body=req_body, method="POST")
382
        if "displayname_catalog" in data:
383
            return data.get("displayname_catalog")
384
        else:
385
            msg = "_displayname_catalog request returned %s. " \
386
                  "No displayname_catalog found" % data
387
            self.logger.error(msg)
388
            raise AstakosClientException(msg)
389

    
390
    def get_uuids(self, display_names):
391
        """Return a displayname_catalog for the given names
392

393
        Keyword arguments:
394
        display_names   -- list of user names (list of strings)
395

396
        The returned displayname_catalog is a dictionary with
397
        the names as keys and the corresponding uuids as values
398

399
        """
400
        return self._displayname_catalog(
401
            display_names, self.api_usercatalogs)
402

    
403
    def get_uuid(self, display_name):
404
        """Return the uuid of a name (see getUUIDs)"""
405
        check_input("get_uuid", self.logger, display_name=display_name)
406
        name_dict = self.get_uuids([display_name])
407
        if display_name in name_dict:
408
            return name_dict.get(display_name)
409
        else:
410
            raise NoUUID(display_name)
411

    
412
    def service_get_uuids(self, display_names):
413
        """Return a display_name catalog using a service's token"""
414
        return self._displayname_catalog(
415
            display_names, self.api_service_usercatalogs)
416

    
417
    def service_get_uuid(self, display_name):
418
        """Return the uuid of a name using a service's token"""
419
        check_input("service_get_uuid", self.logger, display_name=display_name)
420
        name_dict = self.service_get_uuids([display_name])
421
        if display_name in name_dict:
422
            return name_dict.get(display_name)
423
        else:
424
            raise NoUUID(display_name)
425

    
426
    # ----------------------------------
427
    # do a GET to ``API_GETSERVICES``
428
    def get_services(self):
429
        """Return a list of dicts with the registered services"""
430
        return self._call_astakos(self.api_getservices)
431

    
432
    # ----------------------------------
433
    # do a GET to ``API_RESOURCES``
434
    def get_resources(self):
435
        """Return a dict of dicts with the available resources"""
436
        return self._call_astakos(self.api_resources)
437

    
438
    # ----------------------------------
439
    # do a POST to ``API_FEEDBACK``
440
    def send_feedback(self, message, data):
441
        """Send feedback to astakos service
442

443
        keyword arguments:
444
        message     -- Feedback message
445
        data        -- Additional information about service client status
446

447
        In case of success return nothing.
448
        Otherwise raise an AstakosClientException
449

450
        """
451
        check_input("send_feedback", self.logger, message=message, data=data)
452
        req_body = urllib.urlencode(
453
            {'feedback_msg': message, 'feedback_data': data})
454
        self._call_astakos(self.api_feedback, headers=None,
455
                           body=req_body, method="POST")
456

    
457
    # -----------------------------------------
458
    # do a POST to ``API_TOKENS`` with no token
459
    def get_endpoints(self):
460
        """ Get services' endpoints
461

462
        In case of error raise an AstakosClientException.
463

464
        """
465
        req_headers = {'content-type': 'application/json'}
466
        req_body = None
467
        r = self._call_astakos(self.api_tokens, headers=req_headers,
468
                               body=req_body, method="POST",
469
                               log_body=False)
470
        self._fill_endpoints(r)
471
        return r
472

    
473
    # --------------------------------------
474
    # do a POST to ``API_TOKENS`` with a token
475
    def authenticate(self, tenant_name=None):
476
        """ Authenticate and get services' endpoints
477

478
        Keyword arguments:
479
        tenant_name         -- user's uniq id (optional)
480

481
        It returns back the token as well as information about the token
482
        holder and the services he/she can access (in json format).
483

484
        The tenant_name is optional and if it is given it must match the
485
        user's uuid.
486

487
        In case of error raise an AstakosClientException.
488

489
        """
490
        req_headers = {'content-type': 'application/json'}
491
        body = {'auth': {'token': {'id': self.token}}}
492
        if tenant_name is not None:
493
            body['auth']['tenantName'] = tenant_name
494
        req_body = parse_request(body, self.logger)
495
        r = self._call_astakos(self.api_tokens, headers=req_headers,
496
                               body=req_body, method="POST",
497
                               log_body=False)
498
        self._fill_endpoints(r)
499
        return r
500

    
501
    # --------------------------------------
502
    # do a GET to ``API_TOKENS`` with a token
503
    def validate_token(self, token_id, belongsTo=None):
504
        """ Validate a temporary access token (oath2)
505

506
        Keyword arguments:
507
        belongsTo         -- confirm that token belongs to tenant
508

509
        It returns back the token as well as information about the token
510
        holder.
511

512
        The belongsTo is optional and if it is given it must be inside the
513
        token's scope.
514

515
        In case of error raise an AstakosClientException.
516

517
        """
518
        path = join_urls(self.api_tokens, str(token_id))
519
        if belongsTo is not None:
520
            params = {'belongsTo': belongsTo}
521
            path = '%s?%s' % (path, urllib.urlencode(params))
522
        return self._call_astakos(path, method="GET", log_body=False)
523

    
524
    # ----------------------------------
525
    # do a GET to ``API_QUOTAS``
526
    def get_quotas(self):
527
        """Get user's quotas
528

529
        In case of success return a dict of dicts with user's current quotas.
530
        Otherwise raise an AstakosClientException
531

532
        """
533
        return self._call_astakos(self.api_quotas)
534

    
535
    # ----------------------------------
536
    # do a GET to ``API_SERVICE_QUOTAS``
537
    def service_get_quotas(self, user=None):
538
        """Get all quotas for resources associated with the service
539

540
        Keyword arguments:
541
        user    -- optionally, the uuid of a specific user
542

543
        In case of success return a dict of dicts of dicts with current quotas
544
        for all users, or of a specified user, if user argument is set.
545
        Otherwise raise an AstakosClientException
546

547
        """
548
        query = self.api_service_quotas
549
        if user is not None:
550
            query += "?user=" + user
551
        return self._call_astakos(query)
552

    
553
    # ----------------------------------
554
    # do a POST to ``API_COMMISSIONS``
555
    def issue_commission(self, request):
556
        """Issue a commission
557

558
        Keyword arguments:
559
        request -- commision request (dict)
560

561
        In case of success return commission's id (int).
562
        Otherwise raise an AstakosClientException.
563

564
        """
565
        req_headers = {'content-type': 'application/json'}
566
        req_body = parse_request(request, self.logger)
567
        try:
568
            response = self._call_astakos(self.api_commissions,
569
                                          headers=req_headers,
570
                                          body=req_body,
571
                                          method="POST")
572
        except AstakosClientException as err:
573
            if err.status == 413:
574
                raise QuotaLimit(err.message, err.details)
575
            else:
576
                raise
577

    
578
        if "serial" in response:
579
            return response['serial']
580
        else:
581
            msg = "issue_commission_core request returned %s. " + \
582
                  "No serial found" % response
583
            self.logger.error(msg)
584
            raise AstakosClientException(msg)
585

    
586
    def issue_one_commission(self, holder, source, provisions,
587
                             name="", force=False, auto_accept=False):
588
        """Issue one commission (with specific holder and source)
589

590
        keyword arguments:
591
        holder      -- user's id (string)
592
        source      -- commission's source (ex system) (string)
593
        provisions  -- resources with their quantity (dict from string to int)
594
        name        -- description of the commission (string)
595
        force       -- force this commission (boolean)
596
        auto_accept -- auto accept this commission (boolean)
597

598
        In case of success return commission's id (int).
599
        Otherwise raise an AstakosClientException.
600
        (See also issue_commission)
601

602
        """
603
        check_input("issue_one_commission", self.logger,
604
                    holder=holder, source=source,
605
                    provisions=provisions)
606

    
607
        request = {}
608
        request["force"] = force
609
        request["auto_accept"] = auto_accept
610
        request["name"] = name
611
        try:
612
            request["provisions"] = []
613
            for resource, quantity in provisions.iteritems():
614
                prov = {"holder": holder, "source": source,
615
                        "resource": resource, "quantity": quantity}
616
                request["provisions"].append(prov)
617
        except Exception as err:
618
            self.logger.error(str(err))
619
            raise BadValue(str(err))
620

    
621
        return self.issue_commission(request)
622

    
623
    # ----------------------------------
624
    # do a GET to ``API_COMMISSIONS``
625
    def get_pending_commissions(self):
626
        """Get Pending Commissions
627

628
        In case of success return a list of pending commissions' ids
629
        (list of integers)
630

631
        """
632
        return self._call_astakos(self.api_commissions)
633

    
634
    # ----------------------------------
635
    # do a GET to ``API_COMMISSIONS``/<serial>
636
    def get_commission_info(self, serial):
637
        """Get Description of a Commission
638

639
        Keyword arguments:
640
        serial  -- commission's id (int)
641

642
        In case of success return a dict of dicts containing
643
        informations (details) about the requested commission
644

645
        """
646
        check_input("get_commission_info", self.logger, serial=serial)
647

    
648
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
649
        return self._call_astakos(path)
650

    
651
    # ----------------------------------
652
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
653
    def commission_action(self, serial, action):
654
        """Perform a commission action
655

656
        Keyword arguments:
657
        serial  -- commission's id (int)
658
        action  -- action to perform, currently accept/reject (string)
659

660
        In case of success return nothing.
661

662
        """
663
        check_input("commission_action", self.logger,
664
                    serial=serial, action=action)
665

    
666
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
667
        req_headers = {'content-type': 'application/json'}
668
        req_body = parse_request({str(action): ""}, self.logger)
669
        self._call_astakos(path, headers=req_headers,
670
                           body=req_body, method="POST")
671

    
672
    def accept_commission(self, serial):
673
        """Accept a commission (see commission_action)"""
674
        self.commission_action(serial, "accept")
675

    
676
    def reject_commission(self, serial):
677
        """Reject a commission (see commission_action)"""
678
        self.commission_action(serial, "reject")
679

    
680
    # ----------------------------------
681
    # do a POST to ``API_COMMISSIONS_ACTION``
682
    def resolve_commissions(self, accept_serials, reject_serials):
683
        """Resolve multiple commissions at once
684

685
        Keyword arguments:
686
        accept_serials  -- commissions to accept (list of ints)
687
        reject_serials  -- commissions to reject (list of ints)
688

689
        In case of success return a dict of dicts describing which
690
        commissions accepted, which rejected and which failed to
691
        resolved.
692

693
        """
694
        check_input("resolve_commissions", self.logger,
695
                    accept_serials=accept_serials,
696
                    reject_serials=reject_serials)
697

    
698
        req_headers = {'content-type': 'application/json'}
699
        req_body = parse_request({"accept": accept_serials,
700
                                  "reject": reject_serials},
701
                                 self.logger)
702
        return self._call_astakos(self.api_commissions_action,
703
                                  headers=req_headers, body=req_body,
704
                                  method="POST")
705

    
706
    # ----------------------------
707
    # do a GET to ``API_PROJECTS``
708
    def get_projects(self, name=None, state=None, owner=None):
709
        """Retrieve all accessible projects
710

711
        Arguments:
712
        name  -- filter by name (optional)
713
        state -- filter by state (optional)
714
        owner -- filter by owner (optional)
715

716
        In case of success, return a list of project descriptions.
717
        """
718
        filters = {}
719
        if name is not None:
720
            filters["name"] = name
721
        if state is not None:
722
            filters["state"] = state
723
        if owner is not None:
724
            filters["owner"] = owner
725
        req_headers = {'content-type': 'application/json'}
726
        req_body = (parse_request({"filter": filters}, self.logger)
727
                    if filters else None)
728
        return self._call_astakos(self.api_projects,
729
                                  headers=req_headers, body=req_body)
730

    
731
    # -----------------------------------------
732
    # do a GET to ``API_PROJECTS``/<project_id>
733
    def get_project(self, project_id):
734
        """Retrieve project description, if accessible
735

736
        Arguments:
737
        project_id -- project identifier
738

739
        In case of success, return project description.
740
        """
741
        path = join_urls(self.api_projects, str(project_id))
742
        return self._call_astakos(path)
743

    
744
    # -----------------------------
745
    # do a POST to ``API_PROJECTS``
746
    def create_project(self, specs):
747
        """Submit application to create a new project
748

749
        Arguments:
750
        specs -- dict describing a project
751

752
        In case of success, return project and application identifiers.
753
        """
754
        req_headers = {'content-type': 'application/json'}
755
        req_body = parse_request(specs, self.logger)
756
        return self._call_astakos(self.api_projects,
757
                                  headers=req_headers, body=req_body,
758
                                  method="POST")
759

    
760
    # ------------------------------------------
761
    # do a POST to ``API_PROJECTS``/<project_id>
762
    def modify_project(self, project_id, specs):
763
        """Submit application to modify an existing project
764

765
        Arguments:
766
        project_id -- project identifier
767
        specs      -- dict describing a project
768

769
        In case of success, return project and application identifiers.
770
        """
771
        path = join_urls(self.api_projects, str(project_id))
772
        req_headers = {'content-type': 'application/json'}
773
        req_body = parse_request(specs, self.logger)
774
        return self._call_astakos(path, headers=req_headers,
775
                                  body=req_body, method="POST")
776

    
777
    # -------------------------------------------------
778
    # do a POST to ``API_PROJECTS``/<project_id>/action
779
    def project_action(self, project_id, action, reason=""):
780
        """Perform action on a project
781

782
        Arguments:
783
        project_id -- project identifier
784
        action     -- action to perform, one of "suspend", "unsuspend",
785
                      "terminate", "reinstate"
786
        reason     -- reason of performing the action
787

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

    
797
    # --------------------------------
798
    # do a GET to ``API_APPLICATIONS``
799
    def get_applications(self, project=None):
800
        """Retrieve all accessible applications
801

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

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

    
813
    # -----------------------------------------
814
    # do a GET to ``API_APPLICATIONS``/<app_id>
815
    def get_application(self, app_id):
816
        """Retrieve application description, if accessible
817

818
        Arguments:
819
        app_id -- application identifier
820

821
        In case of success, return application description.
822
        """
823
        path = join_urls(self.api_applications, str(app_id))
824
        return self._call_astakos(path)
825

    
826
    # -------------------------------------------------
827
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
828
    def application_action(self, app_id, action, reason=""):
829
        """Perform action on an application
830

831
        Arguments:
832
        app_id -- application identifier
833
        action -- action to perform, one of "approve", "deny",
834
                  "dismiss", "cancel"
835
        reason -- reason of performing the action
836

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

    
846
    # -------------------------------
847
    # do a GET to ``API_MEMBERSHIPS``
848
    def get_memberships(self, project=None):
849
        """Retrieve all accessible memberships
850

851
        Arguments:
852
        project -- filter by project (optional)
853

854
        In case of success, return a list of membership descriptions.
855
        """
856
        req_headers = {'content-type': 'application/json'}
857
        body = {"project": project} if project is not None else None
858
        req_body = parse_request(body, self.logger) if body else None
859
        return self._call_astakos(self.api_memberships,
860
                                  headers=req_headers, body=req_body)
861

    
862
    # -----------------------------------------
863
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
864
    def get_membership(self, memb_id):
865
        """Retrieve membership description, if accessible
866

867
        Arguments:
868
        memb_id -- membership identifier
869

870
        In case of success, return membership description.
871
        """
872
        path = join_urls(self.api_memberships, str(memb_id))
873
        return self._call_astakos(path)
874

    
875
    # -------------------------------------------------
876
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
877
    def membership_action(self, memb_id, action, reason=""):
878
        """Perform action on a membership
879

880
        Arguments:
881
        memb_id -- membership identifier
882
        action  -- action to perform, one of "leave", "cancel", "accept",
883
                   "reject", "remove"
884
        reason  -- reason of performing the action
885

886
        In case of success, return nothing.
887
        """
888
        path = join_urls(self.api_memberships, str(memb_id))
889
        path = join_urls(path, "action")
890
        req_headers = {'content-type': 'application/json'}
891
        req_body = parse_request({action: reason}, self.logger)
892
        return self._call_astakos(path, headers=req_headers,
893
                                  body=req_body, method="POST")
894

    
895
    # --------------------------------
896
    # do a POST to ``API_MEMBERSHIPS``
897
    def join_project(self, project_id):
898
        """Join a project
899

900
        Arguments:
901
        project_id -- project identifier
902

903
        In case of success, return membership identifier.
904
        """
905
        req_headers = {'content-type': 'application/json'}
906
        body = {"join": {"project": project_id}}
907
        req_body = parse_request(body, self.logger)
908
        return self._call_astakos(self.api_memberships, headers=req_headers,
909
                                  body=req_body, method="POST")
910

    
911
    # --------------------------------
912
    # do a POST to ``API_MEMBERSHIPS``
913
    def enroll_member(self, project_id, email):
914
        """Enroll a user in a project
915

916
        Arguments:
917
        project_id -- project identifier
918
        email      -- user identified by email
919

920
        In case of success, return membership identifier.
921
        """
922
        req_headers = {'content-type': 'application/json'}
923
        body = {"enroll": {"project": project_id, "user": email}}
924
        req_body = parse_request(body, self.logger)
925
        return self._call_astakos(self.api_memberships, headers=req_headers,
926
                                  body=req_body, method="POST")
927

    
928
    # --------------------------------
929
    # do a POST to ``API_OAUTH2_TOKEN``
930
    def get_token(self, grant_type, client_id, client_secret, **body_params):
931
        headers = {'content-type': 'application/x-www-form-urlencoded',
932
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
933
                                                           (client_id,
934
                                                            client_secret))}
935
        body_params['grant_type'] = grant_type
936
        body = urllib.urlencode(body_params)
937
        return self._call_astakos(self.api_oauth2_token, headers=headers,
938
                                  body=body, method="POST")
939

    
940

    
941
# --------------------------------------------------------------------
942
# parse endpoints
943
def parse_endpoints(endpoints, ep_name=None, ep_type=None,
944
                    ep_region=None, ep_version_id=None):
945
    """Parse endpoints server response and extract the ones needed
946

947
    Keyword arguments:
948
    endpoints     -- the endpoints (json response from get_endpoints)
949
    ep_name       -- return only endpoints with this name (optional)
950
    ep_type       -- return only endpoints with this type (optional)
951
    ep_region     -- return only endpoints with this region (optional)
952
    ep_version_id -- return only endpoints with this versionId (optional)
953

954
    In case one of the `name', `type', `region', `version_id' parameters
955
    is given, return only the endpoints that match all of these criteria.
956
    If no match is found then raise NoEndpoints exception.
957

958
    """
959
    try:
960
        catalog = endpoints['access']['serviceCatalog']
961
        if ep_name is not None:
962
            catalog = \
963
                [c for c in catalog if c['name'] == ep_name]
964
        if ep_type is not None:
965
            catalog = \
966
                [c for c in catalog if c['type'] == ep_type]
967
        if ep_region is not None:
968
            for c in catalog:
969
                c['endpoints'] = [e for e in c['endpoints']
970
                                  if e['region'] == ep_region]
971
            # Remove catalog entries with no endpoints
972
            catalog = \
973
                [c for c in catalog if c['endpoints']]
974
        if ep_version_id is not None:
975
            for c in catalog:
976
                c['endpoints'] = [e for e in c['endpoints']
977
                                  if e['versionId'] == ep_version_id]
978
            # Remove catalog entries with no endpoints
979
            catalog = \
980
                [c for c in catalog if c['endpoints']]
981

    
982
        if not catalog:
983
            raise NoEndpoints(ep_name, ep_type,
984
                              ep_region, ep_version_id)
985
        else:
986
            return catalog
987
    except KeyError:
988
        raise NoEndpoints()
989

    
990

    
991
# --------------------------------------------------------------------
992
# Private functions
993
# We want _do_request to be a distinct function
994
# so that we can replace it during unit tests.
995
def _do_request(conn, method, url, **kwargs):
996
    """The actual request. This function can easily be mocked"""
997
    conn.request(method, url, **kwargs)
998
    response = conn.getresponse()
999
    length = response.getheader('content-length', None)
1000
    data = response.read(length)
1001
    status = int(response.status)
1002
    message = response.reason
1003
    return (message, data, status)