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

import logging
import urlparse
36
import urllib
37
import hashlib
38
from copy import copy
39 40

import simplejson
41 42
from astakosclient.utils import \
    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
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
49
# Astakos Client Class
50

51
def get_token_from_cookie(request, cookie_name):
52 53 54 55 56 57 58 59 60 61 62 63 64 65
    """Extract token from the cookie name provided

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

    """
    try:
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
        return cookie_content.split("|")[1]
    except:
        return None


Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
66 67 68 69
class AstakosClient():
    """AstakosClient Class Implementation"""

    # ----------------------------------
70 71
    def __init__(self, astakos_url, retry=0,
                 use_pool=False, pool_size=8, logger=None):
72
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
73 74 75 76

        Keyword arguments:
        astakos_url -- i.e https://accounts.example.com (string)
        use_pool    -- use objpool for http requests (boolean)
77
        retry       -- how many time to retry (integer)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
78 79 80 81
        logger      -- pass a different logger

        """
        if logger is None:
82 83 84 85
            logging.basicConfig(
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
                datefmt='%Y-%m-%d %H:%M:%S',
                level=logging.INFO)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
86
            logger = logging.getLogger("astakosclient")
87
        logger.debug("Intialize AstakosClient: astakos_url = %s, "
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
88 89
                     "use_pool = %s" % (astakos_url, use_pool))

90
        check_input("__init__", logger, astakos_url=astakos_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
91 92 93

        # Check for supported scheme
        p = urlparse.urlparse(astakos_url)
94
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
95
        if conn_class is None:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
96 97
            m = "Unsupported scheme: %s" % p.scheme
            logger.error(m)
98
            raise BadValue(m)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
99

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
100
        # Save astakos_url etc. in our class
101
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
102 103 104
        self.logger = logger
        self.netloc = p.netloc
        self.scheme = p.scheme
105
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
106

107 108
    # ----------------------------------
    @retry
109 110
    def _call_astakos(self, token, request_path,
                      headers=None, body=None, method="GET"):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
111
        """Make the actual call to Astakos Service"""
112 113 114 115 116 117
        if token is not None:
            hashed_token = hashlib.sha1()
            hashed_token.update(token)
            using_token = "using token %s" % (hashed_token.hexdigest())
        else:
            using_token = "without using token"
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
118
        self.logger.debug(
119 120
            "Make a %s request to %s %s with headers %s and body %s"
            % (method, request_path, using_token, headers, body))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
121

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
122
        # Check Input
123 124 125 126
        if headers is None:
            headers = {}
        if body is None:
            body = {}
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
127 128
        if request_path[0] != '/':
            request_path = "/" + request_path
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
129

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
130 131
        # Build request's header and body
        kwargs = {}
132
        kwargs['headers'] = copy(headers)
133 134
        if token is not None:
            kwargs['headers']['X-Auth-Token'] = token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
135
        if body:
136
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
137 138 139 140 141 142
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
143 144 145
            # Get the connection object
            with self.conn_class(self.netloc) as conn:
                # Send request
146
                (message, data, status) = \
147
                    _do_request(conn, method, request_path, **kwargs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
148
        except Exception as err:
149
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
150 151 152 153
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
154
        if status == 400:
155
            raise BadRequest(message, data)
156
        elif status == 401:
157
            raise Unauthorized(message, data)
158
        elif status == 403:
159
            raise Forbidden(message, data)
160
        elif status == 404:
161
            raise NotFound(message, data)
162
        elif status < 200 or status >= 300:
163
            raise AstakosClientException(message, data, status)
164 165 166 167 168

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
169
                return None
170
        except Exception as err:
171 172
            self.logger.error("Cannot parse response \"%s\" with simplejson: %s"
                              % (data, str(err)))
173
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
174 175

    # ------------------------
176
    # GET /im/authenticate
177
    def get_user_info(self, token, usage=False):
178
        """Authenticate user and get user's info as a dictionary
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
179 180

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
181
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
182 183 184 185 186 187
        usage   -- return usage information for user (boolean)

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

        """
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
188
        # Send request
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
189 190 191
        auth_path = "/im/authenticate"
        if usage:
            auth_path += "?usage=1"
192
        return self._call_astakos(token, auth_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
193 194

    # ----------------------------------
195 196
    # POST /user_catalogs (or /service/api/user_catalogs)
    #   with {'uuids': uuids}
197
    def _uuid_catalog(self, token, uuids, req_path):
198
        req_headers = {'content-type': 'application/json'}
199
        req_body = parse_request({'uuids': uuids}, self.logger)
200
        data = self._call_astakos(
201
            token, req_path, req_headers, req_body, "POST")
202 203 204
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
205
            m = "_uuid_catalog request returned %s. No uuid_catalog found" \
206 207 208
                % data
            self.logger.error(m)
            raise AstakosClientException(m)
209

210
    def get_usernames(self, token, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
211 212 213
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
214
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
215 216 217 218 219 220 221
        uuids   -- list of user ids (list of strings)

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

        """
        req_path = "/user_catalogs"
222
        return self._uuid_catalog(token, uuids, req_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
223

224 225
    def get_username(self, token, uuid):
        """Return the user name of a uuid (see get_usernames)"""
226
        check_input("get_username", self.logger, uuid=uuid)
227
        uuid_dict = self.get_usernames(token, [uuid])
228 229 230
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
231
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
232

233
    def service_get_usernames(self, token, uuids):
234 235
        """Return a uuid_catalog dict using a service's token"""
        req_path = "/service/api/user_catalogs"
236
        return self._uuid_catalog(token, uuids, req_path)
237

238
    def service_get_username(self, token, uuid):
239
        """Return the displayName of a uuid using a service's token"""
240
        check_input("service_get_username", self.logger, uuid=uuid)
241
        uuid_dict = self.service_get_usernames(token, [uuid])
242 243 244
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
245
            raise NoUserName(uuid)
246

247
    # ----------------------------------
248 249
    # POST /user_catalogs (or /service/api/user_catalogs)
    #   with {'displaynames': display_names}
250
    def _displayname_catalog(self, token, display_names, req_path):
251
        req_headers = {'content-type': 'application/json'}
252
        req_body = parse_request({'displaynames': display_names}, self.logger)
253
        data = self._call_astakos(
254
            token, req_path, req_headers, req_body, "POST")
255 256 257
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
258
            m = "_displayname_catalog request returned %s. " \
259 260 261
                "No displayname_catalog found" % data
            self.logger.error(m)
            raise AstakosClientException(m)
262

263
    def get_uuids(self, token, display_names):
264 265 266 267 268 269 270 271 272 273 274
        """Return a displayname_catalog for the given names

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

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

        """
        req_path = "/user_catalogs"
275
        return self._displayname_catalog(token, display_names, req_path)
276

277
    def get_uuid(self, token, display_name):
278
        """Return the uuid of a name (see getUUIDs)"""
279
        check_input("get_uuid", self.logger, display_name=display_name)
280
        name_dict = self.get_uuids(token, [display_name])
281 282 283 284
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
285

286
    def service_get_uuids(self, token, display_names):
287 288
        """Return a display_name catalog using a service's token"""
        req_path = "/service/api/user_catalogs"
289
        return self._displayname_catalog(token, display_names, req_path)
290

291
    def service_get_uuid(self, token, display_name):
292
        """Return the uuid of a name using a service's token"""
293
        check_input("service_get_uuid", self.logger, display_name=display_name)
294
        name_dict = self.service_get_uuids(token, [display_name])
295 296 297 298
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
299

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
300
    # ----------------------------------
301
    # GET "/im/get_services"
302
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
303
        """Return a list of dicts with the registered services"""
304 305 306 307 308 309 310
        return self._call_astakos(None, "/im/get_services")

    # ----------------------------------
    # GET "/astakos/api/resources"
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
        return self._call_astakos(None, "/astakos/api/resources")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
311

312 313 314
    # ----------------------------------
    # GET "/astakos/api/quotas"
    def get_quotas(self, token):
315 316 317 318 319 320 321 322 323
        """Get user's quotas

        Keyword arguments:
        token   -- user's token (string)

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

        """
324 325
        return self._call_astakos(token, "/astakos/api/quotas")

326 327
    # ----------------------------------
    # GET "/astakos/api/service_quotas"
328
    def get_service_quotas(self, token, user=None):
329 330 331 332
        """Get all quotas for resources associated with the service

        Keyword arguments:
        token   -- service's token (string)
333
        user    -- optionally, the uuid of a specific user
334 335

        In case of success return a dict of dicts of dicts with current quotas
336
        for all users, or of a specified user, if user argument is set.
337 338 339
        Otherwise raise an AstakosClientException

        """
340 341 342 343
        query = "/astakos/api/service_quotas"
        if user is not None:
            query += "?user=" + user
        return self._call_astakos(token, query)
344

345 346 347 348 349 350
    # ----------------------------------
    # POST "/astakos/api/commisions"
    def issue_commission(self, token, request):
        """Issue a commission

        Keyword arguments:
351
        token   -- service's token (string)
352 353 354 355 356 357 358
        request -- commision request (dict)

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

        """
        req_headers = {'content-type': 'application/json'}
359
        req_body = parse_request(request, self.logger)
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376
        try:
            response = self._call_astakos(token, "/astakos/api/commissions",
                                          req_headers, req_body, "POST")
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
            m = "issue_commission_core request returned %s. No serial found" \
                % response
            self.logger.error(m)
            raise AstakosClientException(m)

377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
    def issue_one_commission(self, token, holder, source, provisions,
                             force=False, auto_accept=False):
        """Issue one commission (with specific holder and source)

        keyword arguments:
        token       -- service's token (string)
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
        provisions  -- resources with their quantity (list of (string, int))
        force       -- force this commission (boolean)
        auto_accept -- auto accept this commission (boolean)

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

        """
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
        try:
            request["provisions"] = []
            for p in provisions:
                resource = p[0]
                quantity = p[1]
                t = {"holder": holder, "source": source,
                     "resource": resource, "quantity": quantity}
                request["provisions"].append(t)
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

        return self.issue_commission(token, request)

415 416 417 418 419 420
    # ----------------------------------
    # GET "/astakos/api/commissions"
    def get_pending_commissions(self, token):
        """Get Pending Commissions

        Keyword arguments:
421
        token   -- service's token (string)
422 423 424 425 426 427 428

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

        """
        return self._call_astakos(token, "/astakos/api/commissions")

429 430 431 432 433 434
    # ----------------------------------
    # GET "/astakos/api/commissions/<serial>
    def get_commission_info(self, token, serial):
        """Get Description of a Commission

        Keyword arguments:
435
        token   -- service's token (string)
436 437 438
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
439
        informations (details) about the requested commission
440 441

        """
442
        check_input("get_commission_info", self.logger, serial=serial)
443 444 445 446

        path = "/astakos/api/commissions/" + str(serial)
        return self._call_astakos(token, path)

447
    # ----------------------------------
448
    # POST "/astakos/api/commissions/<serial>/action"
449 450
    def commission_action(self, token, serial, action):
        """Perform a commission action
451 452

        Keyword arguments:
453
        token   -- service's token (string)
454 455 456 457 458 459
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
460 461
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
462 463 464

        path = "/astakos/api/commissions/" + str(serial) + "/action"
        req_headers = {'content-type': 'application/json'}
465
        req_body = parse_request({str(action): ""}, self.logger)
466 467
        self._call_astakos(token, path, req_headers, req_body, "POST")

468 469 470
    def accept_commission(self, token, serial):
        """Accept a commission (see commission_action)"""
        self.commission_action(token, serial, "accept")
471

472 473 474
    def reject_commission(self, token, serial):
        """Reject a commission (see commission_action)"""
        self.commission_action(token, serial, "reject")
475

476 477 478 479 480 481 482 483 484 485 486 487 488 489 490
    # ----------------------------------
    # POST "/astakos/api/commissions/action"
    def resolve_commissions(self, token, accept_serials, reject_serials):
        """Resolve multiple commissions at once

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

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

        """
491 492 493
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
494 495 496 497 498 499 500 501

        path = "/astakos/api/commissions/action"
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
        return self._call_astakos(token, path, req_headers, req_body, "POST")

502

503 504
# --------------------------------------------------------------------
# Private functions
505 506
# We want _doRequest to be a distinct function
# so that we can replace it during unit tests.
507
def _do_request(conn, method, url, **kwargs):
508 509 510 511 512 513
    """The actual request. This function can easily be mocked"""
    conn.request(method, url, **kwargs)
    response = conn.getresponse()
    length = response.getheader('content-length', None)
    data = response.read(length)
    status = int(response.status)
514 515
    message = response.reason
    return (message, data, status)