Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (22.4 kB)

1
# Copyright 2011-2013 GRNET S.A. All rights reserved.
2

    
3
#
4
# Redistribution and use in source and binary forms, with or
5
# without modification, are permitted provided that the following
6
# conditions are met:
7
#
8
#   1. Redistributions of source code must retain the above
9
#      copyright notice, this list of conditions and the following
10
#      disclaimer.
11
#
12
#   2. Redistributions in binary form must reproduce the above
13
#      copyright notice, this list of conditions and the following
14
#      disclaimer in the documentation and/or other materials
15
#      provided with the distribution.
16
#
17
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
18
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
20
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
21
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
24
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
25
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
26
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
27
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28
# POSSIBILITY OF SUCH DAMAGE.
29
#
30
# The views and conclusions contained in the software and
31
# documentation are those of the authors and should not be
32
# interpreted as representing official policies, either expressed
33
# or implied, of GRNET S.A.
34

    
35
"""
36
The Plankton attributes are the following:
37
  - checksum: the 'hash' meta
38
  - container_format: stored as a user meta
39
  - created_at: the 'modified' meta of the first version
40
  - deleted_at: the timestamp of the last version
41
  - disk_format: stored as a user meta
42
  - id: the 'uuid' meta
43
  - is_public: True if there is a * entry for the read permission
44
  - location: generated based on the file's path
45
  - name: stored as a user meta
46
  - owner: the file's account
47
  - properties: stored as user meta prefixed with PROPERTY_PREFIX
48
  - size: the 'bytes' meta
49
  - status: stored as a system meta
50
  - store: is always 'pithos'
51
  - updated_at: the 'modified' meta
52
"""
53

    
54
import json
55
import warnings
56
import logging
57
import os
58

    
59
from time import time, gmtime, strftime
60
from functools import wraps
61
from operator import itemgetter
62

    
63
from django.conf import settings
64
from django.utils import importlib
65
from pithos.backends.base import NotAllowedError, VersionNotExists
66
from synnefo.util.text import uenc
67

    
68

    
69
logger = logging.getLogger(__name__)
70

    
71

    
72
PLANKTON_DOMAIN = 'plankton'
73
PLANKTON_PREFIX = 'plankton:'
74
PROPERTY_PREFIX = 'property:'
75

    
76
PLANKTON_META = ('container_format', 'disk_format', 'name',
77
                 'status', 'created_at', '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})
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
        return self.get_image(snapshot_uuid)
448

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

    
453
    @handle_backend_exceptions
454
    def update_snapshot_status(self):
455
        pass
456

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

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

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

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

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

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

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

    
504

    
505
class ImageBackendError(Exception):
506
    pass
507

    
508

    
509
class ImageNotFound(ImageBackendError):
510
    pass
511

    
512

    
513
class Forbidden(ImageBackendError):
514
    pass
515

    
516

    
517
class InvalidMetadata(ImageBackendError):
518
    pass
519

    
520

    
521
class InvalidLocation(ImageBackendError):
522
    pass
523

    
524

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

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

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

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

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

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

    
569
    return image
570

    
571

    
572
class JSONFileBackend(object):
573
    """
574
    A dummy image backend that loads available images from a file with json
575
    formatted content.
576

577
    usage:
578
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
579
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
580

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

    
596
    def iter(self, *args, **kwargs):
597
        return self.images.__iter__()
598

    
599
    def list_images(self, *args, **kwargs):
600
        return self.images
601

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

    
608
    def close(self):
609
        pass
610

    
611

    
612
def get_backend():
613
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
614
    if not backend_module:
615
        # no setting set
616
        return ImageBackend
617

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