Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / plankton / backend.py @ 3a528c36

History | View | Annotate | Download (17.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
from time import time, gmtime, strftime
58
from functools import wraps
59
from operator import itemgetter
60

    
61
from django.conf import settings
62
from pithos.backends.base import NotAllowedError, VersionNotExists
63

    
64
logger = logging.getLogger(__name__)
65

    
66

    
67
PLANKTON_DOMAIN = 'plankton'
68
PLANKTON_PREFIX = 'plankton:'
69
PROPERTY_PREFIX = 'property:'
70

    
71
PLANKTON_META = ('container_format', 'disk_format', 'name', 'properties',
72
                 'status', 'created_at')
73

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

    
85

    
86
def get_pithos_backend():
87
    return _pithos_backend_pool.pool_get()
88

    
89

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

    
95

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

    
103

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

    
107

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

    
121

    
122
class ImageBackend(object):
123
    """A wrapper arround the pithos backend to simplify image handling."""
124

    
125
    def __init__(self, user):
126
        self.user = user
127

    
128
        original_filters = warnings.filters
129
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
130
        self.backend = get_pithos_backend()
131
        warnings.filters = original_filters     # Restore warnings
132

    
133
    def close(self):
134
        """Close PithosBackend(return to pool)"""
135
        self.backend.close()
136

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

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

    
148
    def _get_image(self, image_url):
149
        """Get information about an Image.
150

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

    
167
        if PLANKTON_PREFIX + 'name' not in meta:
168
            logger.warning("Image without Plankton name! url %s meta %s",
169
                           image_url, meta)
170
            meta[PLANKTON_PREFIX + "name"] = ""
171

    
172
        permissions = self._get_permissions(image_url)
173
        return image_to_dict(image_url, meta, permissions)
174

    
175
    def _get_meta(self, image_url, version=None):
176
        """Get object's metadata."""
177
        account, container, name = split_url(image_url)
178
        return self.backend.get_object_meta(self.user, account, container,
179
                                            name, PLANKTON_DOMAIN, version)
180

    
181
    def _update_meta(self, image_url, meta, replace=False):
182
        """Update object's metadata."""
183
        account, container, name = split_url(image_url)
184

    
185
        prefixed = {}
186
        for key, val in meta.items():
187
            if key in PLANKTON_META:
188
                if key == "properties":
189
                    val = json.dumps(val)
190
                prefixed[PLANKTON_PREFIX + key] = val
191

    
192
        self.backend.update_object_meta(self.user, account, container, name,
193
                                        PLANKTON_DOMAIN, prefixed, replace)
194
        logger.debug("User '%s' updated image '%s', meta: '%s'", self.user,
195
                     image_url, prefixed)
196

    
197
    def _get_permissions(self, image_url):
198
        """Get object's permissions."""
199
        account, container, name = split_url(image_url)
200
        _a, path, permissions = \
201
            self.backend.get_object_permissions(self.user, account, container,
202
                                                name)
203

    
204
        if path is None:
205
            logger.warning("Image '%s' got permissions from None path",
206
                           image_url)
207

    
208
        return permissions
209

    
210
    def _update_permissions(self, image_url, permissions):
211
        """Update object's permissions."""
212
        account, container, name = split_url(image_url)
213
        self.backend.update_object_permissions(self.user, account, container,
214
                                               name, permissions)
215
        logger.debug("User '%s' updated image '%s', permissions: '%s'",
216
                     self.user, image_url, permissions)
217

    
218
    @handle_backend_exceptions
219
    def unregister(self, image_uuid):
220
        """Unregister an image.
221

222
        Unregister an image, by removing all metadata from the Pithos
223
        file that exist in the PLANKTON_DOMAIN.
224

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

    
234
    @handle_backend_exceptions
235
    def add_user(self, image_uuid, add_user):
236
        """Add a user as an image member.
237

238
        Update read permissions of Pithos file, to include the specified user.
239

240
        """
241
        image_url = self._get_image_url(image_uuid)
242
        self._get_image(image_url)  # Assert that it is an image
243
        permissions = self._get_permissions(image_url)
244
        read = set(permissions.get("read", []))
245
        assert(isinstance(add_user, (str, unicode)))
246
        read.add(add_user)
247
        permissions["read"] = list(read)
248
        self._update_permissions(image_url, permissions)
249

    
250
    @handle_backend_exceptions
251
    def remove_user(self, image_uuid, remove_user):
252
        """Remove the user from image members.
253

254
        Remove the specified user from the read permissions of the Pithos file.
255

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

    
269
    @handle_backend_exceptions
270
    def replace_users(self, image_uuid, replace_users):
271
        """Replace image members.
272

273
        Replace the read permissions of the Pithos files with the specified
274
        users. If image is specified as public, we must preserve * permission.
275

276
        """
277
        image_url = self._get_image_url(image_uuid)
278
        image = self._get_image(image_url)
279
        permissions = self._get_permissions(image_url)
280
        assert(isinstance(replace_users, list))
281
        permissions["read"] = replace_users
282
        if image.get("is_public", False):
283
            permissions["read"].append("*")
284
        self._update_permissions(image_url, permissions)
285

    
286
    @handle_backend_exceptions
287
    def list_users(self, image_uuid):
288
        """List the image members.
289

290
        List the image members, by listing all users that have read permission
291
        to the corresponding Pithos file.
292

293
        """
294
        image_url = self._get_image_url(image_uuid)
295
        self._get_image(image_url)  # Assert that it is an image
296
        permissions = self._get_permissions(image_url)
297
        return [user for user in permissions.get('read', []) if user != '*']
298

    
299
    @handle_backend_exceptions
300
    def update_metadata(self, image_uuid, metadata):
301
        """Update Image metadata."""
302
        image_url = self._get_image_url(image_uuid)
303
        self._get_image(image_url)  # Assert that it is an image
304

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

    
319
        self._update_meta(image_url, meta)
320
        return self.get_image(image_uuid)
321

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

    
342
        # Validate that 'size' and 'checksum' are valid
343
        account, container, object = split_url(image_url)
344

    
345
        meta = self._get_meta(image_url)
346

    
347
        size = int(metadata.pop('size', meta['bytes']))
348
        if size != meta['bytes']:
349
            raise ValueError("Invalid size")
350

    
351
        checksum = metadata.pop('checksum', meta['hash'])
352
        if checksum != meta['hash']:
353
            raise ValueError("Invalid checksum")
354

    
355
        # Fix permissions
356
        is_public = metadata.pop('is_public', False)
357
        if is_public:
358
            permissions = {'read': ['*']}
359
        else:
360
            permissions = {'read': [self.user]}
361

    
362
        # Update rest metadata
363
        meta = {}
364
        meta['properties'] = metadata.pop('properties', {})
365
        # Add creation(register) timestamp as a metadata, to avoid extra
366
        # queries when retrieving the list of images.
367
        meta['created_at'] = time()
368
        meta.update(name=name, status='available', **metadata)
369

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

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

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

    
394
        images = []
395
        for (location, meta, permissions) in _images:
396
            image_url = "pithos://" + location
397
            meta["modified"] = meta["version_timestamp"]
398
            images.append(image_to_dict(image_url, meta, permissions))
399

    
400
        if params is None:
401
            params = {}
402
        key = itemgetter(params.get('sort_key', 'created_at'))
403
        reverse = params.get('sort_dir', 'desc') == 'desc'
404
        images.sort(key=key, reverse=reverse)
405
        return images
406

    
407
    def list_images(self, filters=None, params=None):
408
        return self._list_images(user=self.user, filters=filters,
409
                                 params=params)
410

    
411
    def list_shared_images(self, member, filters=None, params=None):
412
        images = self._list_images(user=self.user, filters=filters,
413
                                   params=params)
414
        is_shared = lambda img: not img["is_public"] and img["owner"] == member
415
        return filter(is_shared, images)
416

    
417
    def list_public_images(self, filters=None, params=None):
418
        images = self._list_images(user=None, filters=filters, params=params)
419
        return filter(lambda img: img["is_public"], images)
420

    
421

    
422
class ImageBackendError(Exception):
423
    pass
424

    
425

    
426
class ImageNotFound(ImageBackendError):
427
    pass
428

    
429

    
430
class Forbidden(ImageBackendError):
431
    pass
432

    
433

    
434
def image_to_dict(image_url, meta, permissions):
435
    """Render an image to a dictionary"""
436
    account, container, name = split_url(image_url)
437

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

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

    
456
    # Permissions
457
    image["is_public"] = "*" in permissions.get('read', [])
458

    
459
    for key, val in meta.items():
460
        # Get plankton properties
461
        if key.startswith(PLANKTON_PREFIX):
462
            # Remove plankton prefix
463
            key = key.replace(PLANKTON_PREFIX, "")
464
            # Keep only those in plankton meta
465
            if key in PLANKTON_META:
466
                if key == "properties":
467
                    val = json.loads(val)
468
                elif key == "created_at":
469
                    # created timestamp is return in 'created_at' field
470
                    pass
471
                else:
472
                    image[key] = val
473

    
474
    return image