Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (22.3 kB)

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

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

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

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

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

    
63
from django.conf import settings
64
from django.utils import importlib
65
from pithos.backends.base import NotAllowedError, VersionNotExists
66

    
67
logger = logging.getLogger(__name__)
68

    
69

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

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

    
77
# TODO: Change domain!
78
SNAPSHOTS_DOMAIN = PLANKTON_DOMAIN
79
SNAPSHOTS_PREFIX = PLANKTON_PREFIX
80

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

    
91

    
92
def get_pithos_backend():
93
    return _pithos_backend_pool.pool_get()
94

    
95

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

    
101

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

    
109

    
110
def format_timestamp(t):
111
    return strftime('%Y-%m-%d %H:%M:%S', gmtime(t))
112

    
113

    
114
def handle_backend_exceptions(func):
115
    @wraps(func)
116
    def wrapper(*args, **kwargs):
117
        try:
118
            return func(*args, **kwargs)
119
        except NotAllowedError:
120
            raise Forbidden
121
        except NameError:
122
            raise ImageNotFound
123
        except VersionNotExists:
124
            raise ImageNotFound
125
    return wrapper
126

    
127

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

    
142

    
143
class ImageBackend(object):
144
    """A wrapper arround the pithos backend to simplify image handling."""
145

    
146
    def __init__(self, user):
147
        self.user = user
148

    
149
        original_filters = warnings.filters
150
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
151
        self.backend = get_pithos_backend()
152
        warnings.filters = original_filters     # Restore warnings
153

    
154
    def close(self):
155
        """Close PithosBackend(return to pool)"""
156
        self.backend.close()
157

    
158
    @handle_backend_exceptions
159
    @commit_on_success
160
    def get_image(self, image_uuid):
161
        """Retrieve information about an image."""
162
        image_url = self._get_image_url(image_uuid)
163
        return self._get_image(image_url)
164

    
165
    def _get_image_url(self, image_uuid):
166
        """Get the Pithos url that corresponds to an image UUID."""
167
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
168
        return create_url(account, container, name)
169

    
170
    def _get_image(self, image_url):
171
        """Get information about an Image.
172

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

    
189
        if PLANKTON_PREFIX + 'name' not in meta:
190
            logger.warning("Image without Plankton name! url %s meta %s",
191
                           image_url, meta)
192
            meta[PLANKTON_PREFIX + "name"] = ""
193

    
194
        permissions = self._get_permissions(image_url)
195
        return image_to_dict(image_url, meta, permissions)
196

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

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

    
207
        prefixed = {}
208
        for key, val in meta.items():
209
            if key in PLANKTON_META:
210
                if key == "properties":
211
                    val = json.dumps(val)
212
                prefixed[PLANKTON_PREFIX + key] = val
213

    
214
        self.backend.update_object_meta(self.user, account, container, name,
215
                                        PLANKTON_DOMAIN, prefixed, replace)
216
        logger.debug("User '%s' updated image '%s', meta: '%s'", self.user,
217
                     image_url, prefixed)
218

    
219
    def _get_permissions(self, image_url):
220
        """Get object's permissions."""
221
        account, container, name = split_url(image_url)
222
        _a, path, permissions = \
223
            self.backend.get_object_permissions(self.user, account, container,
224
                                                name)
225

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

    
233
        return permissions
234

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

    
243
    @handle_backend_exceptions
244
    @commit_on_success
245
    def unregister(self, image_uuid):
246
        """Unregister an image.
247

248
        Unregister an image, by removing all metadata from the Pithos
249
        file that exist in the PLANKTON_DOMAIN.
250

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

    
260
    @handle_backend_exceptions
261
    @commit_on_success
262
    def add_user(self, image_uuid, add_user):
263
        """Add a user as an image member.
264

265
        Update read permissions of Pithos file, to include the specified user.
266

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

    
277
    @handle_backend_exceptions
278
    @commit_on_success
279
    def remove_user(self, image_uuid, remove_user):
280
        """Remove the user from image members.
281

282
        Remove the specified user from the read permissions of the Pithos file.
283

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

    
297
    @handle_backend_exceptions
298
    @commit_on_success
299
    def replace_users(self, image_uuid, replace_users):
300
        """Replace image members.
301

302
        Replace the read permissions of the Pithos files with the specified
303
        users. If image is specified as public, we must preserve * permission.
304

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

    
315
    @handle_backend_exceptions
316
    @commit_on_success
317
    def list_users(self, image_uuid):
318
        """List the image members.
319

320
        List the image members, by listing all users that have read permission
321
        to the corresponding Pithos file.
322

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

    
329
    @handle_backend_exceptions
330
    @commit_on_success
331
    def update_metadata(self, image_uuid, metadata):
332
        """Update Image metadata."""
333
        image_url = self._get_image_url(image_uuid)
334
        self._get_image(image_url)  # Assert that it is an image
335

    
336
        is_public = metadata.pop("is_public", None)
337
        if is_public is not None:
338
            permissions = self._get_permissions(image_url)
339
            read = set(permissions.get("read", []))
340
            if is_public:
341
                read.add("*")
342
            else:
343
                read.discard("*")
344
            permissions["read"] = list(read)
345
            self._update_permissions(image_url, permissions)
346
        meta = {}
347
        meta["properties"] = metadata.pop("properties", {})
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
        # Update rest metadata
396
        meta = {}
397
        meta['properties'] = metadata.pop('properties', {})
398
        # Add creation(register) timestamp as a metadata, to avoid extra
399
        # queries when retrieving the list of images.
400
        meta['created_at'] = time()
401
        meta.update(name=name, status='available', **metadata)
402

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

    
410
    def list_snapshots(self, user=None):
411
        _snapshots = self.backend.get_domain_objects(domain=SNAPSHOTS_DOMAIN,
412
                                                     user=user)
413
        snapshots = []
414
        for (location, meta, permissions) in _snapshots:
415
            snapshot_url = "pithos://" + location
416
            if not (SNAPSHOTS_PREFIX + "is_snapshot") in meta:
417
                continue
418
            snapshots.append(snapshot_to_dict(snapshot_url, meta, permissions))
419
        snapshots.sort(key=lambda x: x["uuid"], reverse=False)
420
        return snapshots
421

    
422
    @handle_backend_exceptions
423
    def get_snapshot(self, user, snapshot_uuid):
424
        #snapshot = self.backend.get_version_props_for_uuid(self.user,
425
        #                                                   snapshot_uuid,
426
        #                                                   SNAPSHOT_DOMAIN)
427
        snapshot_url = self._get_image_url(snapshot_uuid)
428
        meta = self._get_meta(snapshot_url)
429
        permissions = self._get_permissions(snapshot_url)
430
        return snapshot_to_dict(snapshot_url, meta, permissions)
431

    
432
    @handle_backend_exceptions
433
    def delete_snapshot(self, snapshot_uuid):
434
        self.backend.delete_object_for_uuid(self.user, snapshot_uuid)
435

    
436
    def _list_images(self, user=None, filters=None, params=None):
437
        filters = filters or {}
438

    
439
        # TODO: Use filters
440
        # # Fix keys
441
        # keys = [PLANKTON_PREFIX + 'name']
442
        # size_range = (None, None)
443
        # for key, val in filters.items():
444
        #     if key == 'size_min':
445
        #         size_range = (val, size_range[1])
446
        #     elif key == 'size_max':
447
        #         size_range = (size_range[0], val)
448
        #     else:
449
        #         keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
450
        _images = self.backend.get_domain_objects(domain=PLANKTON_DOMAIN,
451
                                                  user=user)
452

    
453
        images = []
454
        for (location, meta, permissions) in _images:
455
            image_url = "pithos://" + location
456
            meta["modified"] = meta["version_timestamp"]
457
            images.append(image_to_dict(image_url, meta, permissions))
458

    
459
        if params is None:
460
            params = {}
461
        key = itemgetter(params.get('sort_key', 'created_at'))
462
        reverse = params.get('sort_dir', 'desc') == 'desc'
463
        images.sort(key=key, reverse=reverse)
464
        return images
465

    
466
    @commit_on_success
467
    def list_images(self, filters=None, params=None):
468
        return self._list_images(user=self.user, filters=filters,
469
                                 params=params)
470

    
471
    @commit_on_success
472
    def list_shared_images(self, member, filters=None, params=None):
473
        images = self._list_images(user=self.user, filters=filters,
474
                                   params=params)
475
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
476
        return filter(is_shared, images)
477

    
478
    @commit_on_success
479
    def list_public_images(self, filters=None, params=None):
480
        images = self._list_images(user=None, filters=filters, params=params)
481
        return filter(lambda img: img["is_public"], images)
482

    
483

    
484
class ImageBackendError(Exception):
485
    pass
486

    
487

    
488
class ImageNotFound(ImageBackendError):
489
    pass
490

    
491

    
492
class Forbidden(ImageBackendError):
493
    pass
494

    
495

    
496
def image_to_dict(image_url, meta, permissions):
497
    """Render an image to a dictionary"""
498
    account, container, name = split_url(image_url)
499

    
500
    image = {}
501
    if PLANKTON_PREFIX + 'name' not in meta:
502
        logger.warning("Image without Plankton name!! url %s meta %s",
503
                       image_url, meta)
504
        image[PLANKTON_PREFIX + "name"] = ""
505

    
506
    image["id"] = meta["uuid"]
507
    image["location"] = image_url
508
    image["checksum"] = meta["hash"]
509
    created = meta.get("created_at", meta["modified"])
510
    image["created_at"] = format_timestamp(created)
511
    deleted = meta.get("deleted", None)
512
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
513
    image["updated_at"] = format_timestamp(meta["modified"])
514
    image["size"] = meta["bytes"]
515
    image["store"] = "pithos"
516
    image['owner'] = account
517

    
518
    # Distinquish between images and snapshots
519
    image["is_snapshot"] = (SNAPSHOTS_PREFIX + "is_snapshot") in meta
520

    
521
    # Permissions
522
    image["is_public"] = "*" in permissions.get('read', [])
523

    
524
    for key, val in meta.items():
525
        # Get plankton properties
526
        if key.startswith(PLANKTON_PREFIX):
527
            # Remove plankton prefix
528
            key = key.replace(PLANKTON_PREFIX, "")
529
            # Keep only those in plankton meta
530
            if key in PLANKTON_META:
531
                if key == "properties":
532
                    image[key] = json.loads(val)
533
                elif key == "created_at":
534
                    # created timestamp is return in 'created_at' field
535
                    pass
536
                else:
537
                    image[key] = val
538
    if "properties" not in image:
539
        image["properties"] = {}
540

    
541
    return image
542

    
543

    
544
def snapshot_to_dict(snapshot_url, meta, permissions):
545
    """Render an snapshot to a dictionary"""
546
    account, container, name = split_url(snapshot_url)
547

    
548
    snapshot = {}
549
    snapshot["uuid"] = meta["uuid"]
550
    snapshot["map"] = meta["hash"]
551
    snapshot["size"] = meta["bytes"]
552

    
553
    snapshot['owner'] = account
554
    snapshot["location"] = snapshot_url
555
    snapshot["file_name"] = name
556

    
557
    created = meta["version_timestamp"]
558
    snapshot["created_at"] = format_timestamp(created)
559

    
560
    for key, val in meta.items():
561
        if key.startswith(SNAPSHOTS_PREFIX):
562
            # Remove plankton prefix
563
            key = key.replace(SNAPSHOTS_PREFIX, "")
564
            if key == "metadata":
565
                snapshot[key] = json.loads(val)
566
            else:
567
                snapshot[key] = val
568

    
569
    return snapshot
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))