Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ ff5edb80

History | View | Annotate | Download (40.3 kB)

1
# Copyright (C) 2012-2014 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 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
# --------------------------------------------------------------------
54
# Astakos Client Class
55

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

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

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

    
70

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

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

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

93
        """
94

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

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

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

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

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

    
128
    def _fill_endpoints(self, endpoints, extra=False):
129
        """Fill the endpoints for our AstakosClient
130

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

137
        """
138
        astakos_service_catalog = parse_endpoints(
139
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
140
        self._account_url = \
141
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
142
        parsed_account_url = urlparse.urlparse(self._account_url)
143

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

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

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

    
154
        if extra:
155
            oauth2_service_catalog = \
156
                parse_endpoints(endpoints, ep_name="astakos_oauth2")
157
            self._oauth2_url = \
158
                oauth2_service_catalog[0]['endpoints'][0]['publicURL']
159
            parsed_oauth2_url = urlparse.urlparse(self._oauth2_url)
160
            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
        try:
167
            return getattr(self, s)
168
        except AttributeError:
169
            self.get_endpoints(extra=extra)
170
            return getattr(self, s)
171

    
172
    @property
173
    def account_url(self):
174
        return self._get_value('_account_url')
175

    
176
    @property
177
    def account_prefix(self):
178
        return self._get_value('_account_prefix')
179

    
180
    @property
181
    def ui_url(self):
182
        return self._get_value('_ui_url')
183

    
184
    @property
185
    def ui_prefix(self):
186
        return self._get_value('_ui_prefix')
187

    
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
    @property
197
    def api_usercatalogs(self):
198
        return join_urls(self.account_prefix, "user_catalogs")
199

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

    
204
    @property
205
    def api_resources(self):
206
        return join_urls(self.account_prefix, "resources")
207

    
208
    @property
209
    def api_quotas(self):
210
        return join_urls(self.account_prefix, "quotas")
211

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

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

    
220
    @property
221
    def api_commissions(self):
222
        return join_urls(self.account_prefix, "commissions")
223

    
224
    @property
225
    def api_commissions_action(self):
226
        return join_urls(self.api_commissions, "action")
227

    
228
    @property
229
    def api_feedback(self):
230
        return join_urls(self.account_prefix, "feedback")
231

    
232
    @property
233
    def api_projects(self):
234
        return join_urls(self.account_prefix, "projects")
235

    
236
    @property
237
    def api_memberships(self):
238
        return join_urls(self.api_projects, "memberships")
239

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

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

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

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

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

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

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

    
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
                # Log the response so other clients (like kamaki)
299
                # can use them to produce their own log messages.
300
                self.log_response = dict(
301
                    status=status, message=message, data=data)
302
        except Exception as err:
303
            self.logger.error("Failed to send request: %s" % repr(err))
304
            raise AstakosClientException(str(err))
305

    
306
        # Return
307
        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
        try:
320
            if data:
321
                return simplejson.loads(unicode(data))
322
            else:
323
                return None
324
        except Exception as err:
325
            msg = "Cannot parse response \"%s\" with simplejson: %s"
326
            self.logger.error(msg % (data, str(err)))
327
            raise InvalidResponse(str(err), data)
328

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

    
346
    def get_usernames(self, uuids):
347
        """Return a uuid_catalog dictionary for the given uuids
348

349
        Keyword arguments:
350
        uuids   -- list of user ids (list of strings)
351

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

355
        """
356
        return self._uuid_catalog(uuids, self.api_usercatalogs)
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
        if uuid in uuid_dict:
363
            return uuid_dict.get(uuid)
364
        else:
365
            raise NoUserName(uuid)
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
        if uuid in uuid_dict:
376
            return uuid_dict.get(uuid)
377
        else:
378
            raise NoUserName(uuid)
379

    
380
    # ----------------------------------
381
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
382
    #   with {'displaynames': display_names}
383
    def _displayname_catalog(self, display_names, req_path):
384
        """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
        data = self._call_astakos(req_path, headers=req_headers,
388
                                  body=req_body, method="POST")
389
        if "displayname_catalog" in data:
390
            return data.get("displayname_catalog")
391
        else:
392
            msg = "_displayname_catalog request returned %s. " \
393
                  "No displayname_catalog found" % data
394
            self.logger.error(msg)
395
            raise AstakosClientException(msg)
396

    
397
    def get_uuids(self, display_names):
398
        """Return a displayname_catalog for the given names
399

400
        Keyword arguments:
401
        display_names   -- list of user names (list of strings)
402

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

406
        """
407
        return self._displayname_catalog(
408
            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
        if display_name in name_dict:
415
            return name_dict.get(display_name)
416
        else:
417
            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
        return self._displayname_catalog(
422
            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
        if display_name in name_dict:
429
            return name_dict.get(display_name)
430
        else:
431
            raise NoUUID(display_name)
432

    
433
    # ----------------------------------
434
    # do a GET to ``API_GETSERVICES``
435
    def get_services(self):
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
    def get_resources(self):
442
        """Return a dict of dicts with the available resources"""
443
        return self._call_astakos(self.api_resources)
444

    
445
    # ----------------------------------
446
    # do a POST to ``API_FEEDBACK``
447
    def send_feedback(self, message, data):
448
        """Send feedback to astakos service
449

450
        keyword arguments:
451
        message     -- Feedback message
452
        data        -- Additional information about service client status
453

454
        In case of success return nothing.
455
        Otherwise raise an AstakosClientException
456

457
        """
458
        check_input("send_feedback", self.logger, message=message, data=data)
459
        req_body = urllib.urlencode(
460
            {'feedback_msg': message, 'feedback_data': data})
461
        self._call_astakos(self.api_feedback, headers=None,
462
                           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
        """ Get services' endpoints
468

469
        The extra parameter is to be used by _fill_endpoints.
470
        In case of error raise an AstakosClientException.
471

472
        """
473
        req_headers = {'content-type': 'application/json'}
474
        req_body = None
475
        r = self._call_astakos(self.api_tokens, headers=req_headers,
476
                               body=req_body, method="POST",
477
                               log_body=False)
478
        self._fill_endpoints(r, extra=extra)
479
        return r
480

    
481
    # --------------------------------------
482
    # do a POST to ``API_TOKENS`` with a token
483
    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
        The tenant_name is optional and if it is given it must match the
493
        user's uuid.
494

495
        In case of error raise an AstakosClientException.
496

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

    
509
    # --------------------------------------
510
    # do a GET to ``API_TOKENS`` with a token
511
    def validate_token(self, token_id, belongs_to=None):
512
        """ Validate a temporary access token (oath2)
513

514
        Keyword arguments:
515
        belongsTo         -- confirm that token belongs to tenant
516

517
        It returns back the token as well as information about the token
518
        holder.
519

520
        The belongs_to is optional and if it is given it must be inside the
521
        token's scope.
522

523
        In case of error raise an AstakosClientException.
524

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

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

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

540
        """
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
        """Get all quotas for resources associated with the service
547

548
        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
        Otherwise raise an AstakosClientException
554

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

    
561
    # ----------------------------------
562
    # do a GET to ``API_SERVICE_PROJECT_QUOTAS``
563
    def service_get_project_quotas(self, project=None):
564
        """Get all project quotas for resources associated with the service
565

566
        Keyword arguments:
567
        project    -- optionally, the uuid of a specific project
568

569
        In case of success return a dict of dicts with current quotas
570
        for all projects, or of a specified project, if project argument is set.
571
        Otherwise raise an AstakosClientException
572

573
        """
574
        query = self.api_service_project_quotas
575
        if project is not None:
576
            query += "?project=" + project
577
        return self._call_astakos(query)
578

    
579
    # ----------------------------------
580
    # do a POST to ``API_COMMISSIONS``
581
    def _issue_commission(self, request):
582
        """Issue a commission
583

584
        Keyword arguments:
585
        request -- commision request (dict)
586

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

590
        """
591
        req_headers = {'content-type': 'application/json'}
592
        req_body = parse_request(request, self.logger)
593
        try:
594
            response = self._call_astakos(self.api_commissions,
595
                                          headers=req_headers,
596
                                          body=req_body,
597
                                          method="POST")
598
        except AstakosClientException as err:
599
            if err.status == 413:
600
                raise QuotaLimit(err.message, err.details)
601
            else:
602
                raise
603

    
604
        if "serial" in response:
605
            return response['serial']
606
        else:
607
            msg = "issue_commission_core request returned %s. " + \
608
                  "No serial found" % response
609
            self.logger.error(msg)
610
            raise AstakosClientException(msg)
611

    
612
    def _mk_user_provision(self, holder, source, resource, quantity):
613
        holder = "user:" + holder
614
        source = "project:" + source
615
        return {"holder": holder, "source": source,
616
                "resource": resource, "quantity": quantity}
617

    
618
    def _mk_project_provision(self, holder, resource, quantity):
619
        holder = "project:" + holder
620
        return {"holder": holder, "source": None,
621
                "resource": resource, "quantity": quantity}
622

    
623
    def mk_provisions(self, holder, source, resource, quantity):
624
        return [self._mk_user_provision(holder, source, resource, quantity),
625
                self._mk_project_provision(source, resource, quantity)]
626

    
627
    def issue_commission_generic(self, user_provisions, project_provisions,
628
                                 name="", force=False, auto_accept=False):
629
        """Issue commission (for multiple holder/source pairs)
630

631
        keyword arguments:
632
        user_provisions  -- dict mapping user holdings
633
                            (user, project, resource) to integer quantities
634
        project_provisions -- dict mapping project holdings
635
                              (project, resource) to integer quantities
636
        name        -- description of the commission (string)
637
        force       -- force this commission (boolean)
638
        auto_accept -- auto accept this commission (boolean)
639

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

643
        """
644
        request = {}
645
        request["force"] = force
646
        request["auto_accept"] = auto_accept
647
        request["name"] = name
648
        try:
649
            request["provisions"] = []
650
            for (holder, source, resource), quantity in \
651
                    user_provisions.iteritems():
652
                p = self._mk_user_provision(holder, source, resource, quantity)
653
                request["provisions"].append(p)
654
            for (holder, resource), quantity in project_provisions.iteritems():
655
                p = self._mk_project_provision(holder, resource, quantity)
656
                request["provisions"].append(p)
657
        except Exception as err:
658
            self.logger.error(str(err))
659
            raise BadValue(str(err))
660

    
661
        return self._issue_commission(request)
662

    
663
    def issue_one_commission(self, holder, source, provisions,
664
                             name="", force=False, auto_accept=False):
665
        """Issue one commission (with specific holder and source)
666

667
        keyword arguments:
668
        holder      -- user's id (string)
669
        source      -- commission's source (ex system) (string)
670
        provisions  -- resources with their quantity (dict from string to int)
671
        name        -- description of the commission (string)
672
        force       -- force this commission (boolean)
673
        auto_accept -- auto accept this commission (boolean)
674

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

678
        """
679
        check_input("issue_one_commission", self.logger,
680
                    holder=holder, source=source,
681
                    provisions=provisions)
682

    
683
        request = {}
684
        request["force"] = force
685
        request["auto_accept"] = auto_accept
686
        request["name"] = name
687
        try:
688
            request["provisions"] = []
689
            for resource, quantity in provisions.iteritems():
690
                ps = self.mk_provisions(holder, source, resource, quantity)
691
                request["provisions"].extend(ps)
692
        except Exception as err:
693
            self.logger.error(str(err))
694
            raise BadValue(str(err))
695

    
696
        return self._issue_commission(request)
697

    
698
    def issue_resource_reassignment(self, holder, from_source,
699
                                    to_source, provisions, name="",
700
                                    force=False, auto_accept=False):
701
        """Change resource assignment to another project
702
        """
703

    
704
        request = {}
705
        request["force"] = force
706
        request["auto_accept"] = auto_accept
707
        request["name"] = name
708

    
709
        try:
710
            request["provisions"] = []
711
            for resource, quantity in provisions.iteritems():
712
                ps = self.mk_provisions(
713
                    holder, from_source, resource, -quantity)
714
                ps += self.mk_provisions(holder, to_source, resource, quantity)
715
                request["provisions"].extend(ps)
716
        except Exception as err:
717
            self.logger.error(str(err))
718
            raise BadValue(str(err))
719

    
720
        return self._issue_commission(request)
721

    
722
    # ----------------------------------
723
    # do a GET to ``API_COMMISSIONS``
724
    def get_pending_commissions(self):
725
        """Get Pending Commissions
726

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

730
        """
731
        return self._call_astakos(self.api_commissions)
732

    
733
    # ----------------------------------
734
    # do a GET to ``API_COMMISSIONS``/<serial>
735
    def get_commission_info(self, serial):
736
        """Get Description of a Commission
737

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

741
        In case of success return a dict of dicts containing
742
        informations (details) about the requested commission
743

744
        """
745
        check_input("get_commission_info", self.logger, serial=serial)
746

    
747
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
748
        return self._call_astakos(path)
749

    
750
    # ----------------------------------
751
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
752
    def commission_action(self, serial, action):
753
        """Perform a commission action
754

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

759
        In case of success return nothing.
760

761
        """
762
        check_input("commission_action", self.logger,
763
                    serial=serial, action=action)
764

    
765
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
766
        req_headers = {'content-type': 'application/json'}
767
        req_body = parse_request({str(action): ""}, self.logger)
768
        self._call_astakos(path, headers=req_headers,
769
                           body=req_body, method="POST")
770

    
771
    def accept_commission(self, serial):
772
        """Accept a commission (see commission_action)"""
773
        self.commission_action(serial, "accept")
774

    
775
    def reject_commission(self, serial):
776
        """Reject a commission (see commission_action)"""
777
        self.commission_action(serial, "reject")
778

    
779
    # ----------------------------------
780
    # do a POST to ``API_COMMISSIONS_ACTION``
781
    def resolve_commissions(self, accept_serials, reject_serials):
782
        """Resolve multiple commissions at once
783

784
        Keyword arguments:
785
        accept_serials  -- commissions to accept (list of ints)
786
        reject_serials  -- commissions to reject (list of ints)
787

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

792
        """
793
        check_input("resolve_commissions", self.logger,
794
                    accept_serials=accept_serials,
795
                    reject_serials=reject_serials)
796

    
797
        req_headers = {'content-type': 'application/json'}
798
        req_body = parse_request({"accept": accept_serials,
799
                                  "reject": reject_serials},
800
                                 self.logger)
801
        return self._call_astakos(self.api_commissions_action,
802
                                  headers=req_headers, body=req_body,
803
                                  method="POST")
804

    
805
    # ----------------------------
806
    # do a GET to ``API_PROJECTS``
807
    def get_projects(self, name=None, state=None, owner=None, mode=None):
808
        """Retrieve all accessible projects
809

810
        Arguments:
811
        name  -- filter by name (optional)
812
        state -- filter by state (optional)
813
        owner -- filter by owner (optional)
814
        mode  -- if value is 'member', return only active projects in which
815
                 the request user is an active member
816

817
        In case of success, return a list of project descriptions.
818
        """
819
        filters = {}
820
        if name is not None:
821
            filters["name"] = name
822
        if state is not None:
823
            filters["state"] = state
824
        if owner is not None:
825
            filters["owner"] = owner
826
        if mode is not None:
827
            filters["mode"] = mode
828
        path = self.api_projects
829
        if filters:
830
            path += "?" + urllib.urlencode(filters)
831
        req_headers = {'content-type': 'application/json'}
832
        return self._call_astakos(path, headers=req_headers)
833

    
834
    # -----------------------------------------
835
    # do a GET to ``API_PROJECTS``/<project_id>
836
    def get_project(self, project_id):
837
        """Retrieve project description, if accessible
838

839
        Arguments:
840
        project_id -- project identifier
841

842
        In case of success, return project description.
843
        """
844
        path = join_urls(self.api_projects, str(project_id))
845
        return self._call_astakos(path)
846

    
847
    # -----------------------------
848
    # do a POST to ``API_PROJECTS``
849
    def create_project(self, specs):
850
        """Submit application to create a new project
851

852
        Arguments:
853
        specs -- dict describing a project
854

855
        In case of success, return project and application identifiers.
856
        """
857
        req_headers = {'content-type': 'application/json'}
858
        req_body = parse_request(specs, self.logger)
859
        return self._call_astakos(self.api_projects,
860
                                  headers=req_headers, body=req_body,
861
                                  method="POST")
862

    
863
    # ------------------------------------------
864
    # do a PUT to ``API_PROJECTS``/<project_id>
865
    def modify_project(self, project_id, specs):
866
        """Submit application to modify an existing project
867

868
        Arguments:
869
        project_id -- project identifier
870
        specs      -- dict describing a project
871

872
        In case of success, return project and application identifiers.
873
        """
874
        path = join_urls(self.api_projects, str(project_id))
875
        req_headers = {'content-type': 'application/json'}
876
        req_body = parse_request(specs, self.logger)
877
        return self._call_astakos(path, headers=req_headers,
878
                                  body=req_body, method="PUT")
879

    
880
    # -------------------------------------------------
881
    # do a POST to ``API_PROJECTS``/<project_id>/action
882
    def project_action(self, project_id, action, reason=""):
883
        """Perform action on a project
884

885
        Arguments:
886
        project_id -- project identifier
887
        action     -- action to perform, one of "suspend", "unsuspend",
888
                      "terminate", "reinstate"
889
        reason     -- reason of performing the action
890

891
        In case of success, return nothing.
892
        """
893
        path = join_urls(self.api_projects, str(project_id))
894
        path = join_urls(path, "action")
895
        req_headers = {'content-type': 'application/json'}
896
        req_body = parse_request({action: {"reason": reason}}, self.logger)
897
        return self._call_astakos(path, headers=req_headers,
898
                                  body=req_body, method="POST")
899

    
900
    # -------------------------------------------------
901
    # do a POST to ``API_PROJECTS``/<project_id>/action
902
    def application_action(self, project_id, app_id, action, reason=""):
903
        """Perform action on a project application
904

905
        Arguments:
906
        project_id -- project identifier
907
        app_id     -- application identifier
908
        action     -- action to perform, one of "approve", "deny",
909
                      "dismiss", "cancel"
910
        reason     -- reason of performing the action
911

912
        In case of success, return nothing.
913
        """
914
        path = join_urls(self.api_projects, str(project_id))
915
        path = join_urls(path, "action")
916
        req_headers = {'content-type': 'application/json'}
917
        req_body = parse_request({action: {
918
                    "reasons": reason,
919
                    "app_id": app_id}}, self.logger)
920
        return self._call_astakos(path, headers=req_headers,
921
                                  body=req_body, method="POST")
922

    
923
    # -------------------------------
924
    # do a GET to ``API_MEMBERSHIPS``
925
    def get_memberships(self, project=None):
926
        """Retrieve all accessible memberships
927

928
        Arguments:
929
        project -- filter by project (optional)
930

931
        In case of success, return a list of membership descriptions.
932
        """
933
        req_headers = {'content-type': 'application/json'}
934
        filters = {}
935
        if project is not None:
936
            filters["project"] = project
937
        path = self.api_memberships
938
        if filters:
939
            path += '?' + urllib.urlencode(filters)
940
        return self._call_astakos(path, headers=req_headers)
941

    
942
    # -----------------------------------------
943
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
944
    def get_membership(self, memb_id):
945
        """Retrieve membership description, if accessible
946

947
        Arguments:
948
        memb_id -- membership identifier
949

950
        In case of success, return membership description.
951
        """
952
        path = join_urls(self.api_memberships, str(memb_id))
953
        return self._call_astakos(path)
954

    
955
    # -------------------------------------------------
956
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
957
    def membership_action(self, memb_id, action, reason=""):
958
        """Perform action on a membership
959

960
        Arguments:
961
        memb_id -- membership identifier
962
        action  -- action to perform, one of "leave", "cancel", "accept",
963
                   "reject", "remove"
964
        reason  -- reason of performing the action
965

966
        In case of success, return nothing.
967
        """
968
        path = join_urls(self.api_memberships, str(memb_id))
969
        path = join_urls(path, "action")
970
        req_headers = {'content-type': 'application/json'}
971
        req_body = parse_request({action: reason}, self.logger)
972
        return self._call_astakos(path, headers=req_headers,
973
                                  body=req_body, method="POST")
974

    
975
    # --------------------------------
976
    # do a POST to ``API_MEMBERSHIPS``
977
    def join_project(self, project_id):
978
        """Join a project
979

980
        Arguments:
981
        project_id -- project identifier
982

983
        In case of success, return membership identifier.
984
        """
985
        req_headers = {'content-type': 'application/json'}
986
        body = {"join": {"project": project_id}}
987
        req_body = parse_request(body, self.logger)
988
        return self._call_astakos(self.api_memberships, headers=req_headers,
989
                                  body=req_body, method="POST")
990

    
991
    # --------------------------------
992
    # do a POST to ``API_MEMBERSHIPS``
993
    def enroll_member(self, project_id, email):
994
        """Enroll a user in a project
995

996
        Arguments:
997
        project_id -- project identifier
998
        email      -- user identified by email
999

1000
        In case of success, return membership identifier.
1001
        """
1002
        req_headers = {'content-type': 'application/json'}
1003
        body = {"enroll": {"project": project_id, "user": email}}
1004
        req_body = parse_request(body, self.logger)
1005
        return self._call_astakos(self.api_memberships, headers=req_headers,
1006
                                  body=req_body, method="POST")
1007

    
1008
    # --------------------------------
1009
    # do a POST to ``API_OAUTH2_TOKEN``
1010
    def get_token(self, grant_type, client_id, client_secret, **body_params):
1011
        headers = {'content-type': 'application/x-www-form-urlencoded',
1012
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
1013
                                                           (client_id,
1014
                                                            client_secret))}
1015
        body_params['grant_type'] = grant_type
1016
        body = urllib.urlencode(body_params)
1017
        return self._call_astakos(self.api_oauth2_token, headers=headers,
1018
                                  body=body, method="POST")
1019

    
1020

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

1027
    Keyword arguments:
1028
    endpoints     -- the endpoints (json response from get_endpoints)
1029
    ep_name       -- return only endpoints with this name (optional)
1030
    ep_type       -- return only endpoints with this type (optional)
1031
    ep_region     -- return only endpoints with this region (optional)
1032
    ep_version_id -- return only endpoints with this versionId (optional)
1033

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

1038
    """
1039
    try:
1040
        catalog = endpoints['access']['serviceCatalog']
1041
        if ep_name is not None:
1042
            catalog = \
1043
                [c for c in catalog if c['name'] == ep_name]
1044
        if ep_type is not None:
1045
            catalog = \
1046
                [c for c in catalog if c['type'] == ep_type]
1047
        if ep_region is not None:
1048
            for c in catalog:
1049
                c['endpoints'] = [e for e in c['endpoints']
1050
                                  if e['region'] == ep_region]
1051
            # Remove catalog entries with no endpoints
1052
            catalog = \
1053
                [c for c in catalog if c['endpoints']]
1054
        if ep_version_id is not None:
1055
            for c in catalog:
1056
                c['endpoints'] = [e for e in c['endpoints']
1057
                                  if e['versionId'] == ep_version_id]
1058
            # Remove catalog entries with no endpoints
1059
            catalog = \
1060
                [c for c in catalog if c['endpoints']]
1061

    
1062
        if not catalog:
1063
            raise NoEndpoints(ep_name, ep_type,
1064
                              ep_region, ep_version_id)
1065
        else:
1066
            return catalog
1067
    except KeyError:
1068
        raise NoEndpoints(ep_name, ep_type, ep_region, ep_version_id)
1069

    
1070

    
1071
# --------------------------------------------------------------------
1072
# Private functions
1073
# We want _do_request to be a distinct function
1074
# so that we can replace it during unit tests.
1075
def _do_request(conn, method, url, **kwargs):
1076
    """The actual request. This function can easily be mocked"""
1077
    conn.request(method, url, **kwargs)
1078
    response = conn.getresponse()
1079
    length = response.getheader('content-length', None)
1080
    data = response.read(length)
1081
    status = int(response.status)
1082
    message = response.reason
1083
    return (message, data, status)