Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / plankton / backend.py @ df455d57

History | View | Annotate | Download (21.5 kB)

1
# Copyright 2011-2013 GRNET S.A. All rights reserved.
2

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

    
35
"""
36
The Plankton attributes are the following:
37
  - checksum: the 'hash' meta
38
  - container_format: stored as a user meta
39
  - created_at: the 'modified' meta of the first version
40
  - deleted_at: the timestamp of the last version
41
  - disk_format: stored as a user meta
42
  - id: the 'uuid' meta
43
  - is_public: True if there is a * entry for the read permission
44
  - location: generated based on the file's path
45
  - name: stored as a user meta
46
  - owner: the file's account
47
  - properties: stored as user meta prefixed with PROPERTY_PREFIX
48
  - size: the 'bytes' meta
49
  - status: stored as a system meta
50
  - store: is always 'pithos'
51
  - updated_at: the 'modified' meta
52
"""
53

    
54
import json
55
import warnings
56
import logging
57
import os
58

    
59
from time import time, gmtime, strftime
60
from functools import wraps
61
from operator import itemgetter
62

    
63
from django.conf import settings
64
from django.utils import importlib
65
from django.utils.encoding import smart_unicode
66
from pithos.backends.base import NotAllowedError, VersionNotExists
67

    
68

    
69
logger = logging.getLogger(__name__)
70

    
71

    
72
PLANKTON_DOMAIN = 'plankton'
73
PLANKTON_PREFIX = 'plankton:'
74
PROPERTY_PREFIX = 'property:'
75

    
76
PLANKTON_META = ('container_format', 'disk_format', 'name',
77
                 'status', 'created_at')
78

    
79
MAX_META_KEY_LENGTH = 128 - len(PLANKTON_DOMAIN) - len(PROPERTY_PREFIX)
80
MAX_META_VALUE_LENGTH = 256
81

    
82
from pithos.backends.util import PithosBackendPool
83
_pithos_backend_pool = \
84
    PithosBackendPool(
85
        settings.PITHOS_BACKEND_POOL_SIZE,
86
        astakos_auth_url=settings.ASTAKOS_AUTH_URL,
87
        service_token=settings.CYCLADES_SERVICE_TOKEN,
88
        astakosclient_poolsize=settings.CYCLADES_ASTAKOSCLIENT_POOLSIZE,
89
        db_connection=settings.BACKEND_DB_CONNECTION,
90
        block_path=settings.BACKEND_BLOCK_PATH)
91

    
92

    
93
def get_pithos_backend():
94
    return _pithos_backend_pool.pool_get()
95

    
96

    
97
def create_url(account, container, name):
98
    assert "/" not in account, "Invalid account"
99
    assert "/" not in container, "Invalid container"
100
    return "pithos://%s/%s/%s" % (account, container, name)
101

    
102

    
103
def split_url(url):
104
    """Returns (accout, container, object) from a url string"""
105
    try:
106
        assert(isinstance(url, basestring))
107
        t = url.split('/', 4)
108
        assert t[0] == "pithos:", "Invalid url"
109
        assert len(t) == 5, "Invalid url"
110
        return t[2:5]
111
    except AssertionError:
112
        raise InvalidLocation("Invalid location '%s" % url)
113

    
114

    
115
def format_timestamp(t):
116
    return strftime('%Y-%m-%d %H:%M:%S', gmtime(t))
117

    
118

    
119
def handle_backend_exceptions(func):
120
    @wraps(func)
121
    def wrapper(*args, **kwargs):
122
        try:
123
            return func(*args, **kwargs)
124
        except NotAllowedError:
125
            raise Forbidden
126
        except NameError:
127
            raise ImageNotFound
128
        except VersionNotExists:
129
            raise ImageNotFound
130
    return wrapper
131

    
132

    
133
def commit_on_success(func):
134
    def wrapper(self, *args, **kwargs):
135
        backend = self.backend
136
        backend.pre_exec()
137
        try:
138
            ret = func(self, *args, **kwargs)
139
        except:
140
            backend.post_exec(False)
141
            raise
142
        else:
143
            backend.post_exec(True)
144
        return ret
145
    return wrapper
146

    
147

    
148
class ImageBackend(object):
149
    """A wrapper arround the pithos backend to simplify image handling."""
150

    
151
    def __init__(self, user):
152
        self.user = user
153

    
154
        original_filters = warnings.filters
155
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
156
        self.backend = get_pithos_backend()
157
        warnings.filters = original_filters     # Restore warnings
158

    
159
    def close(self):
160
        """Close PithosBackend(return to pool)"""
161
        self.backend.close()
162

    
163
    @handle_backend_exceptions
164
    @commit_on_success
165
    def get_image(self, image_uuid):
166
        """Retrieve information about an image."""
167
        image_url = self._get_image_url(image_uuid)
168
        return self._get_image(image_url)
169

    
170
    def _get_image_url(self, image_uuid):
171
        """Get the Pithos url that corresponds to an image UUID."""
172
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
173
        return create_url(account, container, name)
174

    
175
    def _get_image(self, image_url):
176
        """Get information about an Image.
177

178
        Get all available information about an Image.
179
        """
180
        account, container, name = split_url(image_url)
181
        try:
182
            meta = self._get_meta(image_url)
183
            meta["deleted"] = ""
184
        except NameError:
185
            versions = self.backend.list_versions(self.user, account,
186
                                                  container, name)
187
            if not versions:
188
                raise Exception("Image without versions %s" % image_url)
189
            # Object was deleted, use the latest version
190
            version, timestamp = versions[-1]
191
            meta = self._get_meta(image_url, version)
192
            meta["deleted"] = timestamp
193

    
194
        # XXX: Check that an object is a plankton image! PithosBackend will
195
        # return common metadata for an object, even if it has no metadata in
196
        # plankton domain. All images must have a name, so we check if a file
197
        # is an image by checking if they are having an image name.
198
        if PLANKTON_PREFIX + 'name' not in meta:
199
            raise ImageNotFound
200

    
201
        permissions = self._get_permissions(image_url)
202
        return image_to_dict(image_url, meta, permissions)
203

    
204
    def _get_meta(self, image_url, version=None):
205
        """Get object's metadata."""
206
        account, container, name = split_url(image_url)
207
        return self.backend.get_object_meta(self.user, account, container,
208
                                            name, PLANKTON_DOMAIN, version)
209

    
210
    def _update_meta(self, image_url, meta, replace=False):
211
        """Update object's metadata."""
212
        account, container, name = split_url(image_url)
213

    
214
        prefixed = [(PLANKTON_PREFIX + smart_unicode(k, encoding="utf-8"),
215
                     smart_unicode(v, encoding="utf-8"))
216
                    for k, v in meta.items()
217
                    if k in PLANKTON_META or k.startswith(PROPERTY_PREFIX)]
218
        prefixed = dict(prefixed)
219

    
220
        for k, v in prefixed.items():
221
            if len(k) > 128:
222
                raise InvalidMetadata('Metadata keys should be less than %s '
223
                                      'characters' % MAX_META_KEY_LENGTH)
224
            if len(v) > 256:
225
                raise InvalidMetadata('Metadata values should be less than %s '
226
                                      'characters.' % MAX_META_VALUE_LENGTH)
227

    
228
        self.backend.update_object_meta(self.user, account, container, name,
229
                                        PLANKTON_DOMAIN, prefixed, replace)
230
        logger.debug("User '%s' updated image '%s', meta: '%s'", self.user,
231
                     image_url, prefixed)
232

    
233
    def _get_permissions(self, image_url):
234
        """Get object's permissions."""
235
        account, container, name = split_url(image_url)
236
        _a, path, permissions = \
237
            self.backend.get_object_permissions(self.user, account, container,
238
                                                name)
239

    
240
        if path is None and permissions != {}:
241
            logger.warning("Image '%s' got permissions '%s' from 'None' path.",
242
                           image_url, permissions)
243
            raise Exception("Database Inconsistency Error:"
244
                            " Image '%s' got permissions from 'None' path." %
245
                            image_url)
246

    
247
        return permissions
248

    
249
    def _update_permissions(self, image_url, permissions):
250
        """Update object's permissions."""
251
        account, container, name = split_url(image_url)
252
        self.backend.update_object_permissions(self.user, account, container,
253
                                               name, permissions)
254
        logger.debug("User '%s' updated image '%s', permissions: '%s'",
255
                     self.user, image_url, permissions)
256

    
257
    @handle_backend_exceptions
258
    @commit_on_success
259
    def unregister(self, image_uuid):
260
        """Unregister an image.
261

262
        Unregister an image, by removing all metadata from the Pithos
263
        file that exist in the PLANKTON_DOMAIN.
264

265
        """
266
        image_url = self._get_image_url(image_uuid)
267
        self._get_image(image_url)  # Assert that it is an image
268
        # Unregister the image by removing all metadata from domain
269
        # 'PLANKTON_DOMAIN'
270
        meta = {}
271
        self._update_meta(image_url, meta, True)
272
        logger.debug("User '%s' deleted image '%s'", self.user, image_url)
273

    
274
    @handle_backend_exceptions
275
    @commit_on_success
276
    def add_user(self, image_uuid, add_user):
277
        """Add a user as an image member.
278

279
        Update read permissions of Pithos file, to include the specified user.
280

281
        """
282
        image_url = self._get_image_url(image_uuid)
283
        self._get_image(image_url)  # Assert that it is an image
284
        permissions = self._get_permissions(image_url)
285
        read = set(permissions.get("read", []))
286
        assert(isinstance(add_user, (str, unicode)))
287
        read.add(add_user)
288
        permissions["read"] = list(read)
289
        self._update_permissions(image_url, permissions)
290

    
291
    @handle_backend_exceptions
292
    @commit_on_success
293
    def remove_user(self, image_uuid, remove_user):
294
        """Remove the user from image members.
295

296
        Remove the specified user from the read permissions of the Pithos file.
297

298
        """
299
        image_url = self._get_image_url(image_uuid)
300
        self._get_image(image_url)  # Assert that it is an image
301
        permissions = self._get_permissions(image_url)
302
        read = set(permissions.get("read", []))
303
        assert(isinstance(remove_user, (str, unicode)))
304
        try:
305
            read.remove(remove_user)
306
        except ValueError:
307
            return  # TODO: User did not have access
308
        permissions["read"] = list(read)
309
        self._update_permissions(image_url, permissions)
310

    
311
    @handle_backend_exceptions
312
    @commit_on_success
313
    def replace_users(self, image_uuid, replace_users):
314
        """Replace image members.
315

316
        Replace the read permissions of the Pithos files with the specified
317
        users. If image is specified as public, we must preserve * permission.
318

319
        """
320
        image_url = self._get_image_url(image_uuid)
321
        image = self._get_image(image_url)
322
        permissions = self._get_permissions(image_url)
323
        assert(isinstance(replace_users, list))
324
        permissions["read"] = replace_users
325
        if image.get("is_public", False):
326
            permissions["read"].append("*")
327
        self._update_permissions(image_url, permissions)
328

    
329
    @handle_backend_exceptions
330
    @commit_on_success
331
    def list_users(self, image_uuid):
332
        """List the image members.
333

334
        List the image members, by listing all users that have read permission
335
        to the corresponding Pithos file.
336

337
        """
338
        image_url = self._get_image_url(image_uuid)
339
        self._get_image(image_url)  # Assert that it is an image
340
        permissions = self._get_permissions(image_url)
341
        return [user for user in permissions.get('read', []) if user != '*']
342

    
343
    @handle_backend_exceptions
344
    @commit_on_success
345
    def update_metadata(self, image_uuid, metadata):
346
        """Update Image metadata."""
347
        image_url = self._get_image_url(image_uuid)
348
        self._get_image(image_url)  # Assert that it is an image
349

    
350
        # 'is_public' metadata is translated in proper file permissions
351
        is_public = metadata.pop("is_public", None)
352
        if is_public is not None:
353
            permissions = self._get_permissions(image_url)
354
            read = set(permissions.get("read", []))
355
            if is_public:
356
                read.add("*")
357
            else:
358
                read.discard("*")
359
            permissions["read"] = list(read)
360
            self._update_permissions(image_url, permissions)
361

    
362
        # Extract the properties dictionary from metadata, and store each
363
        # property as a separeted, prefixed metadata
364
        properties = metadata.pop("properties", {})
365
        meta = dict([(PROPERTY_PREFIX + k, v) for k, v in properties.items()])
366
        # Also add the following metadata
367
        meta.update(**metadata)
368

    
369
        self._update_meta(image_url, meta)
370
        image_url = self._get_image_url(image_uuid)
371
        return self._get_image(image_url)
372

    
373
    @handle_backend_exceptions
374
    @commit_on_success
375
    def register(self, name, image_url, metadata):
376
        # Validate that metadata are allowed
377
        if "id" in metadata:
378
            raise ValueError("Passing an ID is not supported")
379
        store = metadata.pop("store", "pithos")
380
        if store != "pithos":
381
            raise ValueError("Invalid store '%s'. Only 'pithos' store is"
382
                             "supported" % store)
383
        disk_format = metadata.setdefault("disk_format",
384
                                          settings.DEFAULT_DISK_FORMAT)
385
        if disk_format not in settings.ALLOWED_DISK_FORMATS:
386
            raise ValueError("Invalid disk format '%s'" % disk_format)
387
        container_format =\
388
            metadata.setdefault("container_format",
389
                                settings.DEFAULT_CONTAINER_FORMAT)
390
        if container_format not in settings.ALLOWED_CONTAINER_FORMATS:
391
            raise ValueError("Invalid container format '%s'" %
392
                             container_format)
393

    
394
        # Validate that 'size' and 'checksum' are valid
395
        account, container, object = split_url(image_url)
396

    
397
        meta = self._get_meta(image_url)
398

    
399
        size = int(metadata.pop('size', meta['bytes']))
400
        if size != meta['bytes']:
401
            raise ValueError("Invalid size")
402

    
403
        checksum = metadata.pop('checksum', meta['hash'])
404
        if checksum != meta['hash']:
405
            raise ValueError("Invalid checksum")
406

    
407
        # Fix permissions
408
        is_public = metadata.pop('is_public', False)
409
        if is_public:
410
            permissions = {'read': ['*']}
411
        else:
412
            permissions = {'read': [self.user]}
413

    
414
        # Extract the properties dictionary from metadata, and store each
415
        # property as a separeted, prefixed metadata
416
        properties = metadata.pop("properties", {})
417
        meta = dict([(PROPERTY_PREFIX + k, v) for k, v in properties.items()])
418
        # Add creation(register) timestamp as a metadata, to avoid extra
419
        # queries when retrieving the list of images.
420
        meta['created_at'] = time()
421
        # Update rest metadata
422
        meta.update(name=name, status='available', **metadata)
423

    
424
        # Do the actualy update in the Pithos backend
425
        self._update_meta(image_url, meta)
426
        self._update_permissions(image_url, permissions)
427
        logger.debug("User '%s' created image '%s'('%s')", self.user,
428
                     image_url, name)
429
        return self._get_image(image_url)
430

    
431
    def _list_images(self, user=None, filters=None, params=None):
432
        filters = filters or {}
433

    
434
        # TODO: Use filters
435
        # # Fix keys
436
        # keys = [PLANKTON_PREFIX + 'name']
437
        # size_range = (None, None)
438
        # for key, val in filters.items():
439
        #     if key == 'size_min':
440
        #         size_range = (val, size_range[1])
441
        #     elif key == 'size_max':
442
        #         size_range = (size_range[0], val)
443
        #     else:
444
        #         keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
445
        _images = self.backend.get_domain_objects(domain=PLANKTON_DOMAIN,
446
                                                  user=user)
447

    
448
        images = []
449
        for (location, meta, permissions) in _images:
450
            image_url = "pithos://" + location
451
            meta["modified"] = meta["version_timestamp"]
452
            images.append(image_to_dict(image_url, meta, permissions))
453

    
454
        if params is None:
455
            params = {}
456
        key = itemgetter(params.get('sort_key', 'created_at'))
457
        reverse = params.get('sort_dir', 'desc') == 'desc'
458
        images.sort(key=key, reverse=reverse)
459
        return images
460

    
461
    @commit_on_success
462
    def list_images(self, filters=None, params=None):
463
        return self._list_images(user=self.user, filters=filters,
464
                                 params=params)
465

    
466
    @commit_on_success
467
    def list_shared_images(self, member, filters=None, params=None):
468
        images = self._list_images(user=self.user, filters=filters,
469
                                   params=params)
470
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
471
        return filter(is_shared, images)
472

    
473
    @commit_on_success
474
    def list_public_images(self, filters=None, params=None):
475
        images = self._list_images(user=None, filters=filters, params=params)
476
        return filter(lambda img: img["is_public"], images)
477

    
478

    
479
class ImageBackendError(Exception):
480
    pass
481

    
482

    
483
class ImageNotFound(ImageBackendError):
484
    pass
485

    
486

    
487
class Forbidden(ImageBackendError):
488
    pass
489

    
490

    
491
class InvalidMetadata(ImageBackendError):
492
    pass
493

    
494

    
495
class InvalidLocation(ImageBackendError):
496
    pass
497

    
498

    
499
def image_to_dict(image_url, meta, permissions):
500
    """Render an image to a dictionary"""
501
    account, container, name = split_url(image_url)
502

    
503
    image = {}
504
    if PLANKTON_PREFIX + 'name' not in meta:
505
        logger.warning("Image without Plankton name!! url %s meta %s",
506
                       image_url, meta)
507
        image[PLANKTON_PREFIX + "name"] = ""
508

    
509
    image["id"] = meta["uuid"]
510
    image["location"] = image_url
511
    image["checksum"] = meta["hash"]
512
    created = meta.get("created_at", meta["modified"])
513
    image["created_at"] = format_timestamp(created)
514
    deleted = meta.get("deleted", None)
515
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
516
    image["updated_at"] = format_timestamp(meta["modified"])
517
    image["size"] = meta["bytes"]
518
    image["store"] = "pithos"
519
    image['owner'] = account
520

    
521
    # Permissions
522
    image["is_public"] = "*" in permissions.get('read', [])
523

    
524
    properties = {}
525
    for key, val in meta.items():
526
        # Get plankton properties
527
        if key.startswith(PLANKTON_PREFIX):
528
            # Remove plankton prefix
529
            key = key.replace(PLANKTON_PREFIX, "")
530
            # Keep only those in plankton meta
531
            if key in PLANKTON_META:
532
                if key != "created_at":
533
                    # created timestamp is return in 'created_at' field
534
                    image[key] = val
535
            elif key.startswith(PROPERTY_PREFIX):
536
                key = key.replace(PROPERTY_PREFIX, "")
537
                properties[key] = val
538
    image["properties"] = properties
539

    
540
    return image
541

    
542

    
543
class JSONFileBackend(object):
544
    """
545
    A dummy image backend that loads available images from a file with json
546
    formatted content.
547

548
    usage:
549
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
550
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
551

552
        # loading images from an existing plankton service
553
        $ curl -H "X-Auth-Token: <MYTOKEN>" \
554
                https://cyclades.synnefo.org/plankton/images/detail | \
555
                python -m json.tool > /tmp/images.json
556
    """
557
    def __init__(self, userid):
558
        self.images_file = getattr(settings,
559
                                   'PLANKTON_IMAGES_JSON_BACKEND_FILE', '')
560
        if not os.path.exists(self.images_file):
561
            raise Exception("Invalid plankgon images json backend file: %s",
562
                            self.images_file)
563
        fp = file(self.images_file)
564
        self.images = json.load(fp)
565
        fp.close()
566

    
567
    def iter(self, *args, **kwargs):
568
        return self.images.__iter__()
569

    
570
    def list_images(self, *args, **kwargs):
571
        return self.images
572

    
573
    def get_image(self, image_uuid):
574
        try:
575
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
576
        except IndexError:
577
            raise Exception("Unknown image uuid: %s" % image_uuid)
578

    
579
    def close(self):
580
        pass
581

    
582

    
583
def get_backend():
584
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
585
    if not backend_module:
586
        # no setting set
587
        return ImageBackend
588

    
589
    parts = backend_module.split(".")
590
    module = ".".join(parts[:-1])
591
    cls = parts[-1]
592
    try:
593
        return getattr(importlib.import_module(module), cls)
594
    except (ImportError, AttributeError), e:
595
        raise ImportError("Cannot import plankton module: %s (%s)" %
596
                          (backend_module, e.message))