Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 26498848

History | View | Annotate | Download (23.2 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
import logging
35
import urlparse
36
import urllib
37
import hashlib
38
from copy import copy
39

    
40
import simplejson
41
from astakosclient.utils import \
42
    retry, scheme_to_class, parse_request, check_input
43
from astakosclient.errors import \
44
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
45
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse
46
from .keypath import get_path
47
from .services import astakos_services
48

    
49

    
50
# Customize astakos_services here?
51

    
52

    
53
def join_urls(a, b):
54
    """join_urls from synnefo.lib"""
55
    return a.rstrip("/") + "/" + b.lstrip("/")
56

    
57
# --------------------------------------------------------------------
58
# Astakos API urls
59
ACCOUNTS_PREFIX = get_path(astakos_services, 'astakos_account.prefix')
60
ACCOUNTS_PREFIX = join_urls(ACCOUNTS_PREFIX, 'v1.0')
61
API_AUTHENTICATE = join_urls(ACCOUNTS_PREFIX, "authenticate")
62
API_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "user_catalogs")
63
API_SERVICE_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "service/user_catalogs")
64
API_GETSERVICES = join_urls(ACCOUNTS_PREFIX, "get_services")
65
API_RESOURCES = join_urls(ACCOUNTS_PREFIX, "resources")
66
API_QUOTAS = join_urls(ACCOUNTS_PREFIX, "quotas")
67
API_SERVICE_QUOTAS = join_urls(ACCOUNTS_PREFIX, "service_quotas")
68
API_COMMISSIONS = join_urls(ACCOUNTS_PREFIX, "commissions")
69
API_COMMISSIONS_ACTION = join_urls(API_COMMISSIONS, "action")
70
API_FEEDBACK = join_urls(ACCOUNTS_PREFIX, "feedback")
71

    
72
# --------------------------------------------------------------------
73
# Astakos Keystone API urls
74
IDENTITY_PREFIX = get_path(astakos_services, 'astakos_identity.prefix')
75
API_TOKENS = join_urls(IDENTITY_PREFIX, "tokens")
76
TOKENS_ENDPOINTS = join_urls(API_TOKENS, "endpoints")
77

    
78

    
79
# --------------------------------------------------------------------
80
# Astakos Client Class
81

    
82
def get_token_from_cookie(request, cookie_name):
83
    """Extract token from the cookie name provided
84

85
    Cookie should be in the same form as astakos
86
    service sets its cookie contents:
87
        <user_uniq>|<user_token>
88

89
    """
90
    try:
91
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
92
        return cookie_content.split("|")[1]
93
    except:
94
        return None
95

    
96

    
97
class AstakosClient():
98
    """AstakosClient Class Implementation"""
99

    
100
    # ----------------------------------
101
    def __init__(self, astakos_url, retry=0,
102
                 use_pool=False, pool_size=8, logger=None):
103
        """Initialize AstakosClient Class
104

105
        Keyword arguments:
106
        astakos_url -- i.e https://accounts.example.com (string)
107
        use_pool    -- use objpool for http requests (boolean)
108
        retry       -- how many time to retry (integer)
109
        logger      -- pass a different logger
110

111
        """
112
        if logger is None:
113
            logging.basicConfig(
114
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
115
                datefmt='%Y-%m-%d %H:%M:%S',
116
                level=logging.INFO)
117
            logger = logging.getLogger("astakosclient")
118
        logger.debug("Intialize AstakosClient: astakos_url = %s, "
119
                     "use_pool = %s" % (astakos_url, use_pool))
120

    
121
        check_input("__init__", logger, astakos_url=astakos_url)
122

    
123
        # Check for supported scheme
124
        p = urlparse.urlparse(astakos_url)
125
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
126
        if conn_class is None:
127
            m = "Unsupported scheme: %s" % p.scheme
128
            logger.error(m)
129
            raise BadValue(m)
130

    
131
        # Save astakos_url etc. in our class
132
        self.retry = retry
133
        self.logger = logger
134
        self.netloc = p.netloc
135
        self.scheme = p.scheme
136
        self.path = p.path.rstrip('/')
137
        self.conn_class = conn_class
138

    
139
    # ----------------------------------
140
    @retry
141
    def _call_astakos(self, token, request_path, headers=None,
142
                      body=None, method="GET", log_body=True):
143
        """Make the actual call to Astakos Service"""
144
        if token is not None:
145
            hashed_token = hashlib.sha1()
146
            hashed_token.update(token)
147
            using_token = "using token %s" % (hashed_token.hexdigest())
148
        else:
149
            using_token = "without using token"
150
        self.logger.debug(
151
            "Make a %s request to %s %s with headers %s and body %s"
152
            % (method, request_path, using_token, headers,
153
               body if log_body else "(not logged)"))
154

    
155
        # Check Input
156
        if headers is None:
157
            headers = {}
158
        if body is None:
159
            body = {}
160
        path = self.path + "/" + request_path.strip('/')
161

    
162
        # Build request's header and body
163
        kwargs = {}
164
        kwargs['headers'] = copy(headers)
165
        if token is not None:
166
            kwargs['headers']['X-Auth-Token'] = token
167
        if body:
168
            kwargs['body'] = copy(body)
169
            kwargs['headers'].setdefault(
170
                'content-type', 'application/octet-stream')
171
        kwargs['headers'].setdefault('content-length',
172
                                     len(body) if body else 0)
173

    
174
        try:
175
            # Get the connection object
176
            with self.conn_class(self.netloc) as conn:
177
                # Send request
178
                (message, data, status) = \
179
                    _do_request(conn, method, path, **kwargs)
180
        except Exception as err:
181
            self.logger.error("Failed to send request: %s" % repr(err))
182
            raise AstakosClientException(str(err))
183

    
184
        # Return
185
        self.logger.debug("Request returned with status %s" % status)
186
        if status == 400:
187
            raise BadRequest(message, data)
188
        elif status == 401:
189
            raise Unauthorized(message, data)
190
        elif status == 403:
191
            raise Forbidden(message, data)
192
        elif status == 404:
193
            raise NotFound(message, data)
194
        elif status < 200 or status >= 300:
195
            raise AstakosClientException(message, data, status)
196

    
197
        try:
198
            if data:
199
                return simplejson.loads(unicode(data))
200
            else:
201
                return None
202
        except Exception as err:
203
            self.logger.error("Cannot parse response \"%s\" with simplejson: %s"
204
                              % (data, str(err)))
205
            raise InvalidResponse(str(err), data)
206

    
207
    # ------------------------
208
    # do a GET to ``API_AUTHENTICATE``
209
    def get_user_info(self, token, usage=False):
210
        """Authenticate user and get user's info as a dictionary
211

212
        Keyword arguments:
213
        token   -- user's token (string)
214
        usage   -- return usage information for user (boolean)
215

216
        In case of success return user information (json parsed format).
217
        Otherwise raise an AstakosClientException.
218

219
        """
220
        # Send request
221
        auth_path = copy(API_AUTHENTICATE)
222
        if usage:
223
            auth_path += "?usage=1"
224
        return self._call_astakos(token, auth_path)
225

    
226
    # ----------------------------------
227
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
228
    #   with {'uuids': uuids}
229
    def _uuid_catalog(self, token, uuids, req_path):
230
        req_headers = {'content-type': 'application/json'}
231
        req_body = parse_request({'uuids': uuids}, self.logger)
232
        data = self._call_astakos(
233
            token, req_path, req_headers, req_body, "POST")
234
        if "uuid_catalog" in data:
235
            return data.get("uuid_catalog")
236
        else:
237
            m = "_uuid_catalog request returned %s. No uuid_catalog found" \
238
                % data
239
            self.logger.error(m)
240
            raise AstakosClientException(m)
241

    
242
    def get_usernames(self, token, uuids):
243
        """Return a uuid_catalog dictionary for the given uuids
244

245
        Keyword arguments:
246
        token   -- user's token (string)
247
        uuids   -- list of user ids (list of strings)
248

249
        The returned uuid_catalog is a dictionary with uuids as
250
        keys and the corresponding user names as values
251

252
        """
253
        req_path = copy(API_USERCATALOGS)
254
        return self._uuid_catalog(token, uuids, req_path)
255

    
256
    def get_username(self, token, uuid):
257
        """Return the user name of a uuid (see get_usernames)"""
258
        check_input("get_username", self.logger, uuid=uuid)
259
        uuid_dict = self.get_usernames(token, [uuid])
260
        if uuid in uuid_dict:
261
            return uuid_dict.get(uuid)
262
        else:
263
            raise NoUserName(uuid)
264

    
265
    def service_get_usernames(self, token, uuids):
266
        """Return a uuid_catalog dict using a service's token"""
267
        req_path = copy(API_SERVICE_USERCATALOGS)
268
        return self._uuid_catalog(token, uuids, req_path)
269

    
270
    def service_get_username(self, token, uuid):
271
        """Return the displayName of a uuid using a service's token"""
272
        check_input("service_get_username", self.logger, uuid=uuid)
273
        uuid_dict = self.service_get_usernames(token, [uuid])
274
        if uuid in uuid_dict:
275
            return uuid_dict.get(uuid)
276
        else:
277
            raise NoUserName(uuid)
278

    
279
    # ----------------------------------
280
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
281
    #   with {'displaynames': display_names}
282
    def _displayname_catalog(self, token, display_names, req_path):
283
        req_headers = {'content-type': 'application/json'}
284
        req_body = parse_request({'displaynames': display_names}, self.logger)
285
        data = self._call_astakos(
286
            token, req_path, req_headers, req_body, "POST")
287
        if "displayname_catalog" in data:
288
            return data.get("displayname_catalog")
289
        else:
290
            m = "_displayname_catalog request returned %s. " \
291
                "No displayname_catalog found" % data
292
            self.logger.error(m)
293
            raise AstakosClientException(m)
294

    
295
    def get_uuids(self, token, display_names):
296
        """Return a displayname_catalog for the given names
297

298
        Keyword arguments:
299
        token           -- user's token (string)
300
        display_names   -- list of user names (list of strings)
301

302
        The returned displayname_catalog is a dictionary with
303
        the names as keys and the corresponding uuids as values
304

305
        """
306
        req_path = copy(API_USERCATALOGS)
307
        return self._displayname_catalog(token, display_names, req_path)
308

    
309
    def get_uuid(self, token, display_name):
310
        """Return the uuid of a name (see getUUIDs)"""
311
        check_input("get_uuid", self.logger, display_name=display_name)
312
        name_dict = self.get_uuids(token, [display_name])
313
        if display_name in name_dict:
314
            return name_dict.get(display_name)
315
        else:
316
            raise NoUUID(display_name)
317

    
318
    def service_get_uuids(self, token, display_names):
319
        """Return a display_name catalog using a service's token"""
320
        req_path = copy(API_SERVICE_USERCATALOGS)
321
        return self._displayname_catalog(token, display_names, req_path)
322

    
323
    def service_get_uuid(self, token, display_name):
324
        """Return the uuid of a name using a service's token"""
325
        check_input("service_get_uuid", self.logger, display_name=display_name)
326
        name_dict = self.service_get_uuids(token, [display_name])
327
        if display_name in name_dict:
328
            return name_dict.get(display_name)
329
        else:
330
            raise NoUUID(display_name)
331

    
332
    # ----------------------------------
333
    # do a GET to ``API_GETSERVICES``
334
    def get_services(self):
335
        """Return a list of dicts with the registered services"""
336
        return self._call_astakos(None, copy(API_GETSERVICES))
337

    
338
    # ----------------------------------
339
    # do a GET to ``API_RESOURCES``
340
    def get_resources(self):
341
        """Return a dict of dicts with the available resources"""
342
        return self._call_astakos(None, copy(API_RESOURCES))
343

    
344
    # ----------------------------------
345
    # do a POST to ``API_FEEDBACK``
346
    def send_feedback(self, token, message, data):
347
        """Send feedback to astakos service
348

349
        keyword arguments:
350
        token       -- user's token (string)
351
        message     -- Feedback message
352
        data        -- Additional information about service client status
353

354
        In case of success return nothing.
355
        Otherwise raise an AstakosClientException
356

357
        """
358
        check_input("send_feedback", self.logger, message=message, data=data)
359
        path = copy(API_FEEDBACK)
360
        req_body = urllib.urlencode(
361
            {'feedback_msg': message, 'feedback_data': data})
362
        self._call_astakos(token, path, None, req_body, "POST")
363

    
364
    # ----------------------------------
365
    # do a GET to ``API_TOKENS``/<user_token>/``TOKENS_ENDPOINTS``
366
    def get_endpoints(self, token, belongs_to=None, marker=None, limit=None):
367
        """Request registered endpoints from astakos
368

369
        keyword arguments:
370
        token       -- user's token (string)
371
        belongs_to  -- user's uuid (string)
372
        marker      -- return endpoints whose ID is higher than marker's (int)
373
        limit       -- maximum number of endpoints to return (int)
374

375
        Return a json formatted dictionary containing information
376
        about registered endpoints.
377

378
        WARNING: This api call encodes the user's token inside the url.
379
        It's thoughs security unsafe to use it (both astakosclient and
380
        nginx tend to log requested urls).
381
        Avoid the use of get_endpoints method and use
382
        get_user_info_with_endpoints instead.
383

384
        """
385
        params = {}
386
        if belongs_to is not None:
387
            params['belongsTo'] = str(belongs_to)
388
        if marker is not None:
389
            params['marker'] = str(marker)
390
        if limit is not None:
391
            params['limit'] = str(limit)
392
        path = API_TOKENS + "/" + token + "/" + \
393
            TOKENS_ENDPOINTS + "?" + urllib.urlencode(params)
394
        return self._call_astakos(token, path)
395

    
396
    # ----------------------------------
397
    # do a POST to ``API_TOKENS``
398
    def get_user_info_with_endpoints(self, token, uuid=None):
399
        """ Fallback call for authenticate
400

401
        Keyword arguments:
402
        token   -- user's token (string)
403
        uuid    -- user's uniq id
404

405
        It returns back the token as well as information about the token
406
        holder and the services he/she can acess (in json format).
407
        In case of error raise an AstakosClientException.
408

409
        """
410
        req_path = copy(API_TOKENS)
411
        req_headers = {'content-type': 'application/json'}
412
        body = {'auth': {'token': {'id': token}}}
413
        if uuid is not None:
414
            body['auth']['tenantName'] = uuid
415
        req_body = parse_request(body, self.logger)
416
        return self._call_astakos(token, req_path, req_headers,
417
                                  req_body, "POST", False)
418

    
419
    # ----------------------------------
420
    # do a GET to ``API_QUOTAS``
421
    def get_quotas(self, token):
422
        """Get user's quotas
423

424
        Keyword arguments:
425
        token   -- user's token (string)
426

427
        In case of success return a dict of dicts with user's current quotas.
428
        Otherwise raise an AstakosClientException
429

430
        """
431
        return self._call_astakos(token, copy(API_QUOTAS))
432

    
433
    # ----------------------------------
434
    # do a GET to ``API_SERVICE_QUOTAS``
435
    def service_get_quotas(self, token, user=None):
436
        """Get all quotas for resources associated with the service
437

438
        Keyword arguments:
439
        token   -- service's token (string)
440
        user    -- optionally, the uuid of a specific user
441

442
        In case of success return a dict of dicts of dicts with current quotas
443
        for all users, or of a specified user, if user argument is set.
444
        Otherwise raise an AstakosClientException
445

446
        """
447
        query = copy(API_SERVICE_QUOTAS)
448
        if user is not None:
449
            query += "?user=" + user
450
        return self._call_astakos(token, query)
451

    
452
    # ----------------------------------
453
    # do a POST to ``API_COMMISSIONS``
454
    def issue_commission(self, token, request):
455
        """Issue a commission
456

457
        Keyword arguments:
458
        token   -- service's token (string)
459
        request -- commision request (dict)
460

461
        In case of success return commission's id (int).
462
        Otherwise raise an AstakosClientException.
463

464
        """
465
        req_headers = {'content-type': 'application/json'}
466
        req_body = parse_request(request, self.logger)
467
        try:
468
            response = self._call_astakos(token, copy(API_COMMISSIONS),
469
                                          req_headers, req_body, "POST")
470
        except AstakosClientException as err:
471
            if err.status == 413:
472
                raise QuotaLimit(err.message, err.details)
473
            else:
474
                raise
475

    
476
        if "serial" in response:
477
            return response['serial']
478
        else:
479
            m = "issue_commission_core request returned %s. No serial found" \
480
                % response
481
            self.logger.error(m)
482
            raise AstakosClientException(m)
483

    
484
    def issue_one_commission(self, token, holder, source, provisions,
485
                             name="", force=False, auto_accept=False):
486
        """Issue one commission (with specific holder and source)
487

488
        keyword arguments:
489
        token       -- service's token (string)
490
        holder      -- user's id (string)
491
        source      -- commission's source (ex system) (string)
492
        provisions  -- resources with their quantity (dict from string to int)
493
        name        -- description of the commission (string)
494
        force       -- force this commission (boolean)
495
        auto_accept -- auto accept this commission (boolean)
496

497
        In case of success return commission's id (int).
498
        Otherwise raise an AstakosClientException.
499
        (See also issue_commission)
500

501
        """
502
        check_input("issue_one_commission", self.logger,
503
                    holder=holder, source=source,
504
                    provisions=provisions)
505

    
506
        request = {}
507
        request["force"] = force
508
        request["auto_accept"] = auto_accept
509
        request["name"] = name
510
        try:
511
            request["provisions"] = []
512
            for resource, quantity in provisions.iteritems():
513
                t = {"holder": holder, "source": source,
514
                     "resource": resource, "quantity": quantity}
515
                request["provisions"].append(t)
516
        except Exception as err:
517
            self.logger.error(str(err))
518
            raise BadValue(str(err))
519

    
520
        return self.issue_commission(token, request)
521

    
522
    # ----------------------------------
523
    # do a GET to ``API_COMMISSIONS``
524
    def get_pending_commissions(self, token):
525
        """Get Pending Commissions
526

527
        Keyword arguments:
528
        token   -- service's token (string)
529

530
        In case of success return a list of pending commissions' ids
531
        (list of integers)
532

533
        """
534
        return self._call_astakos(token, copy(API_COMMISSIONS))
535

    
536
    # ----------------------------------
537
    # do a GET to ``API_COMMISSIONS``/<serial>
538
    def get_commission_info(self, token, serial):
539
        """Get Description of a Commission
540

541
        Keyword arguments:
542
        token   -- service's token (string)
543
        serial  -- commission's id (int)
544

545
        In case of success return a dict of dicts containing
546
        informations (details) about the requested commission
547

548
        """
549
        check_input("get_commission_info", self.logger, serial=serial)
550

    
551
        path = API_COMMISSIONS + "/" + str(serial)
552
        return self._call_astakos(token, path)
553

    
554
    # ----------------------------------
555
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
556
    def commission_action(self, token, serial, action):
557
        """Perform a commission action
558

559
        Keyword arguments:
560
        token   -- service's token (string)
561
        serial  -- commission's id (int)
562
        action  -- action to perform, currently accept/reject (string)
563

564
        In case of success return nothing.
565

566
        """
567
        check_input("commission_action", self.logger,
568
                    serial=serial, action=action)
569

    
570
        path = API_COMMISSIONS + "/" + str(serial) + "/action"
571
        req_headers = {'content-type': 'application/json'}
572
        req_body = parse_request({str(action): ""}, self.logger)
573
        self._call_astakos(token, path, req_headers, req_body, "POST")
574

    
575
    def accept_commission(self, token, serial):
576
        """Accept a commission (see commission_action)"""
577
        self.commission_action(token, serial, "accept")
578

    
579
    def reject_commission(self, token, serial):
580
        """Reject a commission (see commission_action)"""
581
        self.commission_action(token, serial, "reject")
582

    
583
    # ----------------------------------
584
    # do a POST to ``API_COMMISSIONS_ACTION``
585
    def resolve_commissions(self, token, accept_serials, reject_serials):
586
        """Resolve multiple commissions at once
587

588
        Keyword arguments:
589
        token           -- service's token (string)
590
        accept_serials  -- commissions to accept (list of ints)
591
        reject_serials  -- commissions to reject (list of ints)
592

593
        In case of success return a dict of dicts describing which
594
        commissions accepted, which rejected and which failed to
595
        resolved.
596

597
        """
598
        check_input("resolve_commissions", self.logger,
599
                    accept_serials=accept_serials,
600
                    reject_serials=reject_serials)
601

    
602
        path = copy(API_COMMISSIONS_ACTION)
603
        req_headers = {'content-type': 'application/json'}
604
        req_body = parse_request({"accept": accept_serials,
605
                                  "reject": reject_serials},
606
                                 self.logger)
607
        return self._call_astakos(token, path, req_headers, req_body, "POST")
608

    
609

    
610
# --------------------------------------------------------------------
611
# Private functions
612
# We want _doRequest to be a distinct function
613
# so that we can replace it during unit tests.
614
def _do_request(conn, method, url, **kwargs):
615
    """The actual request. This function can easily be mocked"""
616
    conn.request(method, url, **kwargs)
617
    response = conn.getresponse()
618
    length = response.getheader('content-length', None)
619
    data = response.read(length)
620
    status = int(response.status)
621
    message = response.reason
622
    return (message, data, status)