__init__.py 34.3 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 copy import copy
43 44

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


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

55
def get_token_from_cookie(request, cookie_name):
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]
66
    except BaseException:
67 68 69
        return None


70 71 72
# 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
73 74 75
    """AstakosClient Class Implementation"""

    # ----------------------------------
76 77 78 79 80 81
    # 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):
82
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
83 84

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

        """
93 94

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
95
        if logger is None:
96 97 98 99
            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
100
            logger = logging.getLogger("astakosclient")
101 102 103
        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
104

105 106
        # 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
107

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

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

125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
        # 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")

        # ------------------------------
        # API urls under account_url
        # Get account_url from get_endpoints
        # get_endpoints needs self.api_tokens
        endpoints = self.get_endpoints(non_authentication=True)
        account_service_catalog = parse_endpoints(
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
        self.account_url = \
            account_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.api_authenticate = join_urls(
            self.account_prefix, "authenticate")
        self.api_usercatalogs = join_urls(
            self.account_prefix, "user_catalogs")
        self.api_service_usercatalogs = join_urls(
            self.account_prefix, "service/user_catalogs")
        self.api_resources = join_urls(
            self.account_prefix, "resources")
        self.api_quotas = join_urls(
            self.account_prefix, "quotas")
        self.api_service_quotas = join_urls(
            self.account_prefix, "service_quotas")
        self.api_commissions = join_urls(
            self.account_prefix, "commissions")
        self.api_commissions_action = join_urls(
            self.api_commissions, "action")
        self.api_feedback = join_urls(
            self.account_prefix, "feedback")
        self.api_projects = join_urls(
            self.account_prefix, "projects")
        self.api_applications = join_urls(
            self.api_projects, "apps")
        self.api_memberships = join_urls(
            self.api_projects, "memberships")

        # ------------------------------
        # API urls under ui_url
        # Get ui url from get_endpoints
        # get_endpoints needs self.api_tokens
        ui_service_catalog = parse_endpoints(
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
        parsed_ui_url = urlparse.urlparse(
            ui_service_catalog[0]['endpoints'][0]['SNF:uiURL'])
        self.ui_url = \
            ui_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)

        self.api_getservices = join_urls(self.ui_prefix, "get_services")

186
    # ----------------------------------
187 188
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
189
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
190
        """Make the actual call to Astakos Service"""
191 192
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
193
        self.logger.debug(
194 195 196 197
            "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
198

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
199
        # Check Input
200 201 202 203
        if headers is None:
            headers = {}
        if body is None:
            body = {}
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
204

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
205 206
        # Build request's header and body
        kwargs = {}
207
        kwargs['headers'] = copy(headers)
208
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
209
        if body:
210
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
211 212 213 214 215 216
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
217
            # Get the connection object
218
            with self.conn_class(self.astakos_base_url) as conn:
219
                # Send request
220
                # Used * or ** magic. pylint: disable-msg=W0142
221
                (message, data, status) = \
222
                    _do_request(conn, method, request_path, **kwargs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
223
        except Exception as err:
224
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
225 226 227 228
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
229
        if status == 400:
230
            raise BadRequest(message, data)
231
        elif status == 401:
232
            raise Unauthorized(message, data)
233
        elif status == 403:
234
            raise Forbidden(message, data)
235
        elif status == 404:
236
            raise NotFound(message, data)
237
        elif status < 200 or status >= 300:
238
            raise AstakosClientException(message, data, status)
239 240 241 242 243

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
244
                return None
245
        except Exception as err:
246 247
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
248
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
249 250

    # ------------------------
251
    # do a GET to ``API_AUTHENTICATE``
252
    def get_user_info(self, usage=False):
253
        """Authenticate user and get user's info as a dictionary
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
254 255 256 257 258 259 260 261

        Keyword arguments:
        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
262
        # Send request
263
        auth_path = self.api_authenticate
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
264 265
        if usage:
            auth_path += "?usage=1"
266
        return self._call_astakos(auth_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
267 268

    # ----------------------------------
269
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
270
    #   with {'uuids': uuids}
271 272
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
273
        req_headers = {'content-type': 'application/json'}
274
        req_body = parse_request({'uuids': uuids}, self.logger)
275 276
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
277 278 279
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
280 281 282 283
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
284

285
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
286 287 288 289 290 291 292 293 294
        """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

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

297
    def get_username(self, uuid):
298
        """Return the user name of a uuid (see get_usernames)"""
299
        check_input("get_username", self.logger, uuid=uuid)
300
        uuid_dict = self.get_usernames([uuid])
301 302 303
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
304
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
305

306
    def service_get_usernames(self, uuids):
307
        """Return a uuid_catalog dict using a service's token"""
308
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
309

310
    def service_get_username(self, uuid):
311
        """Return the displayName of a uuid using a service's token"""
312
        check_input("service_get_username", self.logger, uuid=uuid)
313
        uuid_dict = self.service_get_usernames([uuid])
314 315 316
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
317
            raise NoUserName(uuid)
318

319
    # ----------------------------------
320
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
321
    #   with {'displaynames': display_names}
322 323
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
324
        req_headers = {'content-type': 'application/json'}
325
        req_body = parse_request({'displaynames': display_names}, self.logger)
326 327
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
328 329 330
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
331 332 333 334
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
335

336
    def get_uuids(self, display_names):
337 338 339 340 341 342 343 344 345
        """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

        """
346 347
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
348

349
    def get_uuid(self, display_name):
350
        """Return the uuid of a name (see getUUIDs)"""
351
        check_input("get_uuid", self.logger, display_name=display_name)
352
        name_dict = self.get_uuids([display_name])
353 354 355 356
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
357

358
    def service_get_uuids(self, display_names):
359
        """Return a display_name catalog using a service's token"""
360 361
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
362

363
    def service_get_uuid(self, display_name):
364
        """Return the uuid of a name using a service's token"""
365
        check_input("service_get_uuid", self.logger, display_name=display_name)
366
        name_dict = self.service_get_uuids([display_name])
367 368 369 370
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
371

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
372
    # ----------------------------------
373
    # do a GET to ``API_GETSERVICES``
374
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
375
        """Return a list of dicts with the registered services"""
376
        return self._call_astakos(self.api_getservices)
377 378

    # ----------------------------------
379
    # do a GET to ``API_RESOURCES``
380 381
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
382
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
383

384 385
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
386
    def send_feedback(self, message, data):
387 388 389 390 391 392 393 394 395 396 397 398 399
        """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})
400 401
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
402

403 404
    # ----------------------------------
    # do a POST to ``API_TOKENS``
405 406
    def get_endpoints(self, tenant_name=None, non_authentication=False):
        """ Authenticate and get services' endpoints
407 408

        Keyword arguments:
409 410 411
        tenant_name         -- user's uniq id (optional)
        non_authentication  -- get only non authentication protected info

412 413 414

        It returns back the token as well as information about the token
        holder and the services he/she can acess (in json format).
415 416 417 418 419 420 421 422

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

        In case on of the `name', `type', `region', `version_id' parameters
        is given, return only the endpoints that match all of these criteria.
        If no match is found then raise NoEndpoints exception.

423 424 425 426
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
427 428 429 430 431 432 433 434 435 436
        if non_authentication:
            req_body = None
        else:
            body = {'auth': {'token': {'id': self.token}}}
            if tenant_name is not None:
                body['auth']['tenantName'] = tenant_name
            req_body = parse_request(body, self.logger)
        return self._call_astakos(self.api_tokens, headers=req_headers,
                                  body=req_body, method="POST",
                                  log_body=False)
437

438
    # ----------------------------------
439
    # do a GET to ``API_QUOTAS``
440
    def get_quotas(self):
441 442 443 444 445 446
        """Get user's quotas

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

        """
447
        return self._call_astakos(self.api_quotas)
448

449
    # ----------------------------------
450
    # do a GET to ``API_SERVICE_QUOTAS``
451
    def service_get_quotas(self, user=None):
452 453 454
        """Get all quotas for resources associated with the service

        Keyword arguments:
455
        user    -- optionally, the uuid of a specific user
456 457

        In case of success return a dict of dicts of dicts with current quotas
458
        for all users, or of a specified user, if user argument is set.
459 460 461
        Otherwise raise an AstakosClientException

        """
462
        query = self.api_service_quotas
463 464
        if user is not None:
            query += "?user=" + user
465
        return self._call_astakos(query)
466

467
    # ----------------------------------
468
    # do a POST to ``API_COMMISSIONS``
469
    def issue_commission(self, request):
470 471 472 473 474 475 476 477 478 479
        """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'}
480
        req_body = parse_request(request, self.logger)
481
        try:
482 483 484 485
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
486 487 488 489 490 491 492 493 494
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
495 496 497 498
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
499

500
    def issue_one_commission(self, holder, source, provisions,
501
                             name="", force=False, auto_accept=False):
502 503 504 505 506
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
507
        provisions  -- resources with their quantity (dict from string to int)
508
        name        -- description of the commission (string)
509 510 511 512 513 514 515 516
        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)

        """
517 518 519
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
520 521 522 523

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
524
        request["name"] = name
525 526
        try:
            request["provisions"] = []
527
            for resource, quantity in provisions.iteritems():
528 529 530
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
531 532 533 534
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

535
        return self.issue_commission(request)
536

537
    # ----------------------------------
538
    # do a GET to ``API_COMMISSIONS``
539
    def get_pending_commissions(self):
540 541 542 543 544 545
        """Get Pending Commissions

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

        """
546
        return self._call_astakos(self.api_commissions)
547

548
    # ----------------------------------
549
    # do a GET to ``API_COMMISSIONS``/<serial>
550
    def get_commission_info(self, serial):
551 552 553 554 555 556
        """Get Description of a Commission

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

        In case of success return a dict of dicts containing
557
        informations (details) about the requested commission
558 559

        """
560
        check_input("get_commission_info", self.logger, serial=serial)
561

562 563
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
564

565
    # ----------------------------------
566
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
567
    def commission_action(self, serial, action):
568
        """Perform a commission action
569 570 571 572 573 574 575 576

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

        In case of success return nothing.

        """
577 578
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
579

580
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
581
        req_headers = {'content-type': 'application/json'}
582
        req_body = parse_request({str(action): ""}, self.logger)
583 584
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
585

586
    def accept_commission(self, serial):
587
        """Accept a commission (see commission_action)"""
588
        self.commission_action(serial, "accept")
589

590
    def reject_commission(self, serial):
591
        """Reject a commission (see commission_action)"""
592
        self.commission_action(serial, "reject")
593

594
    # ----------------------------------
595
    # do a POST to ``API_COMMISSIONS_ACTION``
596
    def resolve_commissions(self, accept_serials, reject_serials):
597 598 599 600 601 602 603 604 605 606 607
        """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.

        """
608 609 610
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
611 612 613 614 615

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

620 621
    # ----------------------------
    # do a GET to ``API_PROJECTS``
622
    def get_projects(self, name=None, state=None, owner=None):
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641
        """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)
642 643
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
644 645 646

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
647
    def get_project(self, project_id):
648 649 650 651 652 653 654
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
655 656
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
657 658 659

    # -----------------------------
    # do a POST to ``API_PROJECTS``
660
    def create_project(self, specs):
661 662 663 664 665 666 667 668 669
        """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)
670 671 672
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
673 674 675

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
676
    def modify_project(self, project_id, specs):
677 678 679 680 681 682 683 684
        """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.
        """
685
        path = join_urls(self.api_projects, str(project_id))
686 687
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
688 689
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
690 691 692

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
693
    def project_action(self, project_id, action, reason=""):
694 695 696 697 698 699 700 701 702 703
        """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.
        """
704
        path = join_urls(self.api_projects, str(project_id))
705 706 707
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
708 709
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
710 711 712

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
713
    def get_applications(self, project=None):
714 715 716 717 718 719 720 721 722 723
        """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
724 725
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
726 727 728

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
729
    def get_application(self, app_id):
730 731 732 733 734 735 736
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
737 738
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
739 740 741

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
742
    def application_action(self, app_id, action, reason=""):
743 744 745 746 747 748 749 750 751 752
        """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.
        """
753
        path = join_urls(self.api_applications, str(app_id))
754 755 756
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
757 758
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
759 760 761

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
762
    def get_memberships(self, project=None):
763 764 765 766 767 768 769 770 771 772
        """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
773 774
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
775 776 777

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
778
    def get_membership(self, memb_id):
779 780 781 782 783 784 785
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
786 787
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
788 789 790

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
791
    def membership_action(self, memb_id, action, reason=""):
792 793 794 795 796 797 798 799 800 801
        """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.
        """
802
        path = join_urls(self.api_memberships, str(memb_id))
803 804 805
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
806 807
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
808 809 810

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
811
    def join_project(self, project_id):
812 813 814 815 816 817 818 819 820 821
        """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)
822 823
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
824 825 826

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
827
    def enroll_member(self, project_id, email):
828 829 830 831 832 833 834 835 836 837 838
        """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)
839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")


# --------------------------------------------------------------------
# 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)

856
    In case one of the `name', `type', `region', `version_id' parameters
857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891
    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:
        raise NoEndpoints()

892

893 894
# --------------------------------------------------------------------
# Private functions
895 896
# We want _doRequest to be a distinct function
# so that we can replace it during unit tests.
897
def _do_request(conn, method, url, **kwargs):
898 899 900 901 902 903
    """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)
904 905
    message = response.reason
    return (message, data, status)