Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (20 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

    
67
logger = logging.getLogger(__name__)
68

    
69

    
70
PLANKTON_DOMAIN = 'plankton'
71
PLANKTON_PREFIX = 'plankton:'
72
PROPERTY_PREFIX = 'property:'
73

    
74
PLANKTON_META = ('container_format', 'disk_format', 'name', 'properties',
75
                 'status', 'created_at')
76

    
77
from pithos.backends.util import PithosBackendPool
78
_pithos_backend_pool = \
79
    PithosBackendPool(
80
        settings.PITHOS_BACKEND_POOL_SIZE,
81
        astakos_auth_url=settings.ASTAKOS_AUTH_URL,
82
        service_token=settings.CYCLADES_SERVICE_TOKEN,
83
        astakosclient_poolsize=settings.CYCLADES_ASTAKOSCLIENT_POOLSIZE,
84
        db_connection=settings.BACKEND_DB_CONNECTION,
85
        block_path=settings.BACKEND_BLOCK_PATH)
86

    
87

    
88
def get_pithos_backend():
89
    return _pithos_backend_pool.pool_get()
90

    
91

    
92
def create_url(account, container, name):
93
    assert "/" not in account, "Invalid account"
94
    assert "/" not in container, "Invalid container"
95
    return "pithos://%s/%s/%s" % (account, container, name)
96

    
97

    
98
def split_url(url):
99
    """Returns (accout, container, object) from a url string"""
100
    t = url.split('/', 4)
101
    assert t[0] == "pithos:", "Invalid url"
102
    assert len(t) == 5, "Invalid url"
103
    return t[2:5]
104

    
105

    
106
def format_timestamp(t):
107
    return strftime('%Y-%m-%d %H:%M:%S', gmtime(t))
108

    
109

    
110
def handle_backend_exceptions(func):
111
    @wraps(func)
112
    def wrapper(*args, **kwargs):
113
        try:
114
            return func(*args, **kwargs)
115
        except NotAllowedError:
116
            raise Forbidden
117
        except NameError:
118
            raise ImageNotFound
119
        except VersionNotExists:
120
            raise ImageNotFound
121
    return wrapper
122

    
123

    
124
def commit_on_success(func):
125
    def wrapper(self, *args, **kwargs):
126
        backend = self.backend
127
        backend.pre_exec()
128
        try:
129
            ret = func(self, *args, **kwargs)
130
        except:
131
            backend.post_exec(False)
132
            raise
133
        else:
134
            backend.post_exec(True)
135
        return ret
136
    return wrapper
137

    
138

    
139
class ImageBackend(object):
140
    """A wrapper arround the pithos backend to simplify image handling."""
141

    
142
    def __init__(self, user):
143
        self.user = user
144

    
145
        original_filters = warnings.filters
146
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
147
        self.backend = get_pithos_backend()
148
        warnings.filters = original_filters     # Restore warnings
149

    
150
    def close(self):
151
        """Close PithosBackend(return to pool)"""
152
        self.backend.close()
153

    
154
    @handle_backend_exceptions
155
    @commit_on_success
156
    def get_image(self, image_uuid):
157
        """Retrieve information about an image."""
158
        image_url = self._get_image_url(image_uuid)
159
        return self._get_image(image_url)
160

    
161
    def _get_image_url(self, image_uuid):
162
        """Get the Pithos url that corresponds to an image UUID."""
163
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
164
        return create_url(account, container, name)
165

    
166
    def _get_image(self, image_url):
167
        """Get information about an Image.
168

169
        Get all available information about an Image.
170
        """
171
        account, container, name = split_url(image_url)
172
        try:
173
            meta = self._get_meta(image_url)
174
            meta["deleted"] = ""
175
        except NameError:
176
            versions = self.backend.list_versions(self.user, account,
177
                                                  container, name)
178
            if not versions:
179
                raise Exception("Image without versions %s" % image_url)
180
            # Object was deleted, use the latest version
181
            version, timestamp = versions[-1]
182
            meta = self._get_meta(image_url, version)
183
            meta["deleted"] = timestamp
184

    
185
        if PLANKTON_PREFIX + 'name' not in meta:
186
            logger.warning("Image without Plankton name! url %s meta %s",
187
                           image_url, meta)
188
            meta[PLANKTON_PREFIX + "name"] = ""
189

    
190
        permissions = self._get_permissions(image_url)
191
        return image_to_dict(image_url, meta, permissions)
192

    
193
    def _get_meta(self, image_url, version=None):
194
        """Get object's metadata."""
195
        account, container, name = split_url(image_url)
196
        return self.backend.get_object_meta(self.user, account, container,
197
                                            name, PLANKTON_DOMAIN, version)
198

    
199
    def _update_meta(self, image_url, meta, replace=False):
200
        """Update object's metadata."""
201
        account, container, name = split_url(image_url)
202

    
203
        prefixed = {}
204
        for key, val in meta.items():
205
            if key in PLANKTON_META:
206
                if key == "properties":
207
                    val = json.dumps(val)
208
                prefixed[PLANKTON_PREFIX + key] = val
209

    
210
        self.backend.update_object_meta(self.user, account, container, name,
211
                                        PLANKTON_DOMAIN, prefixed, replace)
212
        logger.debug("User '%s' updated image '%s', meta: '%s'", self.user,
213
                     image_url, prefixed)
214

    
215
    def _get_permissions(self, image_url):
216
        """Get object's permissions."""
217
        account, container, name = split_url(image_url)
218
        _a, path, permissions = \
219
            self.backend.get_object_permissions(self.user, account, container,
220
                                                name)
221

    
222
        if path is None and permissions != {}:
223
            logger.warning("Image '%s' got permissions '%s' from 'None' path.",
224
                           image_url, permissions)
225
            raise Exception("Database Inconsistency Error:"
226
                            " Image '%s' got permissions from 'None' path." %
227
                            image_url)
228

    
229
        return permissions
230

    
231
    def _update_permissions(self, image_url, permissions):
232
        """Update object's permissions."""
233
        account, container, name = split_url(image_url)
234
        self.backend.update_object_permissions(self.user, account, container,
235
                                               name, permissions)
236
        logger.debug("User '%s' updated image '%s', permissions: '%s'",
237
                     self.user, image_url, permissions)
238

    
239
    @handle_backend_exceptions
240
    @commit_on_success
241
    def unregister(self, image_uuid):
242
        """Unregister an image.
243

244
        Unregister an image, by removing all metadata from the Pithos
245
        file that exist in the PLANKTON_DOMAIN.
246

247
        """
248
        image_url = self._get_image_url(image_uuid)
249
        self._get_image(image_url)  # Assert that it is an image
250
        # Unregister the image by removing all metadata from domain
251
        # 'PLANKTON_DOMAIN'
252
        meta = {}
253
        self._update_meta(image_url, meta, True)
254
        logger.debug("User '%s' deleted image '%s'", self.user, image_url)
255

    
256
    @handle_backend_exceptions
257
    @commit_on_success
258
    def add_user(self, image_uuid, add_user):
259
        """Add a user as an image member.
260

261
        Update read permissions of Pithos file, to include the specified user.
262

263
        """
264
        image_url = self._get_image_url(image_uuid)
265
        self._get_image(image_url)  # Assert that it is an image
266
        permissions = self._get_permissions(image_url)
267
        read = set(permissions.get("read", []))
268
        assert(isinstance(add_user, (str, unicode)))
269
        read.add(add_user)
270
        permissions["read"] = list(read)
271
        self._update_permissions(image_url, permissions)
272

    
273
    @handle_backend_exceptions
274
    @commit_on_success
275
    def remove_user(self, image_uuid, remove_user):
276
        """Remove the user from image members.
277

278
        Remove the specified user from the read permissions of the Pithos file.
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(remove_user, (str, unicode)))
286
        try:
287
            read.remove(remove_user)
288
        except ValueError:
289
            return  # TODO: User did not have access
290
        permissions["read"] = list(read)
291
        self._update_permissions(image_url, permissions)
292

    
293
    @handle_backend_exceptions
294
    @commit_on_success
295
    def replace_users(self, image_uuid, replace_users):
296
        """Replace image members.
297

298
        Replace the read permissions of the Pithos files with the specified
299
        users. If image is specified as public, we must preserve * permission.
300

301
        """
302
        image_url = self._get_image_url(image_uuid)
303
        image = self._get_image(image_url)
304
        permissions = self._get_permissions(image_url)
305
        assert(isinstance(replace_users, list))
306
        permissions["read"] = replace_users
307
        if image.get("is_public", False):
308
            permissions["read"].append("*")
309
        self._update_permissions(image_url, permissions)
310

    
311
    @handle_backend_exceptions
312
    @commit_on_success
313
    def list_users(self, image_uuid):
314
        """List the image members.
315

316
        List the image members, by listing all users that have read permission
317
        to the corresponding Pithos file.
318

319
        """
320
        image_url = self._get_image_url(image_uuid)
321
        self._get_image(image_url)  # Assert that it is an image
322
        permissions = self._get_permissions(image_url)
323
        return [user for user in permissions.get('read', []) if user != '*']
324

    
325
    @handle_backend_exceptions
326
    @commit_on_success
327
    def update_metadata(self, image_uuid, metadata):
328
        """Update Image metadata."""
329
        image_url = self._get_image_url(image_uuid)
330
        self._get_image(image_url)  # Assert that it is an image
331

    
332
        is_public = metadata.pop("is_public", None)
333
        if is_public is not None:
334
            permissions = self._get_permissions(image_url)
335
            read = set(permissions.get("read", []))
336
            if is_public:
337
                read.add("*")
338
            else:
339
                read.discard("*")
340
            permissions["read"] = list(read)
341
            self._update_permissions(image_url, permissions)
342
        meta = {}
343
        meta["properties"] = metadata.pop("properties", {})
344
        meta.update(**metadata)
345

    
346
        self._update_meta(image_url, meta)
347
        image_url = self._get_image_url(image_uuid)
348
        return self._get_image(image_url)
349

    
350
    @handle_backend_exceptions
351
    @commit_on_success
352
    def register(self, name, image_url, metadata):
353
        # Validate that metadata are allowed
354
        if "id" in metadata:
355
            raise ValueError("Passing an ID is not supported")
356
        store = metadata.pop("store", "pithos")
357
        if store != "pithos":
358
            raise ValueError("Invalid store '%s'. Only 'pithos' store is"
359
                             "supported" % store)
360
        disk_format = metadata.setdefault("disk_format",
361
                                          settings.DEFAULT_DISK_FORMAT)
362
        if disk_format not in settings.ALLOWED_DISK_FORMATS:
363
            raise ValueError("Invalid disk format '%s'" % disk_format)
364
        container_format =\
365
            metadata.setdefault("container_format",
366
                                settings.DEFAULT_CONTAINER_FORMAT)
367
        if container_format not in settings.ALLOWED_CONTAINER_FORMATS:
368
            raise ValueError("Invalid container format '%s'" %
369
                             container_format)
370

    
371
        # Validate that 'size' and 'checksum' are valid
372
        account, container, object = split_url(image_url)
373

    
374
        meta = self._get_meta(image_url)
375

    
376
        size = int(metadata.pop('size', meta['bytes']))
377
        if size != meta['bytes']:
378
            raise ValueError("Invalid size")
379

    
380
        checksum = metadata.pop('checksum', meta['hash'])
381
        if checksum != meta['hash']:
382
            raise ValueError("Invalid checksum")
383

    
384
        # Fix permissions
385
        is_public = metadata.pop('is_public', False)
386
        if is_public:
387
            permissions = {'read': ['*']}
388
        else:
389
            permissions = {'read': [self.user]}
390

    
391
        # Update rest metadata
392
        meta = {}
393
        meta['properties'] = metadata.pop('properties', {})
394
        # Add creation(register) timestamp as a metadata, to avoid extra
395
        # queries when retrieving the list of images.
396
        meta['created_at'] = time()
397
        meta.update(name=name, status='available', **metadata)
398

    
399
        # Do the actualy update in the Pithos backend
400
        self._update_meta(image_url, meta)
401
        self._update_permissions(image_url, permissions)
402
        logger.debug("User '%s' created image '%s'('%s')", self.user,
403
                     image_url, name)
404
        return self._get_image(image_url)
405

    
406
    def _list_images(self, user=None, filters=None, params=None):
407
        filters = filters or {}
408

    
409
        # TODO: Use filters
410
        # # Fix keys
411
        # keys = [PLANKTON_PREFIX + 'name']
412
        # size_range = (None, None)
413
        # for key, val in filters.items():
414
        #     if key == 'size_min':
415
        #         size_range = (val, size_range[1])
416
        #     elif key == 'size_max':
417
        #         size_range = (size_range[0], val)
418
        #     else:
419
        #         keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
420
        _images = self.backend.get_domain_objects(domain=PLANKTON_DOMAIN,
421
                                                  user=user)
422

    
423
        images = []
424
        for (location, meta, permissions) in _images:
425
            image_url = "pithos://" + location
426
            meta["modified"] = meta["version_timestamp"]
427
            images.append(image_to_dict(image_url, meta, permissions))
428

    
429
        if params is None:
430
            params = {}
431
        key = itemgetter(params.get('sort_key', 'created_at'))
432
        reverse = params.get('sort_dir', 'desc') == 'desc'
433
        images.sort(key=key, reverse=reverse)
434
        return images
435

    
436
    @commit_on_success
437
    def list_images(self, filters=None, params=None):
438
        return self._list_images(user=self.user, filters=filters,
439
                                 params=params)
440

    
441
    @commit_on_success
442
    def list_shared_images(self, member, filters=None, params=None):
443
        images = self._list_images(user=self.user, filters=filters,
444
                                   params=params)
445
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
446
        return filter(is_shared, images)
447

    
448
    @commit_on_success
449
    def list_public_images(self, filters=None, params=None):
450
        images = self._list_images(user=None, filters=filters, params=params)
451
        return filter(lambda img: img["is_public"], images)
452

    
453

    
454
class ImageBackendError(Exception):
455
    pass
456

    
457

    
458
class ImageNotFound(ImageBackendError):
459
    pass
460

    
461

    
462
class Forbidden(ImageBackendError):
463
    pass
464

    
465

    
466
def image_to_dict(image_url, meta, permissions):
467
    """Render an image to a dictionary"""
468
    account, container, name = split_url(image_url)
469

    
470
    image = {}
471
    if PLANKTON_PREFIX + 'name' not in meta:
472
        logger.warning("Image without Plankton name!! url %s meta %s",
473
                       image_url, meta)
474
        image[PLANKTON_PREFIX + "name"] = ""
475

    
476
    image["id"] = meta["uuid"]
477
    image["location"] = image_url
478
    image["checksum"] = meta["hash"]
479
    created = meta.get("created_at", meta["modified"])
480
    image["created_at"] = format_timestamp(created)
481
    deleted = meta.get("deleted", None)
482
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
483
    image["updated_at"] = format_timestamp(meta["modified"])
484
    image["size"] = meta["bytes"]
485
    image["store"] = "pithos"
486
    image['owner'] = account
487

    
488
    # Permissions
489
    image["is_public"] = "*" in permissions.get('read', [])
490

    
491
    for key, val in meta.items():
492
        # Get plankton properties
493
        if key.startswith(PLANKTON_PREFIX):
494
            # Remove plankton prefix
495
            key = key.replace(PLANKTON_PREFIX, "")
496
            # Keep only those in plankton meta
497
            if key in PLANKTON_META:
498
                if key == "properties":
499
                    image[key] = json.loads(val)
500
                elif key == "created_at":
501
                    # created timestamp is return in 'created_at' field
502
                    pass
503
                else:
504
                    image[key] = val
505

    
506
    return image
507

    
508

    
509
class JSONFileBackend(object):
510
    """
511
    A dummy image backend that loads available images from a file with json
512
    formatted content.
513

514
    usage:
515
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
516
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
517

518
        # loading images from an existing plankton service
519
        $ curl -H "X-Auth-Token: <MYTOKEN>" \
520
                https://cyclades.synnefo.org/plankton/images/detail | \
521
                python -m json.tool > /tmp/images.json
522
    """
523
    def __init__(self, userid):
524
        self.images_file = getattr(settings,
525
                                   'PLANKTON_IMAGES_JSON_BACKEND_FILE', '')
526
        if not os.path.exists(self.images_file):
527
            raise Exception("Invalid plankgon images json backend file: %s",
528
                            self.images_file)
529
        fp = file(self.images_file)
530
        self.images = json.load(fp)
531
        fp.close()
532

    
533
    def iter(self, *args, **kwargs):
534
        return self.images.__iter__()
535

    
536
    def list_images(self, *args, **kwargs):
537
        return self.images
538

    
539
    def get_image(self, image_uuid):
540
        try:
541
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
542
        except IndexError:
543
            raise Exception("Unknown image uuid: %s" % image_uuid)
544

    
545
    def close(self):
546
        pass
547

    
548

    
549
def get_backend():
550
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
551
    if not backend_module:
552
        # no setting set
553
        return ImageBackend
554

    
555
    parts = backend_module.split(".")
556
    module = ".".join(parts[:-1])
557
    cls = parts[-1]
558
    try:
559
        return getattr(importlib.import_module(module), cls)
560
    except (ImportError, AttributeError), e:
561
        raise ImportError("Cannot import plankton module: %s (%s)" %
562
                          (backend_module, e.message))