Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 6476ceb7

History | View | Annotate | Download (37.5 kB)

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

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

    
38
import logging
39
import urlparse
40
import urllib
41
import hashlib
42
from 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, extra=False):
132
        """Fill the endpoints for our AstakosClient
133

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

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

    
147
        self._account_prefix = parsed_account_url.path
148
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)
149

    
150
        self._ui_url = \
151
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
152
        parsed_ui_url = urlparse.urlparse(self._ui_url)
153

    
154
        self._ui_prefix = parsed_ui_url.path
155
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)
156

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

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

    
175
    @property
176
    def account_url(self):
177
        return self._get_value('_account_url')
178

    
179
    @property
180
    def account_prefix(self):
181
        return self._get_value('_account_prefix')
182

    
183
    @property
184
    def ui_url(self):
185
        return self._get_value('_ui_url')
186

    
187
    @property
188
    def ui_prefix(self):
189
        return self._get_value('_ui_prefix')
190

    
191
    @property
192
    def oauth2_url(self):
193
        return self._get_value('_oauth2_url', extra=True)
194

    
195
    @property
196
    def oauth2_prefix(self):
197
        return self._get_value('_oauth2_prefix', extra=True)
198

    
199
    @property
200
    def api_usercatalogs(self):
201
        return join_urls(self.account_prefix, "user_catalogs")
202

    
203
    @property
204
    def api_service_usercatalogs(self):
205
        return join_urls(self.account_prefix, "service/user_catalogs")
206

    
207
    @property
208
    def api_resources(self):
209
        return join_urls(self.account_prefix, "resources")
210

    
211
    @property
212
    def api_quotas(self):
213
        return join_urls(self.account_prefix, "quotas")
214

    
215
    @property
216
    def api_service_quotas(self):
217
        return join_urls(self.account_prefix, "service_quotas")
218

    
219
    @property
220
    def api_commissions(self):
221
        return join_urls(self.account_prefix, "commissions")
222

    
223
    @property
224
    def api_commissions_action(self):
225
        return join_urls(self.api_commissions, "action")
226

    
227
    @property
228
    def api_feedback(self):
229
        return join_urls(self.account_prefix, "feedback")
230

    
231
    @property
232
    def api_projects(self):
233
        return join_urls(self.account_prefix, "projects")
234

    
235
    @property
236
    def api_applications(self):
237
        return join_urls(self.api_projects, "apps")
238

    
239
    @property
240
    def api_memberships(self):
241
        return join_urls(self.api_projects, "memberships")
242

    
243
    @property
244
    def api_getservices(self):
245
        return join_urls(self.ui_prefix, "get_services")
246

    
247
    @property
248
    def api_oauth2_auth(self):
249
        return join_urls(self.oauth2_prefix, "auth")
250

    
251
    @property
252
    def api_oauth2_token(self):
253
        return join_urls(self.oauth2_prefix, "token")
254

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

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

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

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

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

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

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

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

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

    
349
    def get_usernames(self, uuids):
350
        """Return a uuid_catalog dictionary for the given uuids
351

352
        Keyword arguments:
353
        uuids   -- list of user ids (list of strings)
354

355
        The returned uuid_catalog is a dictionary with uuids as
356
        keys and the corresponding user names as values
357

358
        """
359
        return self._uuid_catalog(uuids, self.api_usercatalogs)
360

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

    
370
    def service_get_usernames(self, uuids):
371
        """Return a uuid_catalog dict using a service's token"""
372
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
373

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

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

    
400
    def get_uuids(self, display_names):
401
        """Return a displayname_catalog for the given names
402

403
        Keyword arguments:
404
        display_names   -- list of user names (list of strings)
405

406
        The returned displayname_catalog is a dictionary with
407
        the names as keys and the corresponding uuids as values
408

409
        """
410
        return self._displayname_catalog(
411
            display_names, self.api_usercatalogs)
412

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

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

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

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

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

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

453
        keyword arguments:
454
        message     -- Feedback message
455
        data        -- Additional information about service client status
456

457
        In case of success return nothing.
458
        Otherwise raise an AstakosClientException
459

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

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

472
        The extra parameter is to be used by _fill_endpoints.
473
        In case of error raise an AstakosClientException.
474

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

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

489
        Keyword arguments:
490
        tenant_name         -- user's uniq id (optional)
491

492
        It returns back the token as well as information about the token
493
        holder and the services he/she can access (in json format).
494

495
        The tenant_name is optional and if it is given it must match the
496
        user's uuid.
497

498
        In case of error raise an AstakosClientException.
499

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

    
512
    # --------------------------------------
513
    # do a GET to ``API_TOKENS`` with a token
514
    def validate_token(self, token_id, belongsTo=None):
515
        """ Validate a temporary access token (oath2)
516

517
        Keyword arguments:
518
        belongsTo         -- confirm that token belongs to tenant
519

520
        It returns back the token as well as information about the token
521
        holder.
522

523
        The belongsTo is optional and if it is given it must be inside the
524
        token's scope.
525

526
        In case of error raise an AstakosClientException.
527

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

    
535
    # ----------------------------------
536
    # do a GET to ``API_QUOTAS``
537
    def get_quotas(self):
538
        """Get user's quotas
539

540
        In case of success return a dict of dicts with user's current quotas.
541
        Otherwise raise an AstakosClientException
542

543
        """
544
        return self._call_astakos(self.api_quotas)
545

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

551
        Keyword arguments:
552
        user    -- optionally, the uuid of a specific user
553

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

558
        """
559
        query = self.api_service_quotas
560
        if user is not None:
561
            query += "?user=" + user
562
        return self._call_astakos(query)
563

    
564
    # ----------------------------------
565
    # do a POST to ``API_COMMISSIONS``
566
    def issue_commission(self, request):
567
        """Issue a commission
568

569
        Keyword arguments:
570
        request -- commision request (dict)
571

572
        In case of success return commission's id (int).
573
        Otherwise raise an AstakosClientException.
574

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

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

    
597
    def issue_one_commission(self, holder, source, provisions,
598
                             name="", force=False, auto_accept=False):
599
        """Issue one commission (with specific holder and source)
600

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

609
        In case of success return commission's id (int).
610
        Otherwise raise an AstakosClientException.
611
        (See also issue_commission)
612

613
        """
614
        check_input("issue_one_commission", self.logger,
615
                    holder=holder, source=source,
616
                    provisions=provisions)
617

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

    
632
        return self.issue_commission(request)
633

    
634
    # ----------------------------------
635
    # do a GET to ``API_COMMISSIONS``
636
    def get_pending_commissions(self):
637
        """Get Pending Commissions
638

639
        In case of success return a list of pending commissions' ids
640
        (list of integers)
641

642
        """
643
        return self._call_astakos(self.api_commissions)
644

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

650
        Keyword arguments:
651
        serial  -- commission's id (int)
652

653
        In case of success return a dict of dicts containing
654
        informations (details) about the requested commission
655

656
        """
657
        check_input("get_commission_info", self.logger, serial=serial)
658

    
659
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
660
        return self._call_astakos(path)
661

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

667
        Keyword arguments:
668
        serial  -- commission's id (int)
669
        action  -- action to perform, currently accept/reject (string)
670

671
        In case of success return nothing.
672

673
        """
674
        check_input("commission_action", self.logger,
675
                    serial=serial, action=action)
676

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

    
683
    def accept_commission(self, serial):
684
        """Accept a commission (see commission_action)"""
685
        self.commission_action(serial, "accept")
686

    
687
    def reject_commission(self, serial):
688
        """Reject a commission (see commission_action)"""
689
        self.commission_action(serial, "reject")
690

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

696
        Keyword arguments:
697
        accept_serials  -- commissions to accept (list of ints)
698
        reject_serials  -- commissions to reject (list of ints)
699

700
        In case of success return a dict of dicts describing which
701
        commissions accepted, which rejected and which failed to
702
        resolved.
703

704
        """
705
        check_input("resolve_commissions", self.logger,
706
                    accept_serials=accept_serials,
707
                    reject_serials=reject_serials)
708

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

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

722
        Arguments:
723
        name  -- filter by name (optional)
724
        state -- filter by state (optional)
725
        owner -- filter by owner (optional)
726

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

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

747
        Arguments:
748
        project_id -- project identifier
749

750
        In case of success, return project description.
751
        """
752
        path = join_urls(self.api_projects, str(project_id))
753
        return self._call_astakos(path)
754

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

760
        Arguments:
761
        specs -- dict describing a project
762

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

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

776
        Arguments:
777
        project_id -- project identifier
778
        specs      -- dict describing a project
779

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

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

793
        Arguments:
794
        project_id -- project identifier
795
        action     -- action to perform, one of "suspend", "unsuspend",
796
                      "terminate", "reinstate"
797
        reason     -- reason of performing the action
798

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

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

813
        Arguments:
814
        project -- filter by project (optional)
815

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

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

829
        Arguments:
830
        app_id -- application identifier
831

832
        In case of success, return application description.
833
        """
834
        path = join_urls(self.api_applications, str(app_id))
835
        return self._call_astakos(path)
836

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

842
        Arguments:
843
        app_id -- application identifier
844
        action -- action to perform, one of "approve", "deny",
845
                  "dismiss", "cancel"
846
        reason -- reason of performing the action
847

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

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

862
        Arguments:
863
        project -- filter by project (optional)
864

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

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

878
        Arguments:
879
        memb_id -- membership identifier
880

881
        In case of success, return membership description.
882
        """
883
        path = join_urls(self.api_memberships, str(memb_id))
884
        return self._call_astakos(path)
885

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

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

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

    
906
    # --------------------------------
907
    # do a POST to ``API_MEMBERSHIPS``
908
    def join_project(self, project_id):
909
        """Join a project
910

911
        Arguments:
912
        project_id -- project identifier
913

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

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

927
        Arguments:
928
        project_id -- project identifier
929
        email      -- user identified by email
930

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

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

    
951

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

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

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

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

    
993
        if not catalog:
994
            raise NoEndpoints(ep_name, ep_type,
995
                              ep_region, ep_version_id)
996
        else:
997
            return catalog
998
    except KeyError:
999
        raise NoEndpoints()
1000

    
1001

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