Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 00d2a0ee

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
API_AUTHENTICATE = join_urls(ACCOUNTS_PREFIX, "authenticate")
61
API_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "user_catalogs")
62
API_SERVICE_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "service/user_catalogs")
63
API_GETSERVICES = join_urls(ACCOUNTS_PREFIX, "get_services")
64
API_RESOURCES = join_urls(ACCOUNTS_PREFIX, "resources")
65
API_QUOTAS = join_urls(ACCOUNTS_PREFIX, "quotas")
66
API_SERVICE_QUOTAS = join_urls(ACCOUNTS_PREFIX, "service_quotas")
67
API_COMMISSIONS = join_urls(ACCOUNTS_PREFIX, "commissions")
68
API_COMMISSIONS_ACTION = join_urls(API_COMMISSIONS, "action")
69
API_FEEDBACK = join_urls(ACCOUNTS_PREFIX, "feedback")
70

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

    
77

    
78
# --------------------------------------------------------------------
79
# Astakos Client Class
80

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

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

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

    
95

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
519
        return self.issue_commission(token, request)
520

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

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

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

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

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

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

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

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

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

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

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

563
        In case of success return nothing.
564

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

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

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

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

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

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

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

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

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

    
608

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