Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / plankton / backend.py @ 9393d4ee

History | View | Annotate | Download (18.9 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 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')
76

    
77
from pithos.backends.util import PithosBackendPool
78
POOL_SIZE = 8
79
_pithos_backend_pool = \
80
    PithosBackendPool(
81
        POOL_SIZE,
82
        astakos_url=settings.ASTAKOS_BASE_URL,
83
        service_token=settings.CYCLADES_SERVICE_TOKEN,
84
        astakosclient_poolsize=settings.CYCLADES_ASTAKOSCLIENT_POOLSIZE,
85
        db_connection=settings.BACKEND_DB_CONNECTION,
86
        block_path=settings.BACKEND_BLOCK_PATH)
87

    
88

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

    
92

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

    
98

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

    
106

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

    
110

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

    
124

    
125
class ImageBackend(object):
126
    """A wrapper arround the pithos backend to simplify image handling."""
127

    
128
    def __init__(self, user):
129
        self.user = user
130

    
131
        original_filters = warnings.filters
132
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
133
        self.backend = get_pithos_backend()
134
        warnings.filters = original_filters     # Restore warnings
135

    
136
    def close(self):
137
        """Close PithosBackend(return to pool)"""
138
        self.backend.close()
139

    
140
    @handle_backend_exceptions
141
    def get_image(self, image_uuid):
142
        """Retrieve information about an image."""
143
        image_url = self._get_image_url(image_uuid)
144
        return self._get_image(image_url)
145

    
146
    def _get_image_url(self, image_uuid):
147
        """Get the Pithos url that corresponds to an image UUID."""
148
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
149
        return create_url(account, container, name)
150

    
151
    def _get_image(self, image_url):
152
        """Get information about an Image.
153

154
        Get all available information about an Image.
155
        """
156
        account, container, name = split_url(image_url)
157
        versions = self.backend.list_versions(self.user, account, container,
158
                                              name)
159
        if not versions:
160
            raise Exception("Image without versions %s" % image_url)
161
        try:
162
            meta = self._get_meta(image_url)
163
            meta["deleted"] = ""
164
        except NameError:
165
            # Object was deleted, use the latest version
166
            version, timestamp = versions[-1]
167
            meta = self._get_meta(image_url, version)
168
            meta["deleted"] = timestamp
169

    
170
        meta["created"] = versions[0][1]
171

    
172
        if PLANKTON_PREFIX + 'name' not in meta:
173
            logger.warning("Image without Plankton name! url %s meta %s",
174
                           image_url, meta)
175
            meta[PLANKTON_PREFIX + "name"] = ""
176

    
177
        permissions = self._get_permissions(image_url)
178
        return image_to_dict(image_url, meta, permissions)
179

    
180
    def _get_meta(self, image_url, version=None):
181
        """Get object's metadata."""
182
        account, container, name = split_url(image_url)
183
        return self.backend.get_object_meta(self.user, account, container,
184
                                            name, PLANKTON_DOMAIN, version)
185

    
186
    def _update_meta(self, image_url, meta, replace=False):
187
        """Update object's metadata."""
188
        account, container, name = split_url(image_url)
189

    
190
        prefixed = {}
191
        for key, val in meta.items():
192
            if key in PLANKTON_META:
193
                if key == "properties":
194
                    val = json.dumps(val)
195
                prefixed[PLANKTON_PREFIX + key] = val
196

    
197
        self.backend.update_object_meta(self.user, account, container, name,
198
                                        PLANKTON_DOMAIN, prefixed, replace)
199
        logger.debug("User '%s' updated image '%s', meta: '%s'", self.user,
200
                     image_url, prefixed)
201

    
202
    def _get_permissions(self, image_url):
203
        """Get object's permissions."""
204
        account, container, name = split_url(image_url)
205
        _a, path, permissions = \
206
            self.backend.get_object_permissions(self.user, account, container,
207
                                                name)
208

    
209
        if path is None:
210
            logger.warning("Image '%s' got permissions from None path",
211
                           image_url)
212

    
213
        return permissions
214

    
215
    def _update_permissions(self, image_url, permissions):
216
        """Update object's permissions."""
217
        account, container, name = split_url(image_url)
218
        self.backend.update_object_permissions(self.user, account, container,
219
                                               name, permissions)
220
        logger.debug("User '%s' updated image '%s', permissions: '%s'",
221
                     self.user, image_url, permissions)
222

    
223
    @handle_backend_exceptions
224
    def unregister(self, image_uuid):
225
        """Unregister an image.
226

227
        Unregister an image, by removing all metadata from the Pithos
228
        file that exist in the PLANKTON_DOMAIN.
229

230
        """
231
        image_url = self._get_image_url(image_uuid)
232
        self._get_image(image_url)  # Assert that it is an image
233
        # Unregister the image by removing all metadata from domain
234
        # 'PLANKTON_DOMAIN'
235
        meta = {}
236
        self._update_meta(image_url, meta, True)
237
        logger.debug("User '%s' deleted image '%s'", self.user, image_url)
238

    
239
    @handle_backend_exceptions
240
    def add_user(self, image_uuid, add_user):
241
        """Add a user as an image member.
242

243
        Update read permissions of Pithos file, to include the specified user.
244

245
        """
246
        image_url = self._get_image_url(image_uuid)
247
        self._get_image(image_url)  # Assert that it is an image
248
        permissions = self._get_permissions(image_url)
249
        read = set(permissions.get("read", []))
250
        assert(isinstance(add_user, (str, unicode)))
251
        read.add(add_user)
252
        permissions["read"] = list(read)
253
        self._update_permissions(image_url, permissions)
254

    
255
    @handle_backend_exceptions
256
    def remove_user(self, image_uuid, remove_user):
257
        """Remove the user from image members.
258

259
        Remove the specified user from the read permissions of the Pithos file.
260

261
        """
262
        image_url = self._get_image_url(image_uuid)
263
        self._get_image(image_url)  # Assert that it is an image
264
        permissions = self._get_permissions(image_url)
265
        read = set(permissions.get("read", []))
266
        assert(isinstance(remove_user, (str, unicode)))
267
        try:
268
            read.remove(remove_user)
269
        except ValueError:
270
            return  # TODO: User did not have access
271
        permissions["read"] = list(read)
272
        self._update_permissions(image_url, permissions)
273

    
274
    @handle_backend_exceptions
275
    def replace_users(self, image_uuid, replace_users):
276
        """Replace image members.
277

278
        Replace the read permissions of the Pithos files with the specified
279
        users. If image is specified as public, we must preserve * permission.
280

281
        """
282
        image_url = self._get_image_url(image_uuid)
283
        image = self._get_image(image_url)
284
        permissions = self._get_permissions(image_url)
285
        assert(isinstance(replace_users, list))
286
        permissions["read"] = replace_users
287
        if image.get("is_public", False):
288
            permissions["read"].append("*")
289
        self._update_permissions(image_url, permissions)
290

    
291
    @handle_backend_exceptions
292
    def list_users(self, image_uuid):
293
        """List the image members.
294

295
        List the image members, by listing all users that have read permission
296
        to the corresponding 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
        return [user for user in permissions.get('read', []) if user != '*']
303

    
304
    @handle_backend_exceptions
305
    def update_metadata(self, image_uuid, metadata):
306
        """Update Image metadata."""
307
        image_url = self._get_image_url(image_uuid)
308
        self._get_image(image_url)  # Assert that it is an image
309

    
310
        is_public = metadata.pop("is_public", None)
311
        if is_public is not None:
312
            permissions = self._get_permissions(image_url)
313
            read = set(permissions.get("read", []))
314
            if is_public:
315
                read.add("*")
316
            else:
317
                read.discard("*")
318
            permissions["read"] = list(read)
319
            self._update_permissions(image_url, permissions)
320
        meta = {}
321
        meta["properties"] = metadata.pop("properties", {})
322
        meta.update(**metadata)
323

    
324
        self._update_meta(image_url, meta)
325
        return self.get_image(image_uuid)
326

    
327
    @handle_backend_exceptions
328
    def register(self, name, image_url, metadata):
329
        # Validate that metadata are allowed
330
        if "id" in metadata:
331
            raise ValueError("Passing an ID is not supported")
332
        store = metadata.pop("store", "pithos")
333
        if store != "pithos":
334
            raise ValueError("Invalid store '%s'. Only 'pithos' store is"
335
                             "supported" % store)
336
        disk_format = metadata.setdefault("disk_format",
337
                                          settings.DEFAULT_DISK_FORMAT)
338
        if disk_format not in settings.ALLOWED_DISK_FORMATS:
339
            raise ValueError("Invalid disk format '%s'" % disk_format)
340
        container_format =\
341
            metadata.setdefault("container_format",
342
                                settings.DEFAULT_CONTAINER_FORMAT)
343
        if container_format not in settings.ALLOWED_CONTAINER_FORMATS:
344
            raise ValueError("Invalid container format '%s'" %
345
                             container_format)
346

    
347
        # Validate that 'size' and 'checksum' are valid
348
        account, container, object = split_url(image_url)
349

    
350
        meta = self._get_meta(image_url)
351

    
352
        size = int(metadata.pop('size', meta['bytes']))
353
        if size != meta['bytes']:
354
            raise ValueError("Invalid size")
355

    
356
        checksum = metadata.pop('checksum', meta['hash'])
357
        if checksum != meta['hash']:
358
            raise ValueError("Invalid checksum")
359

    
360
        # Fix permissions
361
        is_public = metadata.pop('is_public', False)
362
        if is_public:
363
            permissions = {'read': ['*']}
364
        else:
365
            permissions = {'read': [self.user]}
366

    
367
        # Update rest metadata
368
        meta = {}
369
        meta['properties'] = metadata.pop('properties', {})
370
        meta.update(name=name, status='available', **metadata)
371

    
372
        # Do the actualy update in the Pithos backend
373
        self._update_meta(image_url, meta)
374
        self._update_permissions(image_url, permissions)
375
        logger.debug("User '%s' created image '%s'('%s')", self.user,
376
                     image_url, name)
377
        return self._get_image(image_url)
378

    
379
    def _list_images(self, user=None, filters=None, params=None):
380
        filters = filters or {}
381

    
382
        # TODO: Use filters
383
        # # Fix keys
384
        # keys = [PLANKTON_PREFIX + 'name']
385
        # size_range = (None, None)
386
        # for key, val in filters.items():
387
        #     if key == 'size_min':
388
        #         size_range = (val, size_range[1])
389
        #     elif key == 'size_max':
390
        #         size_range = (size_range[0], val)
391
        #     else:
392
        #         keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
393
        _images = self.backend.get_domain_objects(domain=PLANKTON_DOMAIN,
394
                                                  user=user)
395

    
396
        images = []
397
        for (location, meta, permissions) in _images:
398
            image_url = "pithos://" + location
399
            meta["modified"] = meta["version_timestamp"]
400
            # TODO: Create metadata when registering an Image
401
            meta["created"] = meta["version_timestamp"]
402
            images.append(image_to_dict(image_url, meta, permissions))
403

    
404
        if params is None:
405
            params = {}
406
        key = itemgetter(params.get('sort_key', 'created_at'))
407
        reverse = params.get('sort_dir', 'desc') == 'desc'
408
        images.sort(key=key, reverse=reverse)
409
        return images
410

    
411
    def list_images(self, filters=None, params=None):
412
        return self._list_images(user=self.user, filters=filters,
413
                                 params=params)
414

    
415
    def list_shared_images(self, member, filters=None, params=None):
416
        images = self._list_images(user=self.user, filters=filters,
417
                                   params=params)
418
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
419
        return filter(is_shared, images)
420

    
421
    def list_public_images(self, filters=None, params=None):
422
        images = self._list_images(user=None, filters=filters, params=params)
423
        return filter(lambda img: img["is_public"], images)
424

    
425

    
426
class ImageBackendError(Exception):
427
    pass
428

    
429

    
430
class ImageNotFound(ImageBackendError):
431
    pass
432

    
433

    
434
class Forbidden(ImageBackendError):
435
    pass
436

    
437

    
438
def image_to_dict(image_url, meta, permissions):
439
    """Render an image to a dictionary"""
440
    account, container, name = split_url(image_url)
441

    
442
    image = {}
443
    if PLANKTON_PREFIX + 'name' not in meta:
444
        logger.warning("Image without Plankton name!! url %s meta %s",
445
                       image_url, meta)
446
        image[PLANKTON_PREFIX + "name"] = ""
447

    
448
    image["id"] = meta["uuid"]
449
    image["location"] = image_url
450
    image["checksum"] = meta["hash"]
451
    image["created_at"] = format_timestamp(meta["created"])
452
    deleted = meta.get("deleted", None)
453
    image["deleted_at"] = format_timestamp(deleted) if deleted else ""
454
    image["updated_at"] = format_timestamp(meta["modified"])
455
    image["size"] = meta["bytes"]
456
    image["store"] = "pithos"
457
    image['owner'] = account
458

    
459
    # Permissions
460
    image["is_public"] = "*" in permissions.get('read', [])
461

    
462
    for key, val in meta.items():
463
        # Get plankton properties
464
        if key.startswith(PLANKTON_PREFIX):
465
            # Remove plankton prefix
466
            key = key.replace(PLANKTON_PREFIX, "")
467
            # Keep only those in plankton meta
468
            if key in PLANKTON_META:
469
                if key == "properties":
470
                    val = json.loads(val)
471
                image[key] = val
472

    
473
    return image
474

    
475

    
476
class JSONFileBackend(object):
477
    """
478
    A dummy image backend that loads available images from a file with json
479
    formatted content.
480

481
    usage:
482
        PLANKTON_BACKEND_MODULE = 'synnefo.plankton.backend.JSONFileBackend'
483
        PLANKTON_IMAGES_JSON_BACKEND_FILE = '/tmp/images.json'
484

485
        # loading images from an existing plankton service
486
        $ curl -H "X-Auth-Token: <MYTOKEN>" \
487
                https://cyclades.synnefo.org/plankton/images/detail | \
488
                python -m json.tool > /tmp/images.json
489
    """
490
    def __init__(self, userid):
491
        self.images_file = getattr(settings,
492
                                   'PLANKTON_IMAGES_JSON_BACKEND_FILE', '')
493
        if not os.path.exists(self.images_file):
494
            raise Exception("Invalid plankgon images json backend file: %s",
495
                            self.images_file)
496
        fp = file(self.images_file)
497
        self.images = json.load(fp)
498
        fp.close()
499

    
500
    def iter(self, *args, **kwargs):
501
        return self.images.__iter__()
502

    
503
    def list_images(self, *args, **kwargs):
504
        return self.images
505

    
506
    def get_image(self, image_uuid):
507
        try:
508
            return filter(lambda i: i['id'] == image_uuid, self.images)[0]
509
        except IndexError:
510
            raise Exception("Unknown image uuid: %s" % image_uuid)
511

    
512
    def close(self):
513
        pass
514

    
515

    
516
def get_backend():
517
    backend_module = getattr(settings, 'PLANKTON_BACKEND_MODULE', None)
518
    if not backend_module:
519
        # no setting set
520
        return ImageBackend
521

    
522
    parts = backend_module.split(".")
523
    module = ".".join(parts[:-1])
524
    cls = parts[-1]
525
    try:
526
        return getattr(importlib.import_module(module), cls)
527
    except (ImportError, AttributeError), e:
528
        raise ImportError("Cannot import plankton module: %s (%s)" %
529
                          (backend_module, e.message))