Statistics
| Branch: | Tag: | Revision:

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

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

    
61

    
62
from django.conf import settings
63
from pithos.backends.base import NotAllowedError, VersionNotExists
64
import snf_django.lib.astakos as lib_astakos
65

    
66
logger = logging.getLogger(__name__)
67

    
68

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

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

    
76
TRANSLATE_UUIDS = getattr(settings, 'TRANSLATE_UUIDS', False)
77

    
78

    
79
def get_displaynames(names):
80
    try:
81
        auth_url = settings.ASTAKOS_URL
82
        url = auth_url.replace('im/authenticate', 'service/api/user_catalogs')
83
        token = settings.CYCLADES_ASTAKOS_SERVICE_TOKEN
84
        uuids = lib_astakos.get_displaynames(token, names, url=url)
85
    except Exception:
86
        return {}
87

    
88
    return uuids
89

    
90

    
91
def get_location(account, container, object):
92
    assert '/' not in account, "Invalid account"
93
    assert '/' not in container, "Invalid container"
94
    return 'pithos://%s/%s/%s' % (account, container, object)
95

    
96

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

    
103

    
104
from pithos.backends.util import PithosBackendPool
105
POOL_SIZE = 8
106
_pithos_backend_pool = \
107
    PithosBackendPool(
108
        POOL_SIZE,
109
        quotaholder_enabled=settings.CYCLADES_USE_QUOTAHOLDER,
110
        quotaholder_url=settings.CYCLADES_QUOTAHOLDER_URL,
111
        quotaholder_token=settings.CYCLADES_QUOTAHOLDER_TOKEN,
112
        quotaholder_client_poolsize=settings.CYCLADES_QUOTAHOLDER_POOLSIZE,
113
        db_connection=settings.BACKEND_DB_CONNECTION,
114
        block_path=settings.BACKEND_BLOCK_PATH)
115

    
116

    
117
def get_pithos_backend():
118
    return _pithos_backend_pool.pool_get()
119

    
120

    
121
def create_url(account, container, name):
122
    assert "/" not in account, "Invalid account"
123
    assert "/" not in container, "Invalid container"
124
    return "pithos://%s/%s/%s" % (account, container, name)
125

    
126

    
127
def split_url(url):
128
    """Returns (accout, container, object) from a url string"""
129
    t = url.split('/', 4)
130
    assert len(t) == 5, "Invalid url"
131
    return t[2:5]
132

    
133

    
134
def format_timestamp(t):
135
    return strftime('%Y-%m-%d %H:%M:%S', gmtime(t))
136

    
137

    
138
def handle_backend_exceptions(func):
139
    @wraps(func)
140
    def wrapper(*args, **kwargs):
141
        try:
142
            return func(*args, **kwargs)
143
        except NotAllowedError:
144
            raise Forbidden
145
        except NameError:
146
            raise ImageNotFound
147
        except VersionNotExists:
148
            raise ImageNotFound
149
    return wrapper
150

    
151

    
152
class ImageBackend(object):
153
    """A wrapper arround the pithos backend to simplify image handling."""
154

    
155
    def __init__(self, user):
156
        self.user = user
157

    
158
        original_filters = warnings.filters
159
        warnings.simplefilter('ignore')         # Suppress SQLAlchemy warnings
160
        self.backend = get_pithos_backend()
161
        warnings.filters = original_filters     # Restore warnings
162

    
163
    def close(self):
164
        """Close PithosBackend(return to pool)"""
165
        self.backend.close()
166

    
167
    @handle_backend_exceptions
168
    def get_image(self, image_uuid):
169
        """Retrieve information about an image."""
170
        image_url = self._get_image_url(image_uuid)
171
        return self._get_image(image_url)
172

    
173
    def _get_image_url(self, image_uuid):
174
        """Get the Pithos url that corresponds to an image UUID."""
175
        account, container, name = self.backend.get_uuid(self.user, image_uuid)
176
        return create_url(account, container, name)
177

    
178
    def _get_image(self, image_url):
179
        """Get information about an Image.
180

181
        Get all available information about an Image.
182
        """
183
        account, container, name = split_url(image_url)
184
        versions = self.backend.list_versions(self.user, account, container,
185
                                              name)
186

    
187
        if not versions:
188
            raise Exception("Image without versions %s" % image_url)
189

    
190
        image = {}
191

    
192
        try:
193
            meta = self._get_meta(image_url)
194
            image["deleted_at"] = ""
195
        except NameError:
196
            # Object was deleted, use the latest version
197
            version, timestamp = versions[-1]
198
            meta = self._get_meta(image_url, version)
199
            image["deleted_at"] = format_timestamp(timestamp)
200

    
201
        if PLANKTON_PREFIX + 'name' not in meta:
202
            raise ImageNotFound("'%s' is not a Plankton image" % image_url)
203

    
204
        image["id"] = meta["uuid"]
205
        image["location"] = image_url
206
        image["checksum"] = meta["hash"]
207
        image["created_at"] = format_timestamp(versions[0][1])
208
        image["updated_at"] = format_timestamp(meta["modified"])
209
        image["size"] = meta["bytes"]
210
        image["store"] = "pithos"
211

    
212
        if TRANSLATE_UUIDS:
213
            displaynames = get_displaynames([account])
214
            if account in displaynames:
215
                display_account = displaynames[account]
216
            else:
217
                display_account = 'unknown'
218
            image['owner'] = display_account
219
        else:
220
            image['owner'] = account
221

    
222
        # Permissions
223
        permissions = self._get_permissions(image_url)
224
        image["is_public"] = "*" in permissions.get('read', [])
225

    
226
        for key, val in meta.items():
227
            # Get plankton properties
228
            if key.startswith(PLANKTON_PREFIX):
229
                # Remove plankton prefix
230
                key = key.replace(PLANKTON_PREFIX, "")
231
                # Keep only those in plankton meta
232
                if key in PLANKTON_META:
233
                    if key == "properties":
234
                        val = json.loads(val)
235
                    image[key] = val
236

    
237
        return image
238

    
239
    def _get_meta(self, image_url, version=None):
240
        """Get object's metadata."""
241
        account, container, name = split_url(image_url)
242
        return self.backend.get_object_meta(self.user, account, container,
243
                                            name, PLANKTON_DOMAIN, version)
244

    
245
    def _update_meta(self, image_url, meta, replace=False):
246
        """Update object's metadata."""
247
        account, container, name = split_url(image_url)
248

    
249
        prefixed = {}
250
        for key, val in meta.items():
251
            if key in PLANKTON_META:
252
                if key == "properties":
253
                    val = json.dumps(val)
254
                prefixed[PLANKTON_PREFIX + key] = val
255

    
256
        self.backend.update_object_meta(self.user, account, container, name,
257
                                        PLANKTON_DOMAIN, prefixed, replace)
258

    
259
    def _get_permissions(self, image_url):
260
        """Get object's permissions."""
261
        account, container, name = split_url(image_url)
262
        _a, path, permissions = \
263
            self.backend.get_object_permissions(self.user, account, container,
264
                                                name)
265

    
266
        if path is None:
267
            logger.warning("Image '%s' got permissions from None path",
268
                           image_url)
269

    
270
        return permissions
271

    
272
    def _update_permissions(self, image_url, permissions):
273
        """Update object's permissions."""
274
        account, container, name = split_url(image_url)
275
        self.backend.update_object_permissions(self.user, account, container,
276
                                               name, permissions)
277

    
278
    @handle_backend_exceptions
279
    def unregister(self, image_uuid):
280
        """Unregister an image.
281

282
        Unregister an image, by removing all metadata from the Pithos
283
        file that exist in the PLANKTON_DOMAIN.
284

285
        """
286
        image_url = self._get_image_url(image_uuid)
287
        self._get_image(image_url)  # Assert that it is an image
288
        # Unregister the image by removing all metadata from domain
289
        # 'PLANKTON_DOMAIN'
290
        meta = self._get_meta(image_url)
291
        for k in meta.keys():
292
            meta[k] = ""
293
        self._update_meta(image_url, meta, False)
294

    
295
    @handle_backend_exceptions
296
    def add_user(self, image_uuid, add_user):
297
        """Add a user as an image member.
298

299
        Update read permissions of Pithos file, to include the specified user.
300

301
        """
302
        image_url = self._get_image_url(image_uuid)
303
        self._get_image(image_url)  # Assert that it is an image
304
        permissions = self._get_permissions(image_url)
305
        read = set(permissions.get("read", []))
306
        assert(isinstance(add_user, (str, unicode)))
307
        read.add(add_user)
308
        permissions["read"] = list(read)
309
        self._update_permissions(image_url, permissions)
310

    
311
    @handle_backend_exceptions
312
    def remove_user(self, image_uuid, remove_user):
313
        """Remove the user from image members.
314

315
        Remove the specified user from the read permissions of the Pithos file.
316

317
        """
318
        image_url = self._get_image_url(image_uuid)
319
        self._get_image(image_url)  # Assert that it is an image
320
        permissions = self._get_permissions(image_url)
321
        read = set(permissions.get("read", []))
322
        assert(isinstance(remove_user, (str, unicode)))
323
        try:
324
            read.remove(remove_user)
325
        except ValueError:
326
            return  # TODO: User did not have access
327
        permissions["read"] = list(read)
328
        self._update_permissions(image_url, permissions)
329

    
330
    @handle_backend_exceptions
331
    def replace_users(self, image_uuid, replace_users):
332
        """Replace image members.
333

334
        Replace the read permissions of the Pithos files with the specified
335
        users. If image is specified as public, we must preserve * permission.
336

337
        """
338
        image_url = self._get_image_url(image_uuid)
339
        image = self._get_image(image_url)
340
        permissions = self._get_permissions(image_url)
341
        assert(isinstance(replace_users, list))
342
        permissions["read"] = replace_users
343
        if image.get("is_public", False):
344
            permissions["read"].append("*")
345
        self._update_permissions(image_url, permissions)
346

    
347
    @handle_backend_exceptions
348
    def list_users(self, image_uuid):
349
        """List the image members.
350

351
        List the image members, by listing all users that have read permission
352
        to the corresponding Pithos file.
353

354
        """
355
        image_url = self._get_image_url(image_uuid)
356
        self._get_image(image_url)  # Assert that it is an image
357
        permissions = self._get_permissions(image_url)
358
        return [user for user in permissions.get('read', []) if user != '*']
359

    
360
    @handle_backend_exceptions
361
    def update_metadata(self, image_uuid, metadata):
362
        """Update Image metadata."""
363
        image_url = self._get_image_url(image_uuid)
364
        self._get_image(image_url)  # Assert that it is an image
365

    
366
        is_public = metadata.pop("is_public", None)
367
        if is_public is not None:
368
            permissions = self._get_permissions(image_url)
369
            read = set(permissions.get("read", []))
370
            if is_public:
371
                read.add("*")
372
            else:
373
                read.discard("*")
374
            permissions["read"] = list(read)
375
            self._update_permissions(image_url, permissions)
376
        meta = {}
377
        meta["properties"] = metadata.pop("properties", {})
378
        meta.update(**metadata)
379

    
380
        self._update_meta(image_url, meta)
381
        return self.get_image(image_uuid)
382

    
383
    @handle_backend_exceptions
384
    def register(self, name, image_url, metadata):
385
        # Validate that metadata are allowed
386
        if "id" in metadata:
387
            raise ValueError("Passing an ID is not supported")
388
        store = metadata.pop("store", "pithos")
389
        if store != "pithos":
390
            raise ValueError("Invalid store '%s'. Only 'pithos' store is"
391
                             "supported" % store)
392
        disk_format = metadata.setdefault("disk_format",
393
                                          settings.DEFAULT_DISK_FORMAT)
394
        if disk_format not in settings.ALLOWED_DISK_FORMATS:
395
            raise ValueError("Invalid disk format '%s'" % disk_format)
396
        container_format =\
397
            metadata.setdefault("container_format",
398
                                settings.DEFAULT_CONTAINER_FORMAT)
399
        if container_format not in settings.ALLOWED_CONTAINER_FORMATS:
400
            raise ValueError("Invalid container format '%s'" %
401
                             container_format)
402

    
403
        # Validate that 'size' and 'checksum' are valid
404
        account, container, object = split_url(image_url)
405

    
406
        meta = self._get_meta(image_url)
407

    
408
        size = int(metadata.pop('size', meta['bytes']))
409
        if size != meta['bytes']:
410
            raise ValueError("Invalid size")
411

    
412
        checksum = metadata.pop('checksum', meta['hash'])
413
        if checksum != meta['hash']:
414
            raise ValueError("Invalid checksum")
415

    
416
        # Fix permissions
417
        is_public = metadata.pop('is_public', False)
418
        if is_public:
419
            permissions = {'read': ['*']}
420
        else:
421
            permissions = {'read': [self.user]}
422

    
423
        # Update rest metadata
424
        meta = {}
425
        meta['properties'] = metadata.pop('properties', {})
426
        meta.update(name=name, status='available', **metadata)
427

    
428
        # Do the actualy update in the Pithos backend
429
        self._update_meta(image_url, meta)
430
        self._update_permissions(image_url, permissions)
431
        return self._get_image(image_url)
432

    
433
    # TODO: Fix all these
434
    def _iter(self, public=False, filters=None, shared_from=None):
435
        filters = filters or {}
436

    
437
        # Fix keys
438
        keys = [PLANKTON_PREFIX + 'name']
439
        size_range = (None, None)
440
        for key, val in filters.items():
441
            if key == 'size_min':
442
                size_range = (val, size_range[1])
443
            elif key == 'size_max':
444
                size_range = (size_range[0], val)
445
            else:
446
                keys.append('%s = %s' % (PLANKTON_PREFIX + key, val))
447

    
448
        backend = self.backend
449
        if shared_from:
450
            # To get shared images, we connect as shared_from member and
451
            # get the list shared by us
452
            user = shared_from
453
            accounts = [self.user]
454
        else:
455
            user = None if public else self.user
456
            accounts = backend.list_accounts(user)
457

    
458
        for account in accounts:
459
            for container in backend.list_containers(user, account,
460
                                                     shared=True):
461
                for path, _ in backend.list_objects(user, account, container,
462
                                                    domain=PLANKTON_DOMAIN,
463
                                                    keys=keys, shared=True,
464
                                                    size_range=size_range):
465
                    location = get_location(account, container, path)
466
                    image = self._get_image(location)
467
                    if image:
468
                        yield image
469

    
470
    @handle_backend_exceptions
471
    def iter(self, filters=None):
472
        """Iter over all images available to the user"""
473
        return self._iter(filters=filters)
474

    
475
    @handle_backend_exceptions
476
    def iter_public(self, filters=None):
477
        """Iter over public images"""
478
        return self._iter(public=True, filters=filters)
479

    
480
    @handle_backend_exceptions
481
    def iter_shared(self, filters=None, member=None):
482
        """Iter over images shared to member"""
483
        return self._iter(filters=filters, shared_from=member)
484

    
485
    @handle_backend_exceptions
486
    def list(self, filters=None, params={}):
487
        """Return all images available to the user"""
488
        images = list(self.iter(filters))
489
        key = itemgetter(params.get('sort_key', 'created_at'))
490
        reverse = params.get('sort_dir', 'desc') == 'desc'
491
        images.sort(key=key, reverse=reverse)
492
        return images
493

    
494
    @handle_backend_exceptions
495
    def list_public(self, filters, params={}):
496
        """Return public images"""
497
        images = list(self.iter_public(filters))
498
        key = itemgetter(params.get('sort_key', 'created_at'))
499
        reverse = params.get('sort_dir', 'desc') == 'desc'
500
        images.sort(key=key, reverse=reverse)
501
        return images
502

    
503

    
504
class ImageBackendError(Exception):
505
    pass
506

    
507

    
508
class ImageNotFound(ImageBackendError):
509
    pass
510

    
511

    
512
class Forbidden(ImageBackendError):
513
    pass