Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ e6a61e6a

History | View | Annotate | Download (37.4 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
            logger = logging.getLogger("astakosclient")
98
            logger.setLevel(logging.INFO)
99
        logger.debug("Intialize AstakosClient: auth_url = %s, "
100
                     "use_pool = %s, pool_size = %s",
101
                     auth_url, use_pool, pool_size)
102

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

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

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

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

    
128
    def _fill_endpoints(self, endpoints, extra=False):
129
        """Fill the endpoints for our AstakosClient
130

131
        This will be done once (lazily) and the endpoints will be there
132
        to be used afterwards.
133
        The `extra' parameter is there for compatibility reasons. We are going
134
        to fill the oauth2 endpoint only if we need it. This way we are keeping
135
        astakosclient compatible with older Astakos version.
136

137
        """
138
        astakos_service_catalog = parse_endpoints(
139
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
140
        self._account_url = \
141
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
142
        parsed_account_url = urlparse.urlparse(self._account_url)
143

    
144
        self._account_prefix = parsed_account_url.path
145
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)
146

    
147
        self._ui_url = \
148
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
149
        parsed_ui_url = urlparse.urlparse(self._ui_url)
150

    
151
        self._ui_prefix = parsed_ui_url.path
152
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)
153

    
154
        if extra:
155
            oauth2_service_catalog = \
156
                parse_endpoints(endpoints, ep_name="astakos_oauth2")
157
            self._oauth2_url = \
158
                oauth2_service_catalog[0]['endpoints'][0]['publicURL']
159
            parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
160
            self._oauth2_prefix = parsed_oauth2_url.path
161

    
162
    def _get_value(self, s, extra=False):
163
        assert s in ['_account_url', '_account_prefix',
164
                     '_ui_url', '_ui_prefix',
165
                     '_oauth2_url', '_oauth2_prefix']
166
        try:
167
            return getattr(self, s)
168
        except AttributeError:
169
            self.get_endpoints(extra=extra)
170
            return getattr(self, s)
171

    
172
    @property
173
    def account_url(self):
174
        return self._get_value('_account_url')
175

    
176
    @property
177
    def account_prefix(self):
178
        return self._get_value('_account_prefix')
179

    
180
    @property
181
    def ui_url(self):
182
        return self._get_value('_ui_url')
183

    
184
    @property
185
    def ui_prefix(self):
186
        return self._get_value('_ui_prefix')
187

    
188
    @property
189
    def oauth2_url(self):
190
        return self._get_value('_oauth2_url', extra=True)
191

    
192
    @property
193
    def oauth2_prefix(self):
194
        return self._get_value('_oauth2_prefix', extra=True)
195

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

    
200
    @property
201
    def api_service_usercatalogs(self):
202
        return join_urls(self.account_prefix, "service/user_catalogs")
203

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

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

    
212
    @property
213
    def api_service_quotas(self):
214
        return join_urls(self.account_prefix, "service_quotas")
215

    
216
    @property
217
    def api_commissions(self):
218
        return join_urls(self.account_prefix, "commissions")
219

    
220
    @property
221
    def api_commissions_action(self):
222
        return join_urls(self.api_commissions, "action")
223

    
224
    @property
225
    def api_feedback(self):
226
        return join_urls(self.account_prefix, "feedback")
227

    
228
    @property
229
    def api_projects(self):
230
        return join_urls(self.account_prefix, "projects")
231

    
232
    @property
233
    def api_applications(self):
234
        return join_urls(self.api_projects, "apps")
235

    
236
    @property
237
    def api_memberships(self):
238
        return join_urls(self.api_projects, "memberships")
239

    
240
    @property
241
    def api_getservices(self):
242
        return join_urls(self.ui_prefix, "get_services")
243

    
244
    @property
245
    def api_oauth2_auth(self):
246
        return join_urls(self.oauth2_prefix, "auth")
247

    
248
    @property
249
    def api_oauth2_token(self):
250
        return join_urls(self.oauth2_prefix, "token")
251

    
252
    # ----------------------------------
253
    @retry_dec
254
    def _call_astakos(self, request_path, headers=None,
255
                      body=None, method="GET", log_body=True):
256
        """Make the actual call to Astakos Service"""
257
        hashed_token = hashlib.sha1()
258
        hashed_token.update(self.token)
259
        self.logger.debug(
260
            "Make a %s request to %s, using token with hash %s, "
261
            "with headers %s and body %s",
262
            method, request_path, hashed_token.hexdigest(), headers,
263
            body if log_body else "(not logged)")
264

    
265
        # Check Input
266
        if headers is None:
267
            headers = {}
268
        if body is None:
269
            body = {}
270
        # Initialize log_request and log_response attributes
271
        self.log_request = None
272
        self.log_response = None
273

    
274
        # Build request's header and body
275
        kwargs = {}
276
        kwargs['headers'] = copy(headers)
277
        kwargs['headers']['X-Auth-Token'] = self.token
278
        if body:
279
            kwargs['body'] = copy(body)
280
            kwargs['headers'].setdefault(
281
                'content-type', 'application/octet-stream')
282
        kwargs['headers'].setdefault('content-length',
283
                                     len(body) if body else 0)
284

    
285
        try:
286
            # Get the connection object
287
            with self.conn_class(self.astakos_base_url) as conn:
288
                # Log the request so other clients (like kamaki)
289
                # can use them to produce their own log messages.
290
                self.log_request = dict(method=method, path=request_path)
291
                self.log_request.update(kwargs)
292

    
293
                # Send request
294
                # Used * or ** magic. pylint: disable-msg=W0142
295
                (message, data, status) = \
296
                    _do_request(conn, method, request_path, **kwargs)
297

    
298
                # Log the response so other clients (like kamaki)
299
                # can use them to produce their own log messages.
300
                self.log_response = dict(
301
                    status=status, message=message, data=data)
302
        except Exception as err:
303
            self.logger.error("Failed to send request: %s" % repr(err))
304
            raise AstakosClientException(str(err))
305

    
306
        # Return
307
        self.logger.debug("Request returned with status %s" % status)
308
        if status == 400:
309
            raise BadRequest(message, data)
310
        elif status == 401:
311
            raise Unauthorized(message, data)
312
        elif status == 403:
313
            raise Forbidden(message, data)
314
        elif status == 404:
315
            raise NotFound(message, data)
316
        elif status < 200 or status >= 300:
317
            raise AstakosClientException(message, data, status)
318

    
319
        try:
320
            if data:
321
                return simplejson.loads(unicode(data))
322
            else:
323
                return None
324
        except Exception as err:
325
            msg = "Cannot parse response \"%s\" with simplejson: %s"
326
            self.logger.error(msg % (data, str(err)))
327
            raise InvalidResponse(str(err), data)
328

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

    
346
    def get_usernames(self, uuids):
347
        """Return a uuid_catalog dictionary for the given uuids
348

349
        Keyword arguments:
350
        uuids   -- list of user ids (list of strings)
351

352
        The returned uuid_catalog is a dictionary with uuids as
353
        keys and the corresponding user names as values
354

355
        """
356
        return self._uuid_catalog(uuids, self.api_usercatalogs)
357

    
358
    def get_username(self, uuid):
359
        """Return the user name of a uuid (see get_usernames)"""
360
        check_input("get_username", self.logger, uuid=uuid)
361
        uuid_dict = self.get_usernames([uuid])
362
        if uuid in uuid_dict:
363
            return uuid_dict.get(uuid)
364
        else:
365
            raise NoUserName(uuid)
366

    
367
    def service_get_usernames(self, uuids):
368
        """Return a uuid_catalog dict using a service's token"""
369
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
370

    
371
    def service_get_username(self, uuid):
372
        """Return the displayName of a uuid using a service's token"""
373
        check_input("service_get_username", self.logger, uuid=uuid)
374
        uuid_dict = self.service_get_usernames([uuid])
375
        if uuid in uuid_dict:
376
            return uuid_dict.get(uuid)
377
        else:
378
            raise NoUserName(uuid)
379

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

    
397
    def get_uuids(self, display_names):
398
        """Return a displayname_catalog for the given names
399

400
        Keyword arguments:
401
        display_names   -- list of user names (list of strings)
402

403
        The returned displayname_catalog is a dictionary with
404
        the names as keys and the corresponding uuids as values
405

406
        """
407
        return self._displayname_catalog(
408
            display_names, self.api_usercatalogs)
409

    
410
    def get_uuid(self, display_name):
411
        """Return the uuid of a name (see getUUIDs)"""
412
        check_input("get_uuid", self.logger, display_name=display_name)
413
        name_dict = self.get_uuids([display_name])
414
        if display_name in name_dict:
415
            return name_dict.get(display_name)
416
        else:
417
            raise NoUUID(display_name)
418

    
419
    def service_get_uuids(self, display_names):
420
        """Return a display_name catalog using a service's token"""
421
        return self._displayname_catalog(
422
            display_names, self.api_service_usercatalogs)
423

    
424
    def service_get_uuid(self, display_name):
425
        """Return the uuid of a name using a service's token"""
426
        check_input("service_get_uuid", self.logger, display_name=display_name)
427
        name_dict = self.service_get_uuids([display_name])
428
        if display_name in name_dict:
429
            return name_dict.get(display_name)
430
        else:
431
            raise NoUUID(display_name)
432

    
433
    # ----------------------------------
434
    # do a GET to ``API_GETSERVICES``
435
    def get_services(self):
436
        """Return a list of dicts with the registered services"""
437
        return self._call_astakos(self.api_getservices)
438

    
439
    # ----------------------------------
440
    # do a GET to ``API_RESOURCES``
441
    def get_resources(self):
442
        """Return a dict of dicts with the available resources"""
443
        return self._call_astakos(self.api_resources)
444

    
445
    # ----------------------------------
446
    # do a POST to ``API_FEEDBACK``
447
    def send_feedback(self, message, data):
448
        """Send feedback to astakos service
449

450
        keyword arguments:
451
        message     -- Feedback message
452
        data        -- Additional information about service client status
453

454
        In case of success return nothing.
455
        Otherwise raise an AstakosClientException
456

457
        """
458
        check_input("send_feedback", self.logger, message=message, data=data)
459
        req_body = urllib.urlencode(
460
            {'feedback_msg': message, 'feedback_data': data})
461
        self._call_astakos(self.api_feedback, headers=None,
462
                           body=req_body, method="POST")
463

    
464
    # -----------------------------------------
465
    # do a POST to ``API_TOKENS`` with no token
466
    def get_endpoints(self, extra=False):
467
        """ Get services' endpoints
468

469
        The extra parameter is to be used by _fill_endpoints.
470
        In case of error raise an AstakosClientException.
471

472
        """
473
        req_headers = {'content-type': 'application/json'}
474
        req_body = None
475
        r = self._call_astakos(self.api_tokens, headers=req_headers,
476
                               body=req_body, method="POST",
477
                               log_body=False)
478
        self._fill_endpoints(r, extra=extra)
479
        return r
480

    
481
    # --------------------------------------
482
    # do a POST to ``API_TOKENS`` with a token
483
    def authenticate(self, tenant_name=None):
484
        """ Authenticate and get services' endpoints
485

486
        Keyword arguments:
487
        tenant_name         -- user's uniq id (optional)
488

489
        It returns back the token as well as information about the token
490
        holder and the services he/she can access (in json format).
491

492
        The tenant_name is optional and if it is given it must match the
493
        user's uuid.
494

495
        In case of error raise an AstakosClientException.
496

497
        """
498
        req_headers = {'content-type': 'application/json'}
499
        body = {'auth': {'token': {'id': self.token}}}
500
        if tenant_name is not None:
501
            body['auth']['tenantName'] = tenant_name
502
        req_body = parse_request(body, self.logger)
503
        r = self._call_astakos(self.api_tokens, headers=req_headers,
504
                               body=req_body, method="POST",
505
                               log_body=False)
506
        self._fill_endpoints(r)
507
        return r
508

    
509
    # --------------------------------------
510
    # do a GET to ``API_TOKENS`` with a token
511
    def validate_token(self, token_id, belongs_to=None):
512
        """ Validate a temporary access token (oath2)
513

514
        Keyword arguments:
515
        belongsTo         -- confirm that token belongs to tenant
516

517
        It returns back the token as well as information about the token
518
        holder.
519

520
        The belongs_to is optional and if it is given it must be inside the
521
        token's scope.
522

523
        In case of error raise an AstakosClientException.
524

525
        """
526
        path = join_urls(self.api_tokens, str(token_id))
527
        if belongs_to is not None:
528
            params = {'belongsTo': belongs_to}
529
            path = '%s?%s' % (path, urllib.urlencode(params))
530
        return self._call_astakos(path, method="GET", log_body=False)
531

    
532
    # ----------------------------------
533
    # do a GET to ``API_QUOTAS``
534
    def get_quotas(self):
535
        """Get user's quotas
536

537
        In case of success return a dict of dicts with user's current quotas.
538
        Otherwise raise an AstakosClientException
539

540
        """
541
        return self._call_astakos(self.api_quotas)
542

    
543
    # ----------------------------------
544
    # do a GET to ``API_SERVICE_QUOTAS``
545
    def service_get_quotas(self, user=None):
546
        """Get all quotas for resources associated with the service
547

548
        Keyword arguments:
549
        user    -- optionally, the uuid of a specific user
550

551
        In case of success return a dict of dicts of dicts with current quotas
552
        for all users, or of a specified user, if user argument is set.
553
        Otherwise raise an AstakosClientException
554

555
        """
556
        query = self.api_service_quotas
557
        if user is not None:
558
            query += "?user=" + user
559
        return self._call_astakos(query)
560

    
561
    # ----------------------------------
562
    # do a POST to ``API_COMMISSIONS``
563
    def issue_commission(self, request):
564
        """Issue a commission
565

566
        Keyword arguments:
567
        request -- commision request (dict)
568

569
        In case of success return commission's id (int).
570
        Otherwise raise an AstakosClientException.
571

572
        """
573
        req_headers = {'content-type': 'application/json'}
574
        req_body = parse_request(request, self.logger)
575
        try:
576
            response = self._call_astakos(self.api_commissions,
577
                                          headers=req_headers,
578
                                          body=req_body,
579
                                          method="POST")
580
        except AstakosClientException as err:
581
            if err.status == 413:
582
                raise QuotaLimit(err.message, err.details)
583
            else:
584
                raise
585

    
586
        if "serial" in response:
587
            return response['serial']
588
        else:
589
            msg = "issue_commission_core request returned %s. " + \
590
                  "No serial found" % response
591
            self.logger.error(msg)
592
            raise AstakosClientException(msg)
593

    
594
    def issue_one_commission(self, holder, source, provisions,
595
                             name="", force=False, auto_accept=False):
596
        """Issue one commission (with specific holder and source)
597

598
        keyword arguments:
599
        holder      -- user's id (string)
600
        source      -- commission's source (ex system) (string)
601
        provisions  -- resources with their quantity (dict from string to int)
602
        name        -- description of the commission (string)
603
        force       -- force this commission (boolean)
604
        auto_accept -- auto accept this commission (boolean)
605

606
        In case of success return commission's id (int).
607
        Otherwise raise an AstakosClientException.
608
        (See also issue_commission)
609

610
        """
611
        check_input("issue_one_commission", self.logger,
612
                    holder=holder, source=source,
613
                    provisions=provisions)
614

    
615
        request = {}
616
        request["force"] = force
617
        request["auto_accept"] = auto_accept
618
        request["name"] = name
619
        try:
620
            request["provisions"] = []
621
            for resource, quantity in provisions.iteritems():
622
                prov = {"holder": holder, "source": source,
623
                        "resource": resource, "quantity": quantity}
624
                request["provisions"].append(prov)
625
        except Exception as err:
626
            self.logger.error(str(err))
627
            raise BadValue(str(err))
628

    
629
        return self.issue_commission(request)
630

    
631
    # ----------------------------------
632
    # do a GET to ``API_COMMISSIONS``
633
    def get_pending_commissions(self):
634
        """Get Pending Commissions
635

636
        In case of success return a list of pending commissions' ids
637
        (list of integers)
638

639
        """
640
        return self._call_astakos(self.api_commissions)
641

    
642
    # ----------------------------------
643
    # do a GET to ``API_COMMISSIONS``/<serial>
644
    def get_commission_info(self, serial):
645
        """Get Description of a Commission
646

647
        Keyword arguments:
648
        serial  -- commission's id (int)
649

650
        In case of success return a dict of dicts containing
651
        informations (details) about the requested commission
652

653
        """
654
        check_input("get_commission_info", self.logger, serial=serial)
655

    
656
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
657
        return self._call_astakos(path)
658

    
659
    # ----------------------------------
660
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
661
    def commission_action(self, serial, action):
662
        """Perform a commission action
663

664
        Keyword arguments:
665
        serial  -- commission's id (int)
666
        action  -- action to perform, currently accept/reject (string)
667

668
        In case of success return nothing.
669

670
        """
671
        check_input("commission_action", self.logger,
672
                    serial=serial, action=action)
673

    
674
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
675
        req_headers = {'content-type': 'application/json'}
676
        req_body = parse_request({str(action): ""}, self.logger)
677
        self._call_astakos(path, headers=req_headers,
678
                           body=req_body, method="POST")
679

    
680
    def accept_commission(self, serial):
681
        """Accept a commission (see commission_action)"""
682
        self.commission_action(serial, "accept")
683

    
684
    def reject_commission(self, serial):
685
        """Reject a commission (see commission_action)"""
686
        self.commission_action(serial, "reject")
687

    
688
    # ----------------------------------
689
    # do a POST to ``API_COMMISSIONS_ACTION``
690
    def resolve_commissions(self, accept_serials, reject_serials):
691
        """Resolve multiple commissions at once
692

693
        Keyword arguments:
694
        accept_serials  -- commissions to accept (list of ints)
695
        reject_serials  -- commissions to reject (list of ints)
696

697
        In case of success return a dict of dicts describing which
698
        commissions accepted, which rejected and which failed to
699
        resolved.
700

701
        """
702
        check_input("resolve_commissions", self.logger,
703
                    accept_serials=accept_serials,
704
                    reject_serials=reject_serials)
705

    
706
        req_headers = {'content-type': 'application/json'}
707
        req_body = parse_request({"accept": accept_serials,
708
                                  "reject": reject_serials},
709
                                 self.logger)
710
        return self._call_astakos(self.api_commissions_action,
711
                                  headers=req_headers, body=req_body,
712
                                  method="POST")
713

    
714
    # ----------------------------
715
    # do a GET to ``API_PROJECTS``
716
    def get_projects(self, name=None, state=None, owner=None):
717
        """Retrieve all accessible projects
718

719
        Arguments:
720
        name  -- filter by name (optional)
721
        state -- filter by state (optional)
722
        owner -- filter by owner (optional)
723

724
        In case of success, return a list of project descriptions.
725
        """
726
        filters = {}
727
        if name is not None:
728
            filters["name"] = name
729
        if state is not None:
730
            filters["state"] = state
731
        if owner is not None:
732
            filters["owner"] = owner
733
        req_headers = {'content-type': 'application/json'}
734
        req_body = (parse_request({"filter": filters}, self.logger)
735
                    if filters else None)
736
        return self._call_astakos(self.api_projects,
737
                                  headers=req_headers, body=req_body)
738

    
739
    # -----------------------------------------
740
    # do a GET to ``API_PROJECTS``/<project_id>
741
    def get_project(self, project_id):
742
        """Retrieve project description, if accessible
743

744
        Arguments:
745
        project_id -- project identifier
746

747
        In case of success, return project description.
748
        """
749
        path = join_urls(self.api_projects, str(project_id))
750
        return self._call_astakos(path)
751

    
752
    # -----------------------------
753
    # do a POST to ``API_PROJECTS``
754
    def create_project(self, specs):
755
        """Submit application to create a new project
756

757
        Arguments:
758
        specs -- dict describing a project
759

760
        In case of success, return project and application identifiers.
761
        """
762
        req_headers = {'content-type': 'application/json'}
763
        req_body = parse_request(specs, self.logger)
764
        return self._call_astakos(self.api_projects,
765
                                  headers=req_headers, body=req_body,
766
                                  method="POST")
767

    
768
    # ------------------------------------------
769
    # do a POST to ``API_PROJECTS``/<project_id>
770
    def modify_project(self, project_id, specs):
771
        """Submit application to modify an existing project
772

773
        Arguments:
774
        project_id -- project identifier
775
        specs      -- dict describing a project
776

777
        In case of success, return project and application identifiers.
778
        """
779
        path = join_urls(self.api_projects, str(project_id))
780
        req_headers = {'content-type': 'application/json'}
781
        req_body = parse_request(specs, self.logger)
782
        return self._call_astakos(path, headers=req_headers,
783
                                  body=req_body, method="POST")
784

    
785
    # -------------------------------------------------
786
    # do a POST to ``API_PROJECTS``/<project_id>/action
787
    def project_action(self, project_id, action, reason=""):
788
        """Perform action on a project
789

790
        Arguments:
791
        project_id -- project identifier
792
        action     -- action to perform, one of "suspend", "unsuspend",
793
                      "terminate", "reinstate"
794
        reason     -- reason of performing the action
795

796
        In case of success, return nothing.
797
        """
798
        path = join_urls(self.api_projects, str(project_id))
799
        path = join_urls(path, "action")
800
        req_headers = {'content-type': 'application/json'}
801
        req_body = parse_request({action: reason}, self.logger)
802
        return self._call_astakos(path, headers=req_headers,
803
                                  body=req_body, method="POST")
804

    
805
    # --------------------------------
806
    # do a GET to ``API_APPLICATIONS``
807
    def get_applications(self, project=None):
808
        """Retrieve all accessible applications
809

810
        Arguments:
811
        project -- filter by project (optional)
812

813
        In case of success, return a list of application descriptions.
814
        """
815
        req_headers = {'content-type': 'application/json'}
816
        body = {"project": project} if project is not None else None
817
        req_body = parse_request(body, self.logger) if body else None
818
        return self._call_astakos(self.api_applications,
819
                                  headers=req_headers, body=req_body)
820

    
821
    # -----------------------------------------
822
    # do a GET to ``API_APPLICATIONS``/<app_id>
823
    def get_application(self, app_id):
824
        """Retrieve application description, if accessible
825

826
        Arguments:
827
        app_id -- application identifier
828

829
        In case of success, return application description.
830
        """
831
        path = join_urls(self.api_applications, str(app_id))
832
        return self._call_astakos(path)
833

    
834
    # -------------------------------------------------
835
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
836
    def application_action(self, app_id, action, reason=""):
837
        """Perform action on an application
838

839
        Arguments:
840
        app_id -- application identifier
841
        action -- action to perform, one of "approve", "deny",
842
                  "dismiss", "cancel"
843
        reason -- reason of performing the action
844

845
        In case of success, return nothing.
846
        """
847
        path = join_urls(self.api_applications, str(app_id))
848
        path = join_urls(path, "action")
849
        req_headers = {'content-type': 'application/json'}
850
        req_body = parse_request({action: reason}, self.logger)
851
        return self._call_astakos(path, headers=req_headers,
852
                                  body=req_body, method="POST")
853

    
854
    # -------------------------------
855
    # do a GET to ``API_MEMBERSHIPS``
856
    def get_memberships(self, project=None):
857
        """Retrieve all accessible memberships
858

859
        Arguments:
860
        project -- filter by project (optional)
861

862
        In case of success, return a list of membership descriptions.
863
        """
864
        req_headers = {'content-type': 'application/json'}
865
        body = {"project": project} if project is not None else None
866
        req_body = parse_request(body, self.logger) if body else None
867
        return self._call_astakos(self.api_memberships,
868
                                  headers=req_headers, body=req_body)
869

    
870
    # -----------------------------------------
871
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
872
    def get_membership(self, memb_id):
873
        """Retrieve membership description, if accessible
874

875
        Arguments:
876
        memb_id -- membership identifier
877

878
        In case of success, return membership description.
879
        """
880
        path = join_urls(self.api_memberships, str(memb_id))
881
        return self._call_astakos(path)
882

    
883
    # -------------------------------------------------
884
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
885
    def membership_action(self, memb_id, action, reason=""):
886
        """Perform action on a membership
887

888
        Arguments:
889
        memb_id -- membership identifier
890
        action  -- action to perform, one of "leave", "cancel", "accept",
891
                   "reject", "remove"
892
        reason  -- reason of performing the action
893

894
        In case of success, return nothing.
895
        """
896
        path = join_urls(self.api_memberships, str(memb_id))
897
        path = join_urls(path, "action")
898
        req_headers = {'content-type': 'application/json'}
899
        req_body = parse_request({action: reason}, self.logger)
900
        return self._call_astakos(path, headers=req_headers,
901
                                  body=req_body, method="POST")
902

    
903
    # --------------------------------
904
    # do a POST to ``API_MEMBERSHIPS``
905
    def join_project(self, project_id):
906
        """Join a project
907

908
        Arguments:
909
        project_id -- project identifier
910

911
        In case of success, return membership identifier.
912
        """
913
        req_headers = {'content-type': 'application/json'}
914
        body = {"join": {"project": project_id}}
915
        req_body = parse_request(body, self.logger)
916
        return self._call_astakos(self.api_memberships, headers=req_headers,
917
                                  body=req_body, method="POST")
918

    
919
    # --------------------------------
920
    # do a POST to ``API_MEMBERSHIPS``
921
    def enroll_member(self, project_id, email):
922
        """Enroll a user in a project
923

924
        Arguments:
925
        project_id -- project identifier
926
        email      -- user identified by email
927

928
        In case of success, return membership identifier.
929
        """
930
        req_headers = {'content-type': 'application/json'}
931
        body = {"enroll": {"project": project_id, "user": email}}
932
        req_body = parse_request(body, self.logger)
933
        return self._call_astakos(self.api_memberships, headers=req_headers,
934
                                  body=req_body, method="POST")
935

    
936
    # --------------------------------
937
    # do a POST to ``API_OAUTH2_TOKEN``
938
    def get_token(self, grant_type, client_id, client_secret, **body_params):
939
        headers = {'content-type': 'application/x-www-form-urlencoded',
940
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
941
                                                           (client_id,
942
                                                            client_secret))}
943
        body_params['grant_type'] = grant_type
944
        body = urllib.urlencode(body_params)
945
        return self._call_astakos(self.api_oauth2_token, headers=headers,
946
                                  body=body, method="POST")
947

    
948

    
949
# --------------------------------------------------------------------
950
# parse endpoints
951
def parse_endpoints(endpoints, ep_name=None, ep_type=None,
952
                    ep_region=None, ep_version_id=None):
953
    """Parse endpoints server response and extract the ones needed
954

955
    Keyword arguments:
956
    endpoints     -- the endpoints (json response from get_endpoints)
957
    ep_name       -- return only endpoints with this name (optional)
958
    ep_type       -- return only endpoints with this type (optional)
959
    ep_region     -- return only endpoints with this region (optional)
960
    ep_version_id -- return only endpoints with this versionId (optional)
961

962
    In case one of the `name', `type', `region', `version_id' parameters
963
    is given, return only the endpoints that match all of these criteria.
964
    If no match is found then raise NoEndpoints exception.
965

966
    """
967
    try:
968
        catalog = endpoints['access']['serviceCatalog']
969
        if ep_name is not None:
970
            catalog = \
971
                [c for c in catalog if c['name'] == ep_name]
972
        if ep_type is not None:
973
            catalog = \
974
                [c for c in catalog if c['type'] == ep_type]
975
        if ep_region is not None:
976
            for c in catalog:
977
                c['endpoints'] = [e for e in c['endpoints']
978
                                  if e['region'] == ep_region]
979
            # Remove catalog entries with no endpoints
980
            catalog = \
981
                [c for c in catalog if c['endpoints']]
982
        if ep_version_id is not None:
983
            for c in catalog:
984
                c['endpoints'] = [e for e in c['endpoints']
985
                                  if e['versionId'] == ep_version_id]
986
            # Remove catalog entries with no endpoints
987
            catalog = \
988
                [c for c in catalog if c['endpoints']]
989

    
990
        if not catalog:
991
            raise NoEndpoints(ep_name, ep_type,
992
                              ep_region, ep_version_id)
993
        else:
994
            return catalog
995
    except KeyError:
996
        raise NoEndpoints(ep_name, ep_type, ep_region, ep_version_id)
997

    
998

    
999
# --------------------------------------------------------------------
1000
# Private functions
1001
# We want _do_request to be a distinct function
1002
# so that we can replace it during unit tests.
1003
def _do_request(conn, method, url, **kwargs):
1004
    """The actual request. This function can easily be mocked"""
1005
    conn.request(method, url, **kwargs)
1006
    response = conn.getresponse()
1007
    length = response.getheader('content-length', None)
1008
    data = response.read(length)
1009
    status = int(response.status)
1010
    message = response.reason
1011
    return (message, data, status)