__init__.py 37.4 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 36 37
"""
Simple and minimal client for the Astakos authentication service
"""

38 39
import logging
import urlparse
40
import urllib
41
import hashlib
42
from base64 import b64encode
43
from copy import copy
44 45

import simplejson
46
from astakosclient.utils import \
47
    retry_dec, scheme_to_class, parse_request, check_input, join_urls
48
from astakosclient.errors import \
49
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
50
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints
51 52


53
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
54
# Astakos Client Class
55

56
def get_token_from_cookie(request, cookie_name):
57 58 59 60 61 62 63 64 65 66
    """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]
67
    except BaseException:
68 69 70
        return None


71 72 73
# Too many instance attributes. pylint: disable-msg=R0902
# Too many public methods. pylint: disable-msg=R0904
class AstakosClient(object):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
74 75 76
    """AstakosClient Class Implementation"""

    # ----------------------------------
77 78 79 80 81 82
    # Initialize AstakosClient Class
    # Too many arguments. pylint: disable-msg=R0913
    # Too many local variables. pylint: disable-msg=R0914
    # Too many statements. pylint: disable-msg=R0915
    def __init__(self, token, auth_url,
                 retry=0, use_pool=False, pool_size=8, logger=None):
83
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
84 85

        Keyword arguments:
86 87
        token       -- user's/service's token (string)
        auth_url    -- i.e https://accounts.example.com/identity/v2.0
88
        retry       -- how many time to retry (integer)
89 90
        use_pool    -- use objpool for http requests (boolean)
        pool_size   -- if using pool, define the pool size
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
91 92 93
        logger      -- pass a different logger

        """
94 95

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
96 97
        if logger is None:
            logger = logging.getLogger("astakosclient")
98
            logger.setLevel(logging.INFO)
99 100 101
        logger.debug("Intialize AstakosClient: auth_url = %s, "
                     "use_pool = %s, pool_size = %s",
                     auth_url, use_pool, pool_size)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
102

103 104
        # Check that token and auth_url (mandatory options) are given
        check_input("__init__", logger, token=token, auth_url=auth_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
105

106 107 108 109
        # Initialize connection class
        parsed_auth_url = urlparse.urlparse(auth_url)
        conn_class = \
            scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size)
110
        if conn_class is None:
111 112 113
            msg = "Unsupported scheme: %s" % parsed_auth_url.scheme
            logger.error(msg)
            raise BadValue(msg)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
114

115
        # Save astakos base url, logger, connection class etc in our class
116
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
117
        self.logger = logger
118 119 120
        self.token = token
        self.astakos_base_url = parsed_auth_url.netloc
        self.scheme = parsed_auth_url.scheme
121
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
122

123 124 125 126 127
        # Initialize astakos api prefixes
        # API urls under auth_url
        self.auth_prefix = parsed_auth_url.path
        self.api_tokens = join_urls(self.auth_prefix, "tokens")

128 129 130 131 132 133 134 135 136 137
    def _fill_endpoints(self, endpoints, extra=False):
        """Fill the endpoints for our AstakosClient

        This will be done once (lazily) and the endpoints will be there
        to be used afterwards.
        The `extra' parameter is there for compatibility reasons. We are going
        to fill the oauth2 endpoint only if we need it. This way we are keeping
        astakosclient compatible with older Astakos version.

        """
138
        astakos_service_catalog = parse_endpoints(
139
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
140 141 142 143 144 145 146 147 148 149 150 151 152 153
        self._account_url = \
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
        parsed_account_url = urlparse.urlparse(self._account_url)

        self._account_prefix = parsed_account_url.path
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)

        self._ui_url = \
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
        parsed_ui_url = urlparse.urlparse(self._ui_url)

        self._ui_prefix = parsed_ui_url.path
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)

154 155 156 157 158 159 160
        if extra:
            oauth2_service_catalog = \
                parse_endpoints(endpoints, ep_name="astakos_oauth2")
            self._oauth2_url = \
                oauth2_service_catalog[0]['endpoints'][0]['publicURL']
            parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
            self._oauth2_prefix = parsed_oauth2_url.path
161

162
    def _get_value(self, s, extra=False):
163
        assert s in ['_account_url', '_account_prefix',
164
                     '_ui_url', '_ui_prefix',
165
                     '_oauth2_url', '_oauth2_prefix']
166 167 168
        try:
            return getattr(self, s)
        except AttributeError:
169
            self.get_endpoints(extra=extra)
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
            return getattr(self, s)

    @property
    def account_url(self):
        return self._get_value('_account_url')

    @property
    def account_prefix(self):
        return self._get_value('_account_prefix')

    @property
    def ui_url(self):
        return self._get_value('_ui_url')

    @property
    def ui_prefix(self):
        return self._get_value('_ui_prefix')

188
    @property
189
    def oauth2_url(self):
190
        return self._get_value('_oauth2_url', extra=True)
191 192

    @property
193
    def oauth2_prefix(self):
194
        return self._get_value('_oauth2_prefix', extra=True)
195

196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
    @property
    def api_usercatalogs(self):
        return join_urls(self.account_prefix, "user_catalogs")

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

    @property
    def api_resources(self):
        return join_urls(self.account_prefix, "resources")

    @property
    def api_quotas(self):
        return join_urls(self.account_prefix, "quotas")

    @property
    def api_service_quotas(self):
        return join_urls(self.account_prefix, "service_quotas")
215

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

220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242
    @property
    def api_commissions_action(self):
        return join_urls(self.api_commissions, "action")

    @property
    def api_feedback(self):
        return join_urls(self.account_prefix, "feedback")

    @property
    def api_projects(self):
        return join_urls(self.account_prefix, "projects")

    @property
    def api_applications(self):
        return join_urls(self.api_projects, "apps")

    @property
    def api_memberships(self):
        return join_urls(self.api_projects, "memberships")

    @property
    def api_getservices(self):
        return join_urls(self.ui_prefix, "get_services")
243

244
    @property
245 246
    def api_oauth2_auth(self):
        return join_urls(self.oauth2_prefix, "auth")
247 248

    @property
249 250
    def api_oauth2_token(self):
        return join_urls(self.oauth2_prefix, "token")
251

252
    # ----------------------------------
253 254
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
255
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
256
        """Make the actual call to Astakos Service"""
257 258
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
259
        self.logger.debug(
260 261 262 263
            "Make a %s request to %s, using token with hash %s, "
            "with headers %s and body %s",
            method, request_path, hashed_token.hexdigest(), headers,
            body if log_body else "(not logged)")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
264

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
265
        # Check Input
266 267 268 269
        if headers is None:
            headers = {}
        if body is None:
            body = {}
270 271 272
        # Initialize log_request and log_response attributes
        self.log_request = None
        self.log_response = None
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
273

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
274 275
        # Build request's header and body
        kwargs = {}
276
        kwargs['headers'] = copy(headers)
277
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
278
        if body:
279
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
280 281 282 283 284 285
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
286
            # Get the connection object
287
            with self.conn_class(self.astakos_base_url) as conn:
288 289 290 291 292
                # Log the request so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_request = dict(method=method, path=request_path)
                self.log_request.update(kwargs)

293
                # Send request
294
                # Used * or ** magic. pylint: disable-msg=W0142
295
                (message, data, status) = \
296
                    _do_request(conn, method, request_path, **kwargs)
297 298 299 300 301

                # Log the response so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_response = dict(
                    status=status, message=message, data=data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
302
        except Exception as err:
303
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
304 305 306 307
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
308
        if status == 400:
309
            raise BadRequest(message, data)
310
        elif status == 401:
311
            raise Unauthorized(message, data)
312
        elif status == 403:
313
            raise Forbidden(message, data)
314
        elif status == 404:
315
            raise NotFound(message, data)
316
        elif status < 200 or status >= 300:
317
            raise AstakosClientException(message, data, status)
318 319 320 321 322

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
323
                return None
324
        except Exception as err:
325 326
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
327
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
328 329

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

346
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
347 348 349 350 351 352 353 354 355
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
        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

        """
356
        return self._uuid_catalog(uuids, self.api_usercatalogs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
357

358
    def get_username(self, uuid):
359
        """Return the user name of a uuid (see get_usernames)"""
360
        check_input("get_username", self.logger, uuid=uuid)
361
        uuid_dict = self.get_usernames([uuid])
362 363 364
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
365
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
366

367
    def service_get_usernames(self, uuids):
368
        """Return a uuid_catalog dict using a service's token"""
369
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
370

371
    def service_get_username(self, uuid):
372
        """Return the displayName of a uuid using a service's token"""
373
        check_input("service_get_username", self.logger, uuid=uuid)
374
        uuid_dict = self.service_get_usernames([uuid])
375 376 377
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
378
            raise NoUserName(uuid)
379

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

397
    def get_uuids(self, display_names):
398 399 400 401 402 403 404 405 406
        """Return a displayname_catalog for the given names

        Keyword arguments:
        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

        """
407 408
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
409

410
    def get_uuid(self, display_name):
411
        """Return the uuid of a name (see getUUIDs)"""
412
        check_input("get_uuid", self.logger, display_name=display_name)
413
        name_dict = self.get_uuids([display_name])
414 415 416 417
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
418

419
    def service_get_uuids(self, display_names):
420
        """Return a display_name catalog using a service's token"""
421 422
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
423

424
    def service_get_uuid(self, display_name):
425
        """Return the uuid of a name using a service's token"""
426
        check_input("service_get_uuid", self.logger, display_name=display_name)
427
        name_dict = self.service_get_uuids([display_name])
428 429 430 431
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
432

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
433
    # ----------------------------------
434
    # do a GET to ``API_GETSERVICES``
435
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
436
        """Return a list of dicts with the registered services"""
437
        return self._call_astakos(self.api_getservices)
438 439

    # ----------------------------------
440
    # do a GET to ``API_RESOURCES``
441 442
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
443
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
444

445 446
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
447
    def send_feedback(self, message, data):
448 449 450 451 452 453 454 455 456 457 458 459 460
        """Send feedback to astakos service

        keyword arguments:
        message     -- Feedback message
        data        -- Additional information about service client status

        In case of success return nothing.
        Otherwise raise an AstakosClientException

        """
        check_input("send_feedback", self.logger, message=message, data=data)
        req_body = urllib.urlencode(
            {'feedback_msg': message, 'feedback_data': data})
461 462
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
463

464 465
    # -----------------------------------------
    # do a POST to ``API_TOKENS`` with no token
466
    def get_endpoints(self, extra=False):
467 468
        """ Get services' endpoints

469
        The extra parameter is to be used by _fill_endpoints.
470 471 472 473 474 475 476 477
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
        req_body = None
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
478
        self._fill_endpoints(r, extra=extra)
479 480 481 482 483
        return r

    # --------------------------------------
    # do a POST to ``API_TOKENS`` with a token
    def authenticate(self, tenant_name=None):
484
        """ Authenticate and get services' endpoints
485 486

        Keyword arguments:
487
        tenant_name         -- user's uniq id (optional)
488 489

        It returns back the token as well as information about the token
490
        holder and the services he/she can access (in json format).
491 492 493 494

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

495 496 497 498
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
499 500 501 502 503 504 505 506 507
        body = {'auth': {'token': {'id': self.token}}}
        if tenant_name is not None:
            body['auth']['tenantName'] = tenant_name
        req_body = parse_request(body, self.logger)
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
        self._fill_endpoints(r)
        return r
508

509 510
    # --------------------------------------
    # do a GET to ``API_TOKENS`` with a token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
511
    def validate_token(self, token_id, belongs_to=None):
512 513 514 515 516 517 518 519
        """ Validate a temporary access token (oath2)

        Keyword arguments:
        belongsTo         -- confirm that token belongs to tenant

        It returns back the token as well as information about the token
        holder.

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
520
        The belongs_to is optional and if it is given it must be inside the
521 522 523 524 525 526
        token's scope.

        In case of error raise an AstakosClientException.

        """
        path = join_urls(self.api_tokens, str(token_id))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
527 528
        if belongs_to is not None:
            params = {'belongsTo': belongs_to}
529 530 531
            path = '%s?%s' % (path, urllib.urlencode(params))
        return self._call_astakos(path, method="GET", log_body=False)

532
    # ----------------------------------
533
    # do a GET to ``API_QUOTAS``
534
    def get_quotas(self):
535 536 537 538 539 540
        """Get user's quotas

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

        """
541
        return self._call_astakos(self.api_quotas)
542

543
    # ----------------------------------
544
    # do a GET to ``API_SERVICE_QUOTAS``
545
    def service_get_quotas(self, user=None):
546 547 548
        """Get all quotas for resources associated with the service

        Keyword arguments:
549
        user    -- optionally, the uuid of a specific user
550 551

        In case of success return a dict of dicts of dicts with current quotas
552
        for all users, or of a specified user, if user argument is set.
553 554 555
        Otherwise raise an AstakosClientException

        """
556
        query = self.api_service_quotas
557 558
        if user is not None:
            query += "?user=" + user
559
        return self._call_astakos(query)
560

561
    # ----------------------------------
562
    # do a POST to ``API_COMMISSIONS``
563
    def issue_commission(self, request):
564 565 566 567 568 569 570 571 572 573
        """Issue a commission

        Keyword arguments:
        request -- commision request (dict)

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

        """
        req_headers = {'content-type': 'application/json'}
574
        req_body = parse_request(request, self.logger)
575
        try:
576 577 578 579
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
580 581 582 583 584 585 586 587 588
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
589 590 591 592
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
593

594
    def issue_one_commission(self, holder, source, provisions,
595
                             name="", force=False, auto_accept=False):
596 597 598 599 600
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
601
        provisions  -- resources with their quantity (dict from string to int)
602
        name        -- description of the commission (string)
603 604 605 606 607 608 609 610
        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)

        """
611 612 613
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
614 615 616 617

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
618
        request["name"] = name
619 620
        try:
            request["provisions"] = []
621
            for resource, quantity in provisions.iteritems():
622 623 624
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
625 626 627 628
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

629
        return self.issue_commission(request)
630

631
    # ----------------------------------
632
    # do a GET to ``API_COMMISSIONS``
633
    def get_pending_commissions(self):
634 635 636 637 638 639
        """Get Pending Commissions

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

        """
640
        return self._call_astakos(self.api_commissions)
641

642
    # ----------------------------------
643
    # do a GET to ``API_COMMISSIONS``/<serial>
644
    def get_commission_info(self, serial):
645 646 647 648 649 650
        """Get Description of a Commission

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

        In case of success return a dict of dicts containing
651
        informations (details) about the requested commission
652 653

        """
654
        check_input("get_commission_info", self.logger, serial=serial)
655

656 657
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
658

659
    # ----------------------------------
660
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
661
    def commission_action(self, serial, action):
662
        """Perform a commission action
663 664 665 666 667 668 669 670

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

        In case of success return nothing.

        """
671 672
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
673

674
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
675
        req_headers = {'content-type': 'application/json'}
676
        req_body = parse_request({str(action): ""}, self.logger)
677 678
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
679

680
    def accept_commission(self, serial):
681
        """Accept a commission (see commission_action)"""
682
        self.commission_action(serial, "accept")
683

684
    def reject_commission(self, serial):
685
        """Reject a commission (see commission_action)"""
686
        self.commission_action(serial, "reject")
687

688
    # ----------------------------------
689
    # do a POST to ``API_COMMISSIONS_ACTION``
690
    def resolve_commissions(self, accept_serials, reject_serials):
691 692 693 694 695 696 697 698 699 700 701
        """Resolve multiple commissions at once

        Keyword arguments:
        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.

        """
702 703 704
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
705 706 707 708 709

        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
710 711 712
        return self._call_astakos(self.api_commissions_action,
                                  headers=req_headers, body=req_body,
                                  method="POST")
713

714 715
    # ----------------------------
    # do a GET to ``API_PROJECTS``
716
    def get_projects(self, name=None, state=None, owner=None):
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735
        """Retrieve all accessible projects

        Arguments:
        name  -- filter by name (optional)
        state -- filter by state (optional)
        owner -- filter by owner (optional)

        In case of success, return a list of project descriptions.
        """
        filters = {}
        if name is not None:
            filters["name"] = name
        if state is not None:
            filters["state"] = state
        if owner is not None:
            filters["owner"] = owner
        req_headers = {'content-type': 'application/json'}
        req_body = (parse_request({"filter": filters}, self.logger)
                    if filters else None)
736 737
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
738 739 740

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
741
    def get_project(self, project_id):
742 743 744 745 746 747 748
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
749 750
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
751 752 753

    # -----------------------------
    # do a POST to ``API_PROJECTS``
754
    def create_project(self, specs):
755 756 757 758 759 760 761 762 763
        """Submit application to create a new project

        Arguments:
        specs -- dict describing a project

        In case of success, return project and application identifiers.
        """
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
764 765 766
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
767 768 769

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
770
    def modify_project(self, project_id, specs):
771 772 773 774 775 776 777 778
        """Submit application to modify an existing project

        Arguments:
        project_id -- project identifier
        specs      -- dict describing a project

        In case of success, return project and application identifiers.
        """
779
        path = join_urls(self.api_projects, str(project_id))
780 781
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
782 783
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
784 785 786

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
787
    def project_action(self, project_id, action, reason=""):
788 789 790 791 792 793 794 795 796 797
        """Perform action on a project

        Arguments:
        project_id -- project identifier
        action     -- action to perform, one of "suspend", "unsuspend",
                      "terminate", "reinstate"
        reason     -- reason of performing the action

        In case of success, return nothing.
        """
798
        path = join_urls(self.api_projects, str(project_id))
799 800 801
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
802 803
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
804 805 806

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
807
    def get_applications(self, project=None):
808 809 810 811 812 813 814 815 816 817
        """Retrieve all accessible applications

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of application descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
818 819
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
820 821 822

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
823
    def get_application(self, app_id):
824 825 826 827 828 829 830
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
831 832
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
833 834 835

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
836
    def application_action(self, app_id, action, reason=""):
837 838 839 840 841 842 843 844 845 846
        """Perform action on an application

        Arguments:
        app_id -- application identifier
        action -- action to perform, one of "approve", "deny",
                  "dismiss", "cancel"
        reason -- reason of performing the action

        In case of success, return nothing.
        """
847
        path = join_urls(self.api_applications, str(app_id))
848 849 850
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
851 852
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
853 854 855

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
856
    def get_memberships(self, project=None):
857 858 859 860 861 862 863 864 865 866
        """Retrieve all accessible memberships

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of membership descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
867 868
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
869 870 871

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
872
    def get_membership(self, memb_id):
873 874 875 876 877 878 879
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
880 881
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
882 883 884

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
885
    def membership_action(self, memb_id, action, reason=""):
886 887 888 889 890 891 892 893 894 895
        """Perform action on a membership

        Arguments:
        memb_id -- membership identifier
        action  -- action to perform, one of "leave", "cancel", "accept",
                   "reject", "remove"
        reason  -- reason of performing the action

        In case of success, return nothing.
        """
896
        path = join_urls(self.api_memberships, str(memb_id))
897 898 899
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
900 901
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
902 903 904

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
905
    def join_project(self, project_id):
906 907 908 909 910 911 912 913 914 915
        """Join a project

        Arguments:
        project_id -- project identifier

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"join": {"project": project_id}}
        req_body = parse_request(body, self.logger)
916 917
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
918 919 920

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
921
    def enroll_member(self, project_id, email):
922 923 924 925 926 927 928 929 930 931 932
        """Enroll a user in a project

        Arguments:
        project_id -- project identifier
        email      -- user identified by email

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"enroll": {"project": project_id, "user": email}}
        req_body = parse_request(body, self.logger)
933 934 935
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")

936
    # --------------------------------
937
    # do a POST to ``API_OAUTH2_TOKEN``
938
    def get_token(self, grant_type, client_id, client_secret, **body_params):
939
        headers = {'content-type': 'application/x-www-form-urlencoded',
940 941 942 943 944
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
                                                           (client_id,
                                                            client_secret))}
        body_params['grant_type'] = grant_type
        body = urllib.urlencode(body_params)
945
        return self._call_astakos(self.api_oauth2_token, headers=headers,
946 947
                                  body=body, method="POST")

948 949 950 951 952 953 954 955 956 957 958 959 960 961

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

    Keyword arguments:
    endpoints     -- the endpoints (json response from get_endpoints)
    ep_name       -- return only endpoints with this name (optional)
    ep_type       -- return only endpoints with this type (optional)
    ep_region     -- return only endpoints with this region (optional)
    ep_version_id -- return only endpoints with this versionId (optional)

962
    In case one of the `name', `type', `region', `version_id' parameters
963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995
    is given, return only the endpoints that match all of these criteria.
    If no match is found then raise NoEndpoints exception.

    """
    try:
        catalog = endpoints['access']['serviceCatalog']
        if ep_name is not None:
            catalog = \
                [c for c in catalog if c['name'] == ep_name]
        if ep_type is not None:
            catalog = \
                [c for c in catalog if c['type'] == ep_type]
        if ep_region is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['region'] == ep_region]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]
        if ep_version_id is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['versionId'] == ep_version_id]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]

        if not catalog:
            raise NoEndpoints(ep_name, ep_type,
                              ep_region, ep_version_id)
        else:
            return catalog
    except KeyError:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
996
        raise NoEndpoints(ep_name, ep_type, ep_region, ep_version_id)
997

998

999 1000
# --------------------------------------------------------------------
# Private functions
1001
# We want _do_request to be a distinct function
1002
# so that we can replace it during unit tests.
1003
def _do_request(conn, method, url, **kwargs):
1004 1005 1006 1007 1008 1009
    """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)
1010 1011
    message = response.reason
    return (message, data, status)