Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (19.7 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
  - property_*: 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
        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 or key.startswith(PROPERTY_PREFIX):
206
                prefixed[PLANKTON_PREFIX + key] = val
207

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

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

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

    
227
        return permissions
228

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
343
        self._update_meta(image_url, meta)
344
        image_url = self._get_image_url(image_uuid)
345
        return self._get_image(image_url)
346

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

    
368
        # Validate that 'size' and 'checksum' are valid
369
        account, container, object = split_url(image_url)
370

    
371
        meta = self._get_meta(image_url)
372

    
373
        size = int(metadata.pop('size', meta['bytes']))
374
        if size != meta['bytes']:
375
            raise ValueError("Invalid size")
376

    
377
        checksum = metadata.pop('checksum', meta['hash'])
378
        if checksum != meta['hash']:
379
            raise ValueError("Invalid checksum")
380

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

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

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

    
402
    def _list_images(self, user=None, filters=None, params=None):
403
        filters = filters or {}
404

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

    
419
        images = []
420
        for (location, meta, permissions) in _images:
421
            image_url = "pithos://" + location
422
            meta["modified"] = meta["version_timestamp"]
423
            images.append(image_to_dict(image_url, meta, permissions))
424

    
425
        if params is None:
426
            params = {}
427
        key = itemgetter(params.get('sort_key', 'created_at'))
428
        reverse = params.get('sort_dir', 'desc') == 'desc'
429
        images.sort(key=key, reverse=reverse)
430
        return images
431

    
432
    @commit_on_success
433
    def list_images(self, filters=None, params=None):
434
        return self._list_images(user=self.user, filters=filters,
435
                                 params=params)
436

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

    
444
    @commit_on_success
445
    def list_public_images(self, filters=None, params=None):
446
        images = self._list_images(user=None, filters=filters, params=params)
447
        return filter(lambda img: img["is_public"], images)
448

    
449

    
450
class ImageBackendError(Exception):
451
    pass
452

    
453

    
454
class ImageNotFound(ImageBackendError):
455
    pass
456

    
457

    
458
class Forbidden(ImageBackendError):
459
    pass
460

    
461

    
462
def image_to_dict(image_url, meta, permissions):
463
    """Render an image to a dictionary"""
464
    account, container, name = split_url(image_url)
465

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

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

    
484
    # Permissions
485
    image["is_public"] = "*" in permissions.get('read', [])
486

    
487
    for key, val in meta.items():
488
        # Get plankton properties
489
        if key.startswith(PLANKTON_PREFIX):
490
            # Remove plankton prefix
491
            key = key.replace(PLANKTON_PREFIX, "")
492
            # Keep only those in plankton meta
493
            if key in PLANKTON_META or key.startswith(PROPERTY_PREFIX):
494
                if key == "created_at":
495
                    # created timestamp is return in 'created_at' field
496
                    pass
497
                else:
498
                    image[key] = val
499

    
500
    return image
501

    
502

    
503
class JSONFileBackend(object):
504
    """
505
    A dummy image backend that loads available images from a file with json
506
    formatted content.
507

508
    usage:
509
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
510
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
511

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

    
527
    def iter(self, *args, **kwargs):
528
        return self.images.__iter__()
529

    
530
    def list_images(self, *args, **kwargs):
531
        return self.images
532

    
533
    def get_image(self, image_uuid):
534
        try:
535
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
536
        except IndexError:
537
            raise Exception("Unknown image uuid: %s" % image_uuid)
538

    
539
    def close(self):
540
        pass
541

    
542

    
543
def get_backend():
544
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
545
    if not backend_module:
546
        # no setting set
547
        return ImageBackend
548

    
549
    parts = backend_module.split(".")
550
    module = ".".join(parts[:-1])
551
    cls = parts[-1]
552
    try:
553
        return getattr(importlib.import_module(module), cls)
554
    except (ImportError, AttributeError), e:
555
        raise ImportError("Cannot import plankton module: %s (%s)" %
556
                          (backend_module, e.message))