Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (22.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
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', 'volume_id', 'description')
78

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

    
82

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

    
93

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

    
97

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

    
103

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

    
115

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

    
119

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

    
133

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

    
148

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

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

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

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

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

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

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

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

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

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

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

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

    
215
        prefixed = [(PLANKTON_PREFIX + uenc(k), uenc(v))
216
                    for k, v in meta.items()
217
                    if k in PLANKTON_META or k.startswith(PROPERTY_PREFIX)]
218
        prefixed = dict(prefixed)
219

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

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

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

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

    
247
        return permissions
248

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
373
    def update_status(self, image_uuid, status):
374
        """Update Image metadata."""
375
        image_url = self._get_image_url(image_uuid)
376
        self._get_image(image_url)  # Assert that it is an image
377

    
378
        self._update_meta(image_url, {"status": status.upper()})
379
        image_url = self._get_image_url(image_uuid)
380
        return self._get_image(image_url)
381

    
382
    @handle_backend_exceptions
383
    @commit_on_success
384
    def register(self, name, image_url, metadata):
385
        # Validate that metadata are allowed
386
        if "id" in metadata:
387
            raise ValueError("Passing an ID is not supported")
388
        store = metadata.pop("store", "pithos")
389
        if store != "pithos":
390
            raise ValueError("Invalid store '%s'. Only 'pithos' store is"
391
                             "supported" % store)
392
        disk_format = metadata.setdefault("disk_format",
393
                                          settings.DEFAULT_DISK_FORMAT)
394
        if disk_format not in settings.ALLOWED_DISK_FORMATS:
395
            raise ValueError("Invalid disk format '%s'" % disk_format)
396
        container_format =\
397
            metadata.setdefault("container_format",
398
                                settings.DEFAULT_CONTAINER_FORMAT)
399
        if container_format not in settings.ALLOWED_CONTAINER_FORMATS:
400
            raise ValueError("Invalid container format '%s'" %
401
                             container_format)
402

    
403
        # Validate that 'size' and 'checksum' are valid
404
        account, container, object = split_url(image_url)
405

    
406
        meta = self._get_meta(image_url)
407

    
408
        size = int(metadata.pop('size', meta['bytes']))
409
        if size != meta['bytes']:
410
            raise ValueError("Invalid size")
411

    
412
        checksum = metadata.pop('checksum', meta['hash'])
413
        if checksum != meta['hash']:
414
            raise ValueError("Invalid checksum")
415

    
416
        # Fix permissions
417
        is_public = metadata.pop('is_public', False)
418
        if is_public:
419
            permissions = {'read': ['*']}
420
        else:
421
            permissions = {'read': [self.user]}
422

    
423
        # Extract the properties dictionary from metadata, and store each
424
        # property as a separeted, prefixed metadata
425
        properties = metadata.pop("properties", {})
426
        meta = dict([(PROPERTY_PREFIX + k, v) for k, v in properties.items()])
427
        # Add creation(register) timestamp as a metadata, to avoid extra
428
        # queries when retrieving the list of images.
429
        meta['created_at'] = time()
430
        # Update rest metadata
431
        meta.update(name=name, status='AVAILABLE', **metadata)
432

    
433
        # Do the actualy update in the Pithos backend
434
        self._update_meta(image_url, meta)
435
        self._update_permissions(image_url, permissions)
436
        logger.debug("User '%s' created image '%s'('%s')", self.user,
437
                     image_url, name)
438
        return self._get_image(image_url)
439

    
440
    def list_snapshots(self, user=None):
441
        _snapshots = self.list_images()
442
        return [s for s in _snapshots
443
                if s["is_snapshot"]]
444

    
445
    @handle_backend_exceptions
446
    def get_snapshot(self, user, snapshot_uuid):
447
        snap = self.get_image(snapshot_uuid)
448
        if snap["is_snapshot"] is False:
449
            raise ImageNotFound
450

    
451
    @handle_backend_exceptions
452
    def delete_snapshot(self, snapshot_uuid):
453
        self.backend.delete_object_for_uuid(self.user, snapshot_uuid)
454

    
455
    @handle_backend_exceptions
456
    def update_snapshot_status(self):
457
        pass
458

    
459
    def _list_images(self, user=None, filters=None, params=None):
460
        filters = filters or {}
461

    
462
        # TODO: Use filters
463
        # # Fix keys
464
        # keys = [PLANKTON_PREFIX + 'name']
465
        # size_range = (None, None)
466
        # for key, val in filters.items():
467
        #     if key == 'size_min':
468
        #         size_range = (val, size_range[1])
469
        #     elif key == 'size_max':
470
        #         size_range = (size_range[0], val)
471
        #     else:
472
        #         keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
473
        _images = self.backend.get_domain_objects(domain=PLANKTON_DOMAIN,
474
                                                  user=user)
475

    
476
        images = []
477
        for (location, meta, permissions) in _images:
478
            image_url = "pithos://" + location
479
            meta["modified"] = meta["version_timestamp"]
480
            images.append(image_to_dict(image_url, meta, permissions))
481

    
482
        if params is None:
483
            params = {}
484
        key = itemgetter(params.get('sort_key', 'created_at'))
485
        reverse = params.get('sort_dir', 'desc') == 'desc'
486
        images.sort(key=key, reverse=reverse)
487
        return images
488

    
489
    @commit_on_success
490
    def list_images(self, filters=None, params=None):
491
        return self._list_images(user=self.user, filters=filters,
492
                                 params=params)
493

    
494
    @commit_on_success
495
    def list_shared_images(self, member, filters=None, params=None):
496
        images = self._list_images(user=self.user, filters=filters,
497
                                   params=params)
498
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
499
        return filter(is_shared, images)
500

    
501
    @commit_on_success
502
    def list_public_images(self, filters=None, params=None):
503
        images = self._list_images(user=None, filters=filters, params=params)
504
        return filter(lambda img: img["is_public"], images)
505

    
506

    
507
class ImageBackendError(Exception):
508
    pass
509

    
510

    
511
class ImageNotFound(ImageBackendError):
512
    pass
513

    
514

    
515
class Forbidden(ImageBackendError):
516
    pass
517

    
518

    
519
class InvalidMetadata(ImageBackendError):
520
    pass
521

    
522

    
523
class InvalidLocation(ImageBackendError):
524
    pass
525

    
526

    
527
def image_to_dict(image_url, meta, permissions):
528
    """Render an image to a dictionary"""
529
    account, container, name = split_url(image_url)
530

    
531
    image = {}
532
    if PLANKTON_PREFIX + 'name' not in meta:
533
        logger.warning("Image without Plankton name!! url %s meta %s",
534
                       image_url, meta)
535
        image[PLANKTON_PREFIX + "name"] = ""
536

    
537
    image["id"] = meta["uuid"]
538
    image["location"] = image_url
539
    image["mapfile"] = meta["hash"]
540
    created = meta.get("created_at", meta["modified"])
541
    image["created_at"] = format_timestamp(created)
542
    deleted = meta.get("deleted", None)
543
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
544
    image["updated_at"] = format_timestamp(meta["modified"])
545
    image["size"] = meta["bytes"]
546
    image["store"] = "pithos"
547
    image['owner'] = account
548

    
549
    # Distinquish between images and snapshots
550
    image["is_snapshot"] = (PLANKTON_PREFIX + "is_snapshot") in meta
551

    
552
    # Permissions
553
    image["is_public"] = "*" in permissions.get('read', [])
554

    
555
    properties = {}
556
    for key, val in meta.items():
557
        # Get plankton properties
558
        if key.startswith(PLANKTON_PREFIX):
559
            # Remove plankton prefix
560
            key = key.replace(PLANKTON_PREFIX, "")
561
            # Keep only those in plankton meta
562
            if key in PLANKTON_META:
563
                if key == "status":
564
                    image["status"] = val.upper()
565
                if key != "created_at":
566
                    # created timestamp is return in 'created_at' field
567
                    image[key] = val
568
            elif key.startswith(PROPERTY_PREFIX):
569
                key = key.replace(PROPERTY_PREFIX, "")
570
                properties[key] = val
571
    image["properties"] = properties
572

    
573
    return image
574

    
575

    
576
class JSONFileBackend(object):
577
    """
578
    A dummy image backend that loads available images from a file with json
579
    formatted content.
580

581
    usage:
582
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
583
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
584

585
        # loading images from an existing plankton service
586
        $ curl -H "X-Auth-Token: <MYTOKEN>" \
587
                https://cyclades.synnefo.org/plankton/images/detail | \
588
                python -m json.tool > /tmp/images.json
589
    """
590
    def __init__(self, userid):
591
        self.images_file = getattr(settings,
592
                                   'PLANKTON_IMAGES_JSON_BACKEND_FILE', '')
593
        if not os.path.exists(self.images_file):
594
            raise Exception("Invalid plankgon images json backend file: %s",
595
                            self.images_file)
596
        fp = file(self.images_file)
597
        self.images = json.load(fp)
598
        fp.close()
599

    
600
    def iter(self, *args, **kwargs):
601
        return self.images.__iter__()
602

    
603
    def list_images(self, *args, **kwargs):
604
        return self.images
605

    
606
    def get_image(self, image_uuid):
607
        try:
608
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
609
        except IndexError:
610
            raise Exception("Unknown image uuid: %s" % image_uuid)
611

    
612
    def close(self):
613
        pass
614

    
615

    
616
def get_backend():
617
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
618
    if not backend_module:
619
        # no setting set
620
        return ImageBackend
621

    
622
    parts = backend_module.split(".")
623
    module = ".".join(parts[:-1])
624
    cls = parts[-1]
625
    try:
626
        return getattr(importlib.import_module(module), cls)
627
    except (ImportError, AttributeError), e:
628
        raise ImportError("Cannot import plankton module: %s (%s)" %
629
                          (backend_module, e.message))