Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (20.6 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',
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
        # XXX: Check that an object is a plankton image! PithosBackend will
186
        # return common metadata for an object, even if it has no metadata in
187
        # plankton domain. All images must have a name, so we check if a file
188
        # is an image by checking if they are having an image name.
189
        if PLANKTON_PREFIX + 'name' not in meta:
190
            raise ImageNotFound
191

    
192
        permissions = self._get_permissions(image_url)
193
        return image_to_dict(image_url, meta, permissions)
194

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

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

    
205
        prefixed = [(PLANKTON_PREFIX + k, v) for k, v in meta.items()
206
                    if k in PLANKTON_META or k.startswith(PROPERTY_PREFIX)]
207
        prefixed = dict(prefixed)
208

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

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

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

    
228
        return permissions
229

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

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

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

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

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

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

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

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

277
        Remove the specified user from the read permissions of the Pithos file.
278

279
        """
280
        image_url = self._get_image_url(image_uuid)
281
        self._get_image(image_url)  # Assert that it is an image
282
        permissions = self._get_permissions(image_url)
283
        read = set(permissions.get("read", []))
284
        assert(isinstance(remove_user, (str, unicode)))
285
        try:
286
            read.remove(remove_user)
287
        except ValueError:
288
            return  # TODO: User did not have access
289
        permissions["read"] = list(read)
290
        self._update_permissions(image_url, permissions)
291

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

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

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

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

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

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

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

    
331
        # 'is_public' metadata is translated in proper file permissions
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

    
343
        # Extract the properties dictionary from metadata, and store each
344
        # property as a separeted, prefixed metadata
345
        properties = metadata.pop("properties", {})
346
        meta = dict([(PROPERTY_PREFIX + k, v) for k, v in properties.items()])
347
        # Also add the following metadata
348
        meta.update(**metadata)
349

    
350
        self._update_meta(image_url, meta)
351
        image_url = self._get_image_url(image_uuid)
352
        return self._get_image(image_url)
353

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

    
375
        # Validate that 'size' and 'checksum' are valid
376
        account, container, object = split_url(image_url)
377

    
378
        meta = self._get_meta(image_url)
379

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

    
384
        checksum = metadata.pop('checksum', meta['hash'])
385
        if checksum != meta['hash']:
386
            raise ValueError("Invalid checksum")
387

    
388
        # Fix permissions
389
        is_public = metadata.pop('is_public', False)
390
        if is_public:
391
            permissions = {'read': ['*']}
392
        else:
393
            permissions = {'read': [self.user]}
394

    
395
        # Extract the properties dictionary from metadata, and store each
396
        # property as a separeted, prefixed metadata
397
        properties = metadata.pop("properties", {})
398
        meta = dict([(PROPERTY_PREFIX + k, v) for k, v in properties.items()])
399
        # Add creation(register) timestamp as a metadata, to avoid extra
400
        # queries when retrieving the list of images.
401
        meta['created_at'] = time()
402
        # Update rest metadata
403
        meta.update(name=name, status='available', **metadata)
404

    
405
        # Do the actualy update in the Pithos backend
406
        self._update_meta(image_url, meta)
407
        self._update_permissions(image_url, permissions)
408
        logger.debug("User '%s' created image '%s'('%s')", self.user,
409
                     image_url, name)
410
        return self._get_image(image_url)
411

    
412
    def _list_images(self, user=None, filters=None, params=None):
413
        filters = filters or {}
414

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

    
429
        images = []
430
        for (location, meta, permissions) in _images:
431
            image_url = "pithos://" + location
432
            meta["modified"] = meta["version_timestamp"]
433
            images.append(image_to_dict(image_url, meta, permissions))
434

    
435
        if params is None:
436
            params = {}
437
        key = itemgetter(params.get('sort_key', 'created_at'))
438
        reverse = params.get('sort_dir', 'desc') == 'desc'
439
        images.sort(key=key, reverse=reverse)
440
        return images
441

    
442
    @commit_on_success
443
    def list_images(self, filters=None, params=None):
444
        return self._list_images(user=self.user, filters=filters,
445
                                 params=params)
446

    
447
    @commit_on_success
448
    def list_shared_images(self, member, filters=None, params=None):
449
        images = self._list_images(user=self.user, filters=filters,
450
                                   params=params)
451
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
452
        return filter(is_shared, images)
453

    
454
    @commit_on_success
455
    def list_public_images(self, filters=None, params=None):
456
        images = self._list_images(user=None, filters=filters, params=params)
457
        return filter(lambda img: img["is_public"], images)
458

    
459

    
460
class ImageBackendError(Exception):
461
    pass
462

    
463

    
464
class ImageNotFound(ImageBackendError):
465
    pass
466

    
467

    
468
class Forbidden(ImageBackendError):
469
    pass
470

    
471

    
472
def image_to_dict(image_url, meta, permissions):
473
    """Render an image to a dictionary"""
474
    account, container, name = split_url(image_url)
475

    
476
    image = {}
477
    if PLANKTON_PREFIX + 'name' not in meta:
478
        logger.warning("Image without Plankton name!! url %s meta %s",
479
                       image_url, meta)
480
        image[PLANKTON_PREFIX + "name"] = ""
481

    
482
    image["id"] = meta["uuid"]
483
    image["location"] = image_url
484
    image["checksum"] = meta["hash"]
485
    created = meta.get("created_at", meta["modified"])
486
    image["created_at"] = format_timestamp(created)
487
    deleted = meta.get("deleted", None)
488
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
489
    image["updated_at"] = format_timestamp(meta["modified"])
490
    image["size"] = meta["bytes"]
491
    image["store"] = "pithos"
492
    image['owner'] = account
493

    
494
    # Permissions
495
    image["is_public"] = "*" in permissions.get('read', [])
496

    
497
    properties = {}
498
    for key, val in meta.items():
499
        # Get plankton properties
500
        if key.startswith(PLANKTON_PREFIX):
501
            # Remove plankton prefix
502
            key = key.replace(PLANKTON_PREFIX, "")
503
            # Keep only those in plankton meta
504
            if key in PLANKTON_META:
505
                if key != "created_at":
506
                    # created timestamp is return in 'created_at' field
507
                    image[key] = val
508
            elif key.startswith(PROPERTY_PREFIX):
509
                key = key.replace(PROPERTY_PREFIX, "")
510
                properties[key] = val
511
    image["properties"] = properties
512

    
513
    return image
514

    
515

    
516
class JSONFileBackend(object):
517
    """
518
    A dummy image backend that loads available images from a file with json
519
    formatted content.
520

521
    usage:
522
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
523
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
524

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

    
540
    def iter(self, *args, **kwargs):
541
        return self.images.__iter__()
542

    
543
    def list_images(self, *args, **kwargs):
544
        return self.images
545

    
546
    def get_image(self, image_uuid):
547
        try:
548
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
549
        except IndexError:
550
            raise Exception("Unknown image uuid: %s" % image_uuid)
551

    
552
    def close(self):
553
        pass
554

    
555

    
556
def get_backend():
557
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
558
    if not backend_module:
559
        # no setting set
560
        return ImageBackend
561

    
562
    parts = backend_module.split(".")
563
    module = ".".join(parts[:-1])
564
    cls = parts[-1]
565
    try:
566
        return getattr(importlib.import_module(module), cls)
567
    except (ImportError, AttributeError), e:
568
        raise ImportError("Cannot import plankton module: %s (%s)" %
569
                          (backend_module, e.message))