Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 75144caa

History | View | Annotate | Download (36.8 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
        oa2_service_catalog = parse_endpoints(endpoints, ep_name="astakos_oa2")
149
        self._oa2_url = \
150
            oa2_service_catalog[0]['endpoints'][0]['publicURL']
151
        parsed_oa2_url = urlparse.urlparse(self._oa2_url)
152
        self._oa2_prefix = parsed_oa2_url.path
153

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

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

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

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

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

    
180
    @property
181
    def oa2_url(self):
182
        return self._get_value('_oa2_url')
183

    
184
    @property
185
    def oa2_prefix(self):
186
        return self._get_value('_oa2_prefix')
187

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

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

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

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

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

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

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

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

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

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

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

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

    
236
    @property
237
    def api_oa2_auth(self):
238
        return join_urls(self.oa2_prefix, "auth")
239

    
240
    @property
241
    def api_oa2_token(self):
242
        return join_urls(self.oa2_prefix, "token")
243

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

461
        In case of error raise an AstakosClientException.
462

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

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

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

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

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

486
        In case of error raise an AstakosClientException.
487

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

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

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

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

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

514
        In case of error raise an AstakosClientException.
515

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
620
        return self.issue_commission(request)
621

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

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

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

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

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

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

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

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

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

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

659
        In case of success return nothing.
660

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

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

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

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

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

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

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

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

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

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

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

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

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

735
        Arguments:
736
        project_id -- project identifier
737

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

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

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

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

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

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

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

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

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

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

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

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

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

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

817
        Arguments:
818
        app_id -- application identifier
819

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

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

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

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

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

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

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

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

866
        Arguments:
867
        memb_id -- membership identifier
868

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

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

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

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

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

899
        Arguments:
900
        project_id -- project identifier
901

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

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

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

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

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

    
939

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

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

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

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

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

    
989

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