Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (21.4 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 pithos.backends.base import NotAllowedError, VersionNotExists
66
from synnefo.util.text import uenc
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 + uenc(k), uenc(v))
215
                    for k, v in meta.items()
216
                    if k in PLANKTON_META or k.startswith(PROPERTY_PREFIX)]
217
        prefixed = dict(prefixed)
218

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

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

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

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

    
246
        return permissions
247

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
396
        meta = self._get_meta(image_url)
397

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

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

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

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

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

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

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

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

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

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

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

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

    
477

    
478
class ImageBackendError(Exception):
479
    pass
480

    
481

    
482
class ImageNotFound(ImageBackendError):
483
    pass
484

    
485

    
486
class Forbidden(ImageBackendError):
487
    pass
488

    
489

    
490
class InvalidMetadata(ImageBackendError):
491
    pass
492

    
493

    
494
class InvalidLocation(ImageBackendError):
495
    pass
496

    
497

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

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

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

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

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

    
539
    return image
540

    
541

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

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

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

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

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

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

    
578
    def close(self):
579
        pass
580

    
581

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

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