Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ e3ff6830

History | View | Annotate | Download (23 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

    
47

    
48
def join_urls(a, b):
49
    """join_urls from synnefo.lib"""
50
    return a.rstrip("/") + "/" + b.lstrip("/")
51

    
52
# --------------------------------------------------------------------
53
# Astakos API urls
54
ACCOUNTS_PREFIX = 'accounts'
55
API_AUTHENTICATE = join_urls(ACCOUNTS_PREFIX, "authenticate")
56
API_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "user_catalogs")
57
API_SERVICE_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "service/user_catalogs")
58
API_GETSERVICES = join_urls(ACCOUNTS_PREFIX, "get_services")
59
API_RESOURCES = join_urls(ACCOUNTS_PREFIX, "resources")
60
API_QUOTAS = join_urls(ACCOUNTS_PREFIX, "quotas")
61
API_SERVICE_QUOTAS = join_urls(ACCOUNTS_PREFIX, "service_quotas")
62
API_COMMISSIONS = join_urls(ACCOUNTS_PREFIX, "commissions")
63
API_COMMISSIONS_ACTION = join_urls(API_COMMISSIONS, "action")
64
API_FEEDBACK = join_urls(ACCOUNTS_PREFIX, "feedback")
65

    
66
# --------------------------------------------------------------------
67
# Astakos Keystone API urls
68
KEYSTONE_PREFIX = 'keystone'
69
API_TOKENS = join_urls(KEYSTONE_PREFIX, "tokens")
70
TOKENS_ENDPOINTS = join_urls(API_TOKENS, "endpoints")
71

    
72

    
73
# --------------------------------------------------------------------
74
# Astakos Client Class
75

    
76
def get_token_from_cookie(request, cookie_name):
77
    """Extract token from the cookie name provided
78

79
    Cookie should be in the same form as astakos
80
    service sets its cookie contents:
81
        <user_uniq>|<user_token>
82

83
    """
84
    try:
85
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
86
        return cookie_content.split("|")[1]
87
    except:
88
        return None
89

    
90

    
91
class AstakosClient():
92
    """AstakosClient Class Implementation"""
93

    
94
    # ----------------------------------
95
    def __init__(self, astakos_url, retry=0,
96
                 use_pool=False, pool_size=8, logger=None):
97
        """Initialize AstakosClient Class
98

99
        Keyword arguments:
100
        astakos_url -- i.e https://accounts.example.com (string)
101
        use_pool    -- use objpool for http requests (boolean)
102
        retry       -- how many time to retry (integer)
103
        logger      -- pass a different logger
104

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

    
115
        check_input("__init__", logger, astakos_url=astakos_url)
116

    
117
        # Check for supported scheme
118
        p = urlparse.urlparse(astakos_url)
119
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
120
        if conn_class is None:
121
            m = "Unsupported scheme: %s" % p.scheme
122
            logger.error(m)
123
            raise BadValue(m)
124

    
125
        # Save astakos_url etc. in our class
126
        self.retry = retry
127
        self.logger = logger
128
        self.netloc = p.netloc
129
        self.scheme = p.scheme
130
        self.path = p.path.rstrip('/')
131
        self.conn_class = conn_class
132

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

    
149
        # Check Input
150
        if headers is None:
151
            headers = {}
152
        if body is None:
153
            body = {}
154
        path = self.path + "/" + request_path.strip('/')
155

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

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

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

    
191
        try:
192
            if data:
193
                return simplejson.loads(unicode(data))
194
            else:
195
                return None
196
        except Exception as err:
197
            self.logger.error("Cannot parse response \"%s\" with simplejson: %s"
198
                              % (data, str(err)))
199
            raise InvalidResponse(str(err), data)
200

    
201
    # ------------------------
202
    # do a GET to ``API_AUTHENTICATE``
203
    def get_user_info(self, token, usage=False):
204
        """Authenticate user and get user's info as a dictionary
205

206
        Keyword arguments:
207
        token   -- user's token (string)
208
        usage   -- return usage information for user (boolean)
209

210
        In case of success return user information (json parsed format).
211
        Otherwise raise an AstakosClientException.
212

213
        """
214
        # Send request
215
        auth_path = copy(API_AUTHENTICATE)
216
        if usage:
217
            auth_path += "?usage=1"
218
        return self._call_astakos(token, auth_path)
219

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

    
236
    def get_usernames(self, token, uuids):
237
        """Return a uuid_catalog dictionary for the given uuids
238

239
        Keyword arguments:
240
        token   -- user's token (string)
241
        uuids   -- list of user ids (list of strings)
242

243
        The returned uuid_catalog is a dictionary with uuids as
244
        keys and the corresponding user names as values
245

246
        """
247
        req_path = copy(API_USERCATALOGS)
248
        return self._uuid_catalog(token, uuids, req_path)
249

    
250
    def get_username(self, token, uuid):
251
        """Return the user name of a uuid (see get_usernames)"""
252
        check_input("get_username", self.logger, uuid=uuid)
253
        uuid_dict = self.get_usernames(token, [uuid])
254
        if uuid in uuid_dict:
255
            return uuid_dict.get(uuid)
256
        else:
257
            raise NoUserName(uuid)
258

    
259
    def service_get_usernames(self, token, uuids):
260
        """Return a uuid_catalog dict using a service's token"""
261
        req_path = copy(API_SERVICE_USERCATALOGS)
262
        return self._uuid_catalog(token, uuids, req_path)
263

    
264
    def service_get_username(self, token, uuid):
265
        """Return the displayName of a uuid using a service's token"""
266
        check_input("service_get_username", self.logger, uuid=uuid)
267
        uuid_dict = self.service_get_usernames(token, [uuid])
268
        if uuid in uuid_dict:
269
            return uuid_dict.get(uuid)
270
        else:
271
            raise NoUserName(uuid)
272

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

    
289
    def get_uuids(self, token, display_names):
290
        """Return a displayname_catalog for the given names
291

292
        Keyword arguments:
293
        token           -- user's token (string)
294
        display_names   -- list of user names (list of strings)
295

296
        The returned displayname_catalog is a dictionary with
297
        the names as keys and the corresponding uuids as values
298

299
        """
300
        req_path = copy(API_USERCATALOGS)
301
        return self._displayname_catalog(token, display_names, req_path)
302

    
303
    def get_uuid(self, token, display_name):
304
        """Return the uuid of a name (see getUUIDs)"""
305
        check_input("get_uuid", self.logger, display_name=display_name)
306
        name_dict = self.get_uuids(token, [display_name])
307
        if display_name in name_dict:
308
            return name_dict.get(display_name)
309
        else:
310
            raise NoUUID(display_name)
311

    
312
    def service_get_uuids(self, token, display_names):
313
        """Return a display_name catalog using a service's token"""
314
        req_path = copy(API_SERVICE_USERCATALOGS)
315
        return self._displayname_catalog(token, display_names, req_path)
316

    
317
    def service_get_uuid(self, token, display_name):
318
        """Return the uuid of a name using a service's token"""
319
        check_input("service_get_uuid", self.logger, display_name=display_name)
320
        name_dict = self.service_get_uuids(token, [display_name])
321
        if display_name in name_dict:
322
            return name_dict.get(display_name)
323
        else:
324
            raise NoUUID(display_name)
325

    
326
    # ----------------------------------
327
    # do a GET to ``API_GETSERVICES``
328
    def get_services(self):
329
        """Return a list of dicts with the registered services"""
330
        return self._call_astakos(None, copy(API_GETSERVICES))
331

    
332
    # ----------------------------------
333
    # do a GET to ``API_RESOURCES``
334
    def get_resources(self):
335
        """Return a dict of dicts with the available resources"""
336
        return self._call_astakos(None, copy(API_RESOURCES))
337

    
338
    # ----------------------------------
339
    # do a POST to ``API_FEEDBACK``
340
    def send_feedback(self, token, message, data):
341
        """Send feedback to astakos service
342

343
        keyword arguments:
344
        token       -- user's token (string)
345
        message     -- Feedback message
346
        data        -- Additional information about service client status
347

348
        In case of success return nothing.
349
        Otherwise raise an AstakosClientException
350

351
        """
352
        check_input("send_feedback", self.logger, message=message, data=data)
353
        path = copy(API_FEEDBACK)
354
        req_body = urllib.urlencode(
355
            {'feedback_msg': message, 'feedback_data': data})
356
        self._call_astakos(token, path, None, req_body, "POST")
357

    
358
    # ----------------------------------
359
    # do a GET to ``API_TOKENS``/<user_token>/``TOKENS_ENDPOINTS``
360
    def get_endpoints(self, token, belongs_to=None, marker=None, limit=None):
361
        """Request registered endpoints from astakos
362

363
        keyword arguments:
364
        token       -- user's token (string)
365
        belongs_to  -- user's uuid (string)
366
        marker      -- return endpoints whose ID is higher than marker's (int)
367
        limit       -- maximum number of endpoints to return (int)
368

369
        Return a json formatted dictionary containing information
370
        about registered endpoints.
371

372
        WARNING: This api call encodes the user's token inside the url.
373
        It's thoughs security unsafe to use it (both astakosclient and
374
        nginx tend to log requested urls).
375
        Avoid the use of get_endpoints method and use
376
        get_user_info_with_endpoints instead.
377

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

    
390
    # ----------------------------------
391
    # do a POST to ``API_TOKENS``
392
    def get_user_info_with_endpoints(self, token, uuid=None):
393
        """ Fallback call for authenticate
394

395
        Keyword arguments:
396
        token   -- user's token (string)
397
        uuid    -- user's uniq id
398

399
        It returns back the token as well as information about the token
400
        holder and the services he/she can acess (in json format).
401
        In case of error raise an AstakosClientException.
402

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

    
413
    # ----------------------------------
414
    # do a GET to ``API_QUOTAS``
415
    def get_quotas(self, token):
416
        """Get user's quotas
417

418
        Keyword arguments:
419
        token   -- user's token (string)
420

421
        In case of success return a dict of dicts with user's current quotas.
422
        Otherwise raise an AstakosClientException
423

424
        """
425
        return self._call_astakos(token, copy(API_QUOTAS))
426

    
427
    # ----------------------------------
428
    # do a GET to ``API_SERVICE_QUOTAS``
429
    def service_get_quotas(self, token, user=None):
430
        """Get all quotas for resources associated with the service
431

432
        Keyword arguments:
433
        token   -- service's token (string)
434
        user    -- optionally, the uuid of a specific user
435

436
        In case of success return a dict of dicts of dicts with current quotas
437
        for all users, or of a specified user, if user argument is set.
438
        Otherwise raise an AstakosClientException
439

440
        """
441
        query = copy(API_SERVICE_QUOTAS)
442
        if user is not None:
443
            query += "?user=" + user
444
        return self._call_astakos(token, query)
445

    
446
    # ----------------------------------
447
    # do a POST to ``API_COMMISSIONS``
448
    def issue_commission(self, token, request):
449
        """Issue a commission
450

451
        Keyword arguments:
452
        token   -- service's token (string)
453
        request -- commision request (dict)
454

455
        In case of success return commission's id (int).
456
        Otherwise raise an AstakosClientException.
457

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

    
470
        if "serial" in response:
471
            return response['serial']
472
        else:
473
            m = "issue_commission_core request returned %s. No serial found" \
474
                % response
475
            self.logger.error(m)
476
            raise AstakosClientException(m)
477

    
478
    def issue_one_commission(self, token, holder, source, provisions,
479
                             name="", force=False, auto_accept=False):
480
        """Issue one commission (with specific holder and source)
481

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

491
        In case of success return commission's id (int).
492
        Otherwise raise an AstakosClientException.
493
        (See also issue_commission)
494

495
        """
496
        check_input("issue_one_commission", self.logger,
497
                    holder=holder, source=source,
498
                    provisions=provisions)
499

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

    
514
        return self.issue_commission(token, request)
515

    
516
    # ----------------------------------
517
    # do a GET to ``API_COMMISSIONS``
518
    def get_pending_commissions(self, token):
519
        """Get Pending Commissions
520

521
        Keyword arguments:
522
        token   -- service's token (string)
523

524
        In case of success return a list of pending commissions' ids
525
        (list of integers)
526

527
        """
528
        return self._call_astakos(token, copy(API_COMMISSIONS))
529

    
530
    # ----------------------------------
531
    # do a GET to ``API_COMMISSIONS``/<serial>
532
    def get_commission_info(self, token, serial):
533
        """Get Description of a Commission
534

535
        Keyword arguments:
536
        token   -- service's token (string)
537
        serial  -- commission's id (int)
538

539
        In case of success return a dict of dicts containing
540
        informations (details) about the requested commission
541

542
        """
543
        check_input("get_commission_info", self.logger, serial=serial)
544

    
545
        path = API_COMMISSIONS + "/" + str(serial)
546
        return self._call_astakos(token, path)
547

    
548
    # ----------------------------------
549
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
550
    def commission_action(self, token, serial, action):
551
        """Perform a commission action
552

553
        Keyword arguments:
554
        token   -- service's token (string)
555
        serial  -- commission's id (int)
556
        action  -- action to perform, currently accept/reject (string)
557

558
        In case of success return nothing.
559

560
        """
561
        check_input("commission_action", self.logger,
562
                    serial=serial, action=action)
563

    
564
        path = API_COMMISSIONS + "/" + str(serial) + "/action"
565
        req_headers = {'content-type': 'application/json'}
566
        req_body = parse_request({str(action): ""}, self.logger)
567
        self._call_astakos(token, path, req_headers, req_body, "POST")
568

    
569
    def accept_commission(self, token, serial):
570
        """Accept a commission (see commission_action)"""
571
        self.commission_action(token, serial, "accept")
572

    
573
    def reject_commission(self, token, serial):
574
        """Reject a commission (see commission_action)"""
575
        self.commission_action(token, serial, "reject")
576

    
577
    # ----------------------------------
578
    # do a POST to ``API_COMMISSIONS_ACTION``
579
    def resolve_commissions(self, token, accept_serials, reject_serials):
580
        """Resolve multiple commissions at once
581

582
        Keyword arguments:
583
        token           -- service's token (string)
584
        accept_serials  -- commissions to accept (list of ints)
585
        reject_serials  -- commissions to reject (list of ints)
586

587
        In case of success return a dict of dicts describing which
588
        commissions accepted, which rejected and which failed to
589
        resolved.
590

591
        """
592
        check_input("resolve_commissions", self.logger,
593
                    accept_serials=accept_serials,
594
                    reject_serials=reject_serials)
595

    
596
        path = copy(API_COMMISSIONS_ACTION)
597
        req_headers = {'content-type': 'application/json'}
598
        req_body = parse_request({"accept": accept_serials,
599
                                  "reject": reject_serials},
600
                                 self.logger)
601
        return self._call_astakos(token, path, req_headers, req_body, "POST")
602

    
603

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