Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (22.2 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="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

    
539
    return image
540

    
541

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

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

    
551
    snapshot['owner'] = account
552
    snapshot["location"] = snapshot_url
553
    snapshot["file_name"] = name
554

    
555
    created = meta.get("created_at", meta["modified"])
556
    snapshot["created_at"] = format_timestamp(created)
557

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

    
567
    return snapshot
568

    
569

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

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

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

    
594
    def iter(self, *args, **kwargs):
595
        return self.images.__iter__()
596

    
597
    def list_images(self, *args, **kwargs):
598
        return self.images
599

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

    
606
    def close(self):
607
        pass
608

    
609

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

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