Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / plankton / backend.py @ 435bb7fb

History | View | Annotate | Download (19.8 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
from pithos.backends.util import PithosBackendPool
78
_pithos_backend_pool = \
79
    PithosBackendPool(
80
        settings.PITHOS_BACKEND_POOL_SIZE,
81
        astakos_url=settings.ASTAKOS_BASE_URL,
82
        service_token=settings.CYCLADES_SERVICE_TOKEN,
83
        astakosclient_poolsize=settings.CYCLADES_ASTAKOSCLIENT_POOLSIZE,
84
        db_connection=settings.BACKEND_DB_CONNECTION,
85
        block_path=settings.BACKEND_BLOCK_PATH)
86

    
87

    
88
def get_pithos_backend():
89
    return _pithos_backend_pool.pool_get()
90

    
91

    
92
def create_url(account, container, name):
93
    assert "/" not in account, "Invalid account"
94
    assert "/" not in container, "Invalid container"
95
    return "pithos://%s/%s/%s" % (account, container, name)
96

    
97

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

    
105

    
106
def format_timestamp(t):
107
    return strftime('%Y-%m-%d %H:%M:%S', gmtime(t))
108

    
109

    
110
def handle_backend_exceptions(func):
111
    @wraps(func)
112
    def wrapper(*args, **kwargs):
113
        try:
114
            return func(*args, **kwargs)
115
        except NotAllowedError:
116
            raise Forbidden
117
        except NameError:
118
            raise ImageNotFound
119
        except VersionNotExists:
120
            raise ImageNotFound
121
    return wrapper
122

    
123

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

    
138

    
139
class ImageBackend(object):
140
    """A wrapper arround the pithos backend to simplify image handling."""
141

    
142
    def __init__(self, user):
143
        self.user = user
144

    
145
        original_filters = warnings.filters
146
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
147
        self.backend = get_pithos_backend()
148
        warnings.filters = original_filters     # Restore warnings
149

    
150
    def close(self):
151
        """Close PithosBackend(return to pool)"""
152
        self.backend.close()
153

    
154
    @handle_backend_exceptions
155
    @commit_on_success
156
    def get_image(self, image_uuid):
157
        """Retrieve information about an image."""
158
        image_url = self._get_image_url(image_uuid)
159
        return self._get_image(image_url)
160

    
161
    def _get_image_url(self, image_uuid):
162
        """Get the Pithos url that corresponds to an image UUID."""
163
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
164
        return create_url(account, container, name)
165

    
166
    def _get_image(self, image_url):
167
        """Get information about an Image.
168

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

    
185
        if PLANKTON_PREFIX + 'name' not in meta:
186
            logger.warning("Image without Plankton name! url %s meta %s",
187
                           image_url, meta)
188
            meta[PLANKTON_PREFIX + "name"] = ""
189

    
190
        permissions = self._get_permissions(image_url)
191
        return image_to_dict(image_url, meta, permissions)
192

    
193
    def _get_meta(self, image_url, version=None):
194
        """Get object's metadata."""
195
        account, container, name = split_url(image_url)
196
        return self.backend.get_object_meta(self.user, account, container,
197
                                            name, PLANKTON_DOMAIN, version)
198

    
199
    def _update_meta(self, image_url, meta, replace=False):
200
        """Update object's metadata."""
201
        account, container, name = split_url(image_url)
202

    
203
        prefixed = {}
204
        for key, val in meta.items():
205
            if key in PLANKTON_META:
206
                if key == "properties":
207
                    val = json.dumps(val)
208
                prefixed[PLANKTON_PREFIX + key] = val
209

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

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

    
222
        if path is None:
223
            logger.warning("Image '%s' got permissions from None path",
224
                           image_url)
225

    
226
        return permissions
227

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
371
        meta = self._get_meta(image_url)
372

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

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

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

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

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

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

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

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

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

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

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

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

    
450

    
451
class ImageBackendError(Exception):
452
    pass
453

    
454

    
455
class ImageNotFound(ImageBackendError):
456
    pass
457

    
458

    
459
class Forbidden(ImageBackendError):
460
    pass
461

    
462

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

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

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

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

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

    
503
    return image
504

    
505

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

511
    usage:
512
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
513
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
514

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

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

    
533
    def list_images(self, *args, **kwargs):
534
        return self.images
535

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

    
542
    def close(self):
543
        pass
544

    
545

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

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