Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 837d85bb

History | View | Annotate | Download (34.1 kB)

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

    
34
"""
35
Simple and minimal client for the Astakos authentication service
36
"""
37

    
38
import logging
39
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
# --------------------------------------------------------------------
53
# Astakos Client Class
54

    
55
def get_token_from_cookie(request, cookie_name):
56
    """Extract token from the cookie name provided
57

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

62
    """
63
    try:
64
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
65
        return cookie_content.split("|")[1]
66
    except BaseException:
67
        return None
68

    
69

    
70
# Too many instance attributes. pylint: disable-msg=R0902
71
# Too many public methods. pylint: disable-msg=R0904
72
class AstakosClient(object):
73
    """AstakosClient Class Implementation"""
74

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

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

92
        """
93

    
94
        # Get logger
95
        if logger is None:
96
            logging.basicConfig(
97
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
98
                datefmt='%Y-%m-%d %H:%M:%S',
99
                level=logging.INFO)
100
            logger = logging.getLogger("astakosclient")
101
        logger.debug("Intialize AstakosClient: auth_url = %s, "
102
                     "use_pool = %s, pool_size = %s",
103
                     auth_url, use_pool, pool_size)
104

    
105
        # Check that token and auth_url (mandatory options) are given
106
        check_input("__init__", logger, token=token, auth_url=auth_url)
107

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

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

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

    
130
        # ------------------------------
131
        # API urls under account_url
132
        # Get account_url from get_endpoints
133
        # get_endpoints needs self.api_tokens
134
        endpoints = self.get_endpoints(non_authentication=True)
135
        account_service_catalog = parse_endpoints(
136
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
137
        self.account_url = \
138
            account_service_catalog[0]['endpoints'][0]['publicURL']
139
        parsed_account_url = urlparse.urlparse(self.account_url)
140

    
141
        self.account_prefix = parsed_account_url.path
142
        self.logger.debug("Got account_prefix \"%s\"" % self.account_prefix)
143

    
144
        self.api_authenticate = join_urls(
145
            self.account_prefix, "authenticate")
146
        self.api_usercatalogs = join_urls(
147
            self.account_prefix, "user_catalogs")
148
        self.api_service_usercatalogs = join_urls(
149
            self.account_prefix, "service/user_catalogs")
150
        self.api_resources = join_urls(
151
            self.account_prefix, "resources")
152
        self.api_quotas = join_urls(
153
            self.account_prefix, "quotas")
154
        self.api_service_quotas = join_urls(
155
            self.account_prefix, "service_quotas")
156
        self.api_commissions = join_urls(
157
            self.account_prefix, "commissions")
158
        self.api_commissions_action = join_urls(
159
            self.api_commissions, "action")
160
        self.api_feedback = join_urls(
161
            self.account_prefix, "feedback")
162
        self.api_projects = join_urls(
163
            self.account_prefix, "projects")
164
        self.api_applications = join_urls(
165
            self.api_projects, "apps")
166
        self.api_memberships = join_urls(
167
            self.api_projects, "memberships")
168

    
169
        # ------------------------------
170
        # API urls under ui_url
171
        # Get ui url from get_endpoints
172
        # get_endpoints needs self.api_tokens
173
        ui_service_catalog = parse_endpoints(
174
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
175
        parsed_ui_url = urlparse.urlparse(
176
            ui_service_catalog[0]['endpoints'][0]['SNF:uiURL'])
177
        self.ui_url = \
178
            ui_service_catalog[0]['endpoints'][0]['SNF:uiURL']
179
        parsed_ui_url = urlparse.urlparse(self.ui_url)
180

    
181
        self.ui_prefix = parsed_ui_url.path
182
        self.logger.debug("Got ui_prefix \"%s\"" % self.ui_prefix)
183

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

    
186
    # ----------------------------------
187
    @retry_dec
188
    def _call_astakos(self, request_path, headers=None,
189
                      body=None, method="GET", log_body=True):
190
        """Make the actual call to Astakos Service"""
191
        hashed_token = hashlib.sha1()
192
        hashed_token.update(self.token)
193
        self.logger.debug(
194
            "Make a %s request to %s, using token with hash %s, "
195
            "with headers %s and body %s",
196
            method, request_path, hashed_token.hexdigest(), headers,
197
            body if log_body else "(not logged)")
198

    
199
        # Check Input
200
        if headers is None:
201
            headers = {}
202
        if body is None:
203
            body = {}
204

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

    
216
        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)
223
        except Exception as err:
224
            self.logger.error("Failed to send request: %s" % repr(err))
225
            raise AstakosClientException(str(err))
226

    
227
        # Return
228
        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
        try:
241
            if data:
242
                return simplejson.loads(unicode(data))
243
            else:
244
                return None
245
        except Exception as err:
246
            msg = "Cannot parse response \"%s\" with simplejson: %s"
247
            self.logger.error(msg % (data, str(err)))
248
            raise InvalidResponse(str(err), data)
249

    
250
    # ------------------------
251
    # do a GET to ``API_AUTHENTICATE``
252
    def get_user_info(self):
253
        """Authenticate user and get user's info as a dictionary
254

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

258
        """
259
        return self._call_astakos(self.api_authenticate)
260

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

    
278
    def get_usernames(self, uuids):
279
        """Return a uuid_catalog dictionary for the given uuids
280

281
        Keyword arguments:
282
        uuids   -- list of user ids (list of strings)
283

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

287
        """
288
        return self._uuid_catalog(uuids, self.api_usercatalogs)
289

    
290
    def get_username(self, uuid):
291
        """Return the user name of a uuid (see get_usernames)"""
292
        check_input("get_username", self.logger, uuid=uuid)
293
        uuid_dict = self.get_usernames([uuid])
294
        if uuid in uuid_dict:
295
            return uuid_dict.get(uuid)
296
        else:
297
            raise NoUserName(uuid)
298

    
299
    def service_get_usernames(self, uuids):
300
        """Return a uuid_catalog dict using a service's token"""
301
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
302

    
303
    def service_get_username(self, uuid):
304
        """Return the displayName of a uuid using a service's token"""
305
        check_input("service_get_username", self.logger, uuid=uuid)
306
        uuid_dict = self.service_get_usernames([uuid])
307
        if uuid in uuid_dict:
308
            return uuid_dict.get(uuid)
309
        else:
310
            raise NoUserName(uuid)
311

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

    
329
    def get_uuids(self, display_names):
330
        """Return a displayname_catalog for the given names
331

332
        Keyword arguments:
333
        display_names   -- list of user names (list of strings)
334

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

338
        """
339
        return self._displayname_catalog(
340
            display_names, self.api_usercatalogs)
341

    
342
    def get_uuid(self, display_name):
343
        """Return the uuid of a name (see getUUIDs)"""
344
        check_input("get_uuid", self.logger, display_name=display_name)
345
        name_dict = self.get_uuids([display_name])
346
        if display_name in name_dict:
347
            return name_dict.get(display_name)
348
        else:
349
            raise NoUUID(display_name)
350

    
351
    def service_get_uuids(self, display_names):
352
        """Return a display_name catalog using a service's token"""
353
        return self._displayname_catalog(
354
            display_names, self.api_service_usercatalogs)
355

    
356
    def service_get_uuid(self, display_name):
357
        """Return the uuid of a name using a service's token"""
358
        check_input("service_get_uuid", self.logger, display_name=display_name)
359
        name_dict = self.service_get_uuids([display_name])
360
        if display_name in name_dict:
361
            return name_dict.get(display_name)
362
        else:
363
            raise NoUUID(display_name)
364

    
365
    # ----------------------------------
366
    # do a GET to ``API_GETSERVICES``
367
    def get_services(self):
368
        """Return a list of dicts with the registered services"""
369
        return self._call_astakos(self.api_getservices)
370

    
371
    # ----------------------------------
372
    # do a GET to ``API_RESOURCES``
373
    def get_resources(self):
374
        """Return a dict of dicts with the available resources"""
375
        return self._call_astakos(self.api_resources)
376

    
377
    # ----------------------------------
378
    # do a POST to ``API_FEEDBACK``
379
    def send_feedback(self, message, data):
380
        """Send feedback to astakos service
381

382
        keyword arguments:
383
        message     -- Feedback message
384
        data        -- Additional information about service client status
385

386
        In case of success return nothing.
387
        Otherwise raise an AstakosClientException
388

389
        """
390
        check_input("send_feedback", self.logger, message=message, data=data)
391
        req_body = urllib.urlencode(
392
            {'feedback_msg': message, 'feedback_data': data})
393
        self._call_astakos(self.api_feedback, headers=None,
394
                           body=req_body, method="POST")
395

    
396
    # ----------------------------------
397
    # do a POST to ``API_TOKENS``
398
    def get_endpoints(self, tenant_name=None, non_authentication=False):
399
        """ Authenticate and get services' endpoints
400

401
        Keyword arguments:
402
        tenant_name         -- user's uniq id (optional)
403
        non_authentication  -- get only non authentication protected info
404

405

406
        It returns back the token as well as information about the token
407
        holder and the services he/she can acess (in json format).
408

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

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

416
        In case of error raise an AstakosClientException.
417

418
        """
419
        req_headers = {'content-type': 'application/json'}
420
        if non_authentication:
421
            req_body = None
422
        else:
423
            body = {'auth': {'token': {'id': self.token}}}
424
            if tenant_name is not None:
425
                body['auth']['tenantName'] = tenant_name
426
            req_body = parse_request(body, self.logger)
427
        return self._call_astakos(self.api_tokens, headers=req_headers,
428
                                  body=req_body, method="POST",
429
                                  log_body=False)
430

    
431
    # ----------------------------------
432
    # do a GET to ``API_QUOTAS``
433
    def get_quotas(self):
434
        """Get user's quotas
435

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

439
        """
440
        return self._call_astakos(self.api_quotas)
441

    
442
    # ----------------------------------
443
    # do a GET to ``API_SERVICE_QUOTAS``
444
    def service_get_quotas(self, user=None):
445
        """Get all quotas for resources associated with the service
446

447
        Keyword arguments:
448
        user    -- optionally, the uuid of a specific user
449

450
        In case of success return a dict of dicts of dicts with current quotas
451
        for all users, or of a specified user, if user argument is set.
452
        Otherwise raise an AstakosClientException
453

454
        """
455
        query = self.api_service_quotas
456
        if user is not None:
457
            query += "?user=" + user
458
        return self._call_astakos(query)
459

    
460
    # ----------------------------------
461
    # do a POST to ``API_COMMISSIONS``
462
    def issue_commission(self, request):
463
        """Issue a commission
464

465
        Keyword arguments:
466
        request -- commision request (dict)
467

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

471
        """
472
        req_headers = {'content-type': 'application/json'}
473
        req_body = parse_request(request, self.logger)
474
        try:
475
            response = self._call_astakos(self.api_commissions,
476
                                          headers=req_headers,
477
                                          body=req_body,
478
                                          method="POST")
479
        except AstakosClientException as err:
480
            if err.status == 413:
481
                raise QuotaLimit(err.message, err.details)
482
            else:
483
                raise
484

    
485
        if "serial" in response:
486
            return response['serial']
487
        else:
488
            msg = "issue_commission_core request returned %s. " + \
489
                  "No serial found" % response
490
            self.logger.error(msg)
491
            raise AstakosClientException(msg)
492

    
493
    def issue_one_commission(self, holder, source, provisions,
494
                             name="", force=False, auto_accept=False):
495
        """Issue one commission (with specific holder and source)
496

497
        keyword arguments:
498
        holder      -- user's id (string)
499
        source      -- commission's source (ex system) (string)
500
        provisions  -- resources with their quantity (dict from string to int)
501
        name        -- description of the commission (string)
502
        force       -- force this commission (boolean)
503
        auto_accept -- auto accept this commission (boolean)
504

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

509
        """
510
        check_input("issue_one_commission", self.logger,
511
                    holder=holder, source=source,
512
                    provisions=provisions)
513

    
514
        request = {}
515
        request["force"] = force
516
        request["auto_accept"] = auto_accept
517
        request["name"] = name
518
        try:
519
            request["provisions"] = []
520
            for resource, quantity in provisions.iteritems():
521
                prov = {"holder": holder, "source": source,
522
                        "resource": resource, "quantity": quantity}
523
                request["provisions"].append(prov)
524
        except Exception as err:
525
            self.logger.error(str(err))
526
            raise BadValue(str(err))
527

    
528
        return self.issue_commission(request)
529

    
530
    # ----------------------------------
531
    # do a GET to ``API_COMMISSIONS``
532
    def get_pending_commissions(self):
533
        """Get Pending Commissions
534

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

538
        """
539
        return self._call_astakos(self.api_commissions)
540

    
541
    # ----------------------------------
542
    # do a GET to ``API_COMMISSIONS``/<serial>
543
    def get_commission_info(self, serial):
544
        """Get Description of a Commission
545

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

549
        In case of success return a dict of dicts containing
550
        informations (details) about the requested commission
551

552
        """
553
        check_input("get_commission_info", self.logger, serial=serial)
554

    
555
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
556
        return self._call_astakos(path)
557

    
558
    # ----------------------------------
559
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
560
    def commission_action(self, serial, action):
561
        """Perform a commission action
562

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

567
        In case of success return nothing.
568

569
        """
570
        check_input("commission_action", self.logger,
571
                    serial=serial, action=action)
572

    
573
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
574
        req_headers = {'content-type': 'application/json'}
575
        req_body = parse_request({str(action): ""}, self.logger)
576
        self._call_astakos(path, headers=req_headers,
577
                           body=req_body, method="POST")
578

    
579
    def accept_commission(self, serial):
580
        """Accept a commission (see commission_action)"""
581
        self.commission_action(serial, "accept")
582

    
583
    def reject_commission(self, serial):
584
        """Reject a commission (see commission_action)"""
585
        self.commission_action(serial, "reject")
586

    
587
    # ----------------------------------
588
    # do a POST to ``API_COMMISSIONS_ACTION``
589
    def resolve_commissions(self, accept_serials, reject_serials):
590
        """Resolve multiple commissions at once
591

592
        Keyword arguments:
593
        accept_serials  -- commissions to accept (list of ints)
594
        reject_serials  -- commissions to reject (list of ints)
595

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

600
        """
601
        check_input("resolve_commissions", self.logger,
602
                    accept_serials=accept_serials,
603
                    reject_serials=reject_serials)
604

    
605
        req_headers = {'content-type': 'application/json'}
606
        req_body = parse_request({"accept": accept_serials,
607
                                  "reject": reject_serials},
608
                                 self.logger)
609
        return self._call_astakos(self.api_commissions_action,
610
                                  headers=req_headers, body=req_body,
611
                                  method="POST")
612

    
613
    # ----------------------------
614
    # do a GET to ``API_PROJECTS``
615
    def get_projects(self, name=None, state=None, owner=None):
616
        """Retrieve all accessible projects
617

618
        Arguments:
619
        name  -- filter by name (optional)
620
        state -- filter by state (optional)
621
        owner -- filter by owner (optional)
622

623
        In case of success, return a list of project descriptions.
624
        """
625
        filters = {}
626
        if name is not None:
627
            filters["name"] = name
628
        if state is not None:
629
            filters["state"] = state
630
        if owner is not None:
631
            filters["owner"] = owner
632
        req_headers = {'content-type': 'application/json'}
633
        req_body = (parse_request({"filter": filters}, self.logger)
634
                    if filters else None)
635
        return self._call_astakos(self.api_projects,
636
                                  headers=req_headers, body=req_body)
637

    
638
    # -----------------------------------------
639
    # do a GET to ``API_PROJECTS``/<project_id>
640
    def get_project(self, project_id):
641
        """Retrieve project description, if accessible
642

643
        Arguments:
644
        project_id -- project identifier
645

646
        In case of success, return project description.
647
        """
648
        path = join_urls(self.api_projects, str(project_id))
649
        return self._call_astakos(path)
650

    
651
    # -----------------------------
652
    # do a POST to ``API_PROJECTS``
653
    def create_project(self, specs):
654
        """Submit application to create a new project
655

656
        Arguments:
657
        specs -- dict describing a project
658

659
        In case of success, return project and application identifiers.
660
        """
661
        req_headers = {'content-type': 'application/json'}
662
        req_body = parse_request(specs, self.logger)
663
        return self._call_astakos(self.api_projects,
664
                                  headers=req_headers, body=req_body,
665
                                  method="POST")
666

    
667
    # ------------------------------------------
668
    # do a POST to ``API_PROJECTS``/<project_id>
669
    def modify_project(self, project_id, specs):
670
        """Submit application to modify an existing project
671

672
        Arguments:
673
        project_id -- project identifier
674
        specs      -- dict describing a project
675

676
        In case of success, return project and application identifiers.
677
        """
678
        path = join_urls(self.api_projects, str(project_id))
679
        req_headers = {'content-type': 'application/json'}
680
        req_body = parse_request(specs, self.logger)
681
        return self._call_astakos(path, headers=req_headers,
682
                                  body=req_body, method="POST")
683

    
684
    # -------------------------------------------------
685
    # do a POST to ``API_PROJECTS``/<project_id>/action
686
    def project_action(self, project_id, action, reason=""):
687
        """Perform action on a project
688

689
        Arguments:
690
        project_id -- project identifier
691
        action     -- action to perform, one of "suspend", "unsuspend",
692
                      "terminate", "reinstate"
693
        reason     -- reason of performing the action
694

695
        In case of success, return nothing.
696
        """
697
        path = join_urls(self.api_projects, str(project_id))
698
        path = join_urls(path, "action")
699
        req_headers = {'content-type': 'application/json'}
700
        req_body = parse_request({action: reason}, self.logger)
701
        return self._call_astakos(path, headers=req_headers,
702
                                  body=req_body, method="POST")
703

    
704
    # --------------------------------
705
    # do a GET to ``API_APPLICATIONS``
706
    def get_applications(self, project=None):
707
        """Retrieve all accessible applications
708

709
        Arguments:
710
        project -- filter by project (optional)
711

712
        In case of success, return a list of application descriptions.
713
        """
714
        req_headers = {'content-type': 'application/json'}
715
        body = {"project": project} if project is not None else None
716
        req_body = parse_request(body, self.logger) if body else None
717
        return self._call_astakos(self.api_applications,
718
                                  headers=req_headers, body=req_body)
719

    
720
    # -----------------------------------------
721
    # do a GET to ``API_APPLICATIONS``/<app_id>
722
    def get_application(self, app_id):
723
        """Retrieve application description, if accessible
724

725
        Arguments:
726
        app_id -- application identifier
727

728
        In case of success, return application description.
729
        """
730
        path = join_urls(self.api_applications, str(app_id))
731
        return self._call_astakos(path)
732

    
733
    # -------------------------------------------------
734
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
735
    def application_action(self, app_id, action, reason=""):
736
        """Perform action on an application
737

738
        Arguments:
739
        app_id -- application identifier
740
        action -- action to perform, one of "approve", "deny",
741
                  "dismiss", "cancel"
742
        reason -- reason of performing the action
743

744
        In case of success, return nothing.
745
        """
746
        path = join_urls(self.api_applications, str(app_id))
747
        path = join_urls(path, "action")
748
        req_headers = {'content-type': 'application/json'}
749
        req_body = parse_request({action: reason}, self.logger)
750
        return self._call_astakos(path, headers=req_headers,
751
                                  body=req_body, method="POST")
752

    
753
    # -------------------------------
754
    # do a GET to ``API_MEMBERSHIPS``
755
    def get_memberships(self, project=None):
756
        """Retrieve all accessible memberships
757

758
        Arguments:
759
        project -- filter by project (optional)
760

761
        In case of success, return a list of membership descriptions.
762
        """
763
        req_headers = {'content-type': 'application/json'}
764
        body = {"project": project} if project is not None else None
765
        req_body = parse_request(body, self.logger) if body else None
766
        return self._call_astakos(self.api_memberships,
767
                                  headers=req_headers, body=req_body)
768

    
769
    # -----------------------------------------
770
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
771
    def get_membership(self, memb_id):
772
        """Retrieve membership description, if accessible
773

774
        Arguments:
775
        memb_id -- membership identifier
776

777
        In case of success, return membership description.
778
        """
779
        path = join_urls(self.api_memberships, str(memb_id))
780
        return self._call_astakos(path)
781

    
782
    # -------------------------------------------------
783
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
784
    def membership_action(self, memb_id, action, reason=""):
785
        """Perform action on a membership
786

787
        Arguments:
788
        memb_id -- membership identifier
789
        action  -- action to perform, one of "leave", "cancel", "accept",
790
                   "reject", "remove"
791
        reason  -- reason of performing the action
792

793
        In case of success, return nothing.
794
        """
795
        path = join_urls(self.api_memberships, str(memb_id))
796
        path = join_urls(path, "action")
797
        req_headers = {'content-type': 'application/json'}
798
        req_body = parse_request({action: reason}, self.logger)
799
        return self._call_astakos(path, headers=req_headers,
800
                                  body=req_body, method="POST")
801

    
802
    # --------------------------------
803
    # do a POST to ``API_MEMBERSHIPS``
804
    def join_project(self, project_id):
805
        """Join a project
806

807
        Arguments:
808
        project_id -- project identifier
809

810
        In case of success, return membership identifier.
811
        """
812
        req_headers = {'content-type': 'application/json'}
813
        body = {"join": {"project": project_id}}
814
        req_body = parse_request(body, self.logger)
815
        return self._call_astakos(self.api_memberships, headers=req_headers,
816
                                  body=req_body, method="POST")
817

    
818
    # --------------------------------
819
    # do a POST to ``API_MEMBERSHIPS``
820
    def enroll_member(self, project_id, email):
821
        """Enroll a user in a project
822

823
        Arguments:
824
        project_id -- project identifier
825
        email      -- user identified by email
826

827
        In case of success, return membership identifier.
828
        """
829
        req_headers = {'content-type': 'application/json'}
830
        body = {"enroll": {"project": project_id, "user": email}}
831
        req_body = parse_request(body, self.logger)
832
        return self._call_astakos(self.api_memberships, headers=req_headers,
833
                                  body=req_body, method="POST")
834

    
835

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

842
    Keyword arguments:
843
    endpoints     -- the endpoints (json response from get_endpoints)
844
    ep_name       -- return only endpoints with this name (optional)
845
    ep_type       -- return only endpoints with this type (optional)
846
    ep_region     -- return only endpoints with this region (optional)
847
    ep_version_id -- return only endpoints with this versionId (optional)
848

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

853
    """
854
    try:
855
        catalog = endpoints['access']['serviceCatalog']
856
        if ep_name is not None:
857
            catalog = \
858
                [c for c in catalog if c['name'] == ep_name]
859
        if ep_type is not None:
860
            catalog = \
861
                [c for c in catalog if c['type'] == ep_type]
862
        if ep_region is not None:
863
            for c in catalog:
864
                c['endpoints'] = [e for e in c['endpoints']
865
                                  if e['region'] == ep_region]
866
            # Remove catalog entries with no endpoints
867
            catalog = \
868
                [c for c in catalog if c['endpoints']]
869
        if ep_version_id is not None:
870
            for c in catalog:
871
                c['endpoints'] = [e for e in c['endpoints']
872
                                  if e['versionId'] == ep_version_id]
873
            # Remove catalog entries with no endpoints
874
            catalog = \
875
                [c for c in catalog if c['endpoints']]
876

    
877
        if not catalog:
878
            raise NoEndpoints(ep_name, ep_type,
879
                              ep_region, ep_version_id)
880
        else:
881
            return catalog
882
    except KeyError:
883
        raise NoEndpoints()
884

    
885

    
886
# --------------------------------------------------------------------
887
# Private functions
888
# We want _doRequest to be a distinct function
889
# so that we can replace it during unit tests.
890
def _do_request(conn, method, url, **kwargs):
891
    """The actual request. This function can easily be mocked"""
892
    conn.request(method, url, **kwargs)
893
    response = conn.getresponse()
894
    length = response.getheader('content-length', None)
895
    data = response.read(length)
896
    status = int(response.status)
897
    message = response.reason
898
    return (message, data, status)