Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / api / util.py @ be1dca04

History | View | Annotate | Download (17.9 kB)

1
# Copyright 2011-2012 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

    
34
import datetime
35

    
36
from base64 import b64encode, b64decode
37
from datetime import timedelta, tzinfo
38
from functools import wraps
39
from hashlib import sha256
40
from logging import getLogger
41
from random import choice
42
from string import digits, lowercase, uppercase
43
from time import time
44
from traceback import format_exc
45
from wsgiref.handlers import format_date_time
46

    
47
import dateutil.parser
48

    
49
from Crypto.Cipher import AES
50

    
51
from django.conf import settings
52
from django.http import HttpResponse
53
from django.template.loader import render_to_string
54
from django.utils import simplejson as json
55
from django.utils.cache import add_never_cache_headers
56
from django.db.models import Q
57

    
58
from synnefo.api.faults import (Fault, BadRequest, BuildInProgress,
59
                                ItemNotFound, ServiceUnavailable, Unauthorized,
60
                                BadMediaType, Forbidden, OverLimit)
61
from synnefo.db.models import (Flavor, VirtualMachine, VirtualMachineMetadata,
62
                               Network, BackendNetwork, NetworkInterface,
63
                               BridgePoolTable, MacPrefixPoolTable)
64
from synnefo.db.pools import EmptyPool
65

    
66
from synnefo.lib.astakos import get_user
67
from synnefo.plankton.backend import ImageBackend, NotAllowedError
68
from synnefo.settings import MAX_CIDR_BLOCK
69

    
70

    
71
log = getLogger('synnefo.api')
72

    
73

    
74
class UTC(tzinfo):
75
    def utcoffset(self, dt):
76
        return timedelta(0)
77

    
78
    def tzname(self, dt):
79
        return 'UTC'
80

    
81
    def dst(self, dt):
82
        return timedelta(0)
83

    
84

    
85
def isoformat(d):
86
    """Return an ISO8601 date string that includes a timezone."""
87

    
88
    return d.replace(tzinfo=UTC()).isoformat()
89

    
90

    
91
def isoparse(s):
92
    """Parse an ISO8601 date string into a datetime object."""
93

    
94
    if not s:
95
        return None
96

    
97
    try:
98
        since = dateutil.parser.parse(s)
99
        utc_since = since.astimezone(UTC()).replace(tzinfo=None)
100
    except ValueError:
101
        raise BadRequest('Invalid changes-since parameter.')
102

    
103
    now = datetime.datetime.now()
104
    if utc_since > now:
105
        raise BadRequest('changes-since value set in the future.')
106

    
107
    if now - utc_since > timedelta(seconds=settings.POLL_LIMIT):
108
        raise BadRequest('Too old changes-since value.')
109

    
110
    return utc_since
111

    
112

    
113
def random_password():
114
    """Generates a random password
115

116
    We generate a windows compliant password: it must contain at least
117
    one charachter from each of the groups: upper case, lower case, digits.
118
    """
119

    
120
    pool = lowercase + uppercase + digits
121
    lowerset = set(lowercase)
122
    upperset = set(uppercase)
123
    digitset = set(digits)
124
    length = 10
125

    
126
    password = ''.join(choice(pool) for i in range(length - 2))
127

    
128
    # Make sure the password is compliant
129
    chars = set(password)
130
    if not chars & lowerset:
131
        password += choice(lowercase)
132
    if not chars & upperset:
133
        password += choice(uppercase)
134
    if not chars & digitset:
135
        password += choice(digits)
136

    
137
    # Pad if necessary to reach required length
138
    password += ''.join(choice(pool) for i in range(length - len(password)))
139

    
140
    return password
141

    
142

    
143
def zeropad(s):
144
    """Add zeros at the end of a string in order to make its length
145
       a multiple of 16."""
146

    
147
    npad = 16 - len(s) % 16
148
    return s + '\x00' * npad
149

    
150

    
151
def encrypt(plaintext):
152
    # Make sure key is 32 bytes long
153
    key = sha256(settings.SECRET_KEY).digest()
154

    
155
    aes = AES.new(key)
156
    enc = aes.encrypt(zeropad(plaintext))
157
    return b64encode(enc)
158

    
159

    
160
def get_vm(server_id, user_id, for_update=False, non_deleted=False,
161
           non_suspended=False):
162
    """Find a VirtualMachine instance based on ID and owner."""
163

    
164
    try:
165
        server_id = int(server_id)
166
        servers = VirtualMachine.objects
167
        if for_update:
168
            servers = servers.select_for_update()
169
        vm = servers.get(id=server_id, userid=user_id)
170
        if non_deleted and vm.deleted:
171
            raise VirtualMachine.DeletedError
172
        if non_suspended and vm.suspended:
173
            raise Forbidden("Administratively Suspended VM")
174
        return vm
175
    except ValueError:
176
        raise BadRequest('Invalid server ID.')
177
    except VirtualMachine.DoesNotExist:
178
        raise ItemNotFound('Server not found.')
179

    
180

    
181
def get_vm_meta(vm, key):
182
    """Return a VirtualMachineMetadata instance or raise ItemNotFound."""
183

    
184
    try:
185
        return VirtualMachineMetadata.objects.get(meta_key=key, vm=vm)
186
    except VirtualMachineMetadata.DoesNotExist:
187
        raise ItemNotFound('Metadata key not found.')
188

    
189

    
190
def get_image(image_id, user_id):
191
    """Return an Image instance or raise ItemNotFound."""
192

    
193
    backend = ImageBackend(user_id)
194
    try:
195
        image = backend.get_image(image_id)
196
        if not image:
197
            raise ItemNotFound('Image not found.')
198
        return image
199
    finally:
200
        backend.close()
201

    
202

    
203
def get_image_dict(image_id, user_id):
204
    image = {}
205
    img = get_image(image_id, user_id)
206
    properties = img.get('properties', {})
207
    image['backend_id'] = img['location']
208
    image['format'] = img['disk_format']
209
    image['metadata'] = dict((key.upper(), val)
210
                             for key, val in properties.items())
211
    image['checksum'] = img['checksum']
212

    
213
    return image
214

    
215

    
216
def get_flavor(flavor_id, include_deleted=False):
217
    """Return a Flavor instance or raise ItemNotFound."""
218

    
219
    try:
220
        flavor_id = int(flavor_id)
221
        if include_deleted:
222
            return Flavor.objects.get(id=flavor_id)
223
        else:
224
            return Flavor.objects.get(id=flavor_id, deleted=include_deleted)
225
    except (ValueError, Flavor.DoesNotExist):
226
        raise ItemNotFound('Flavor not found.')
227

    
228

    
229
def get_network(network_id, user_id, for_update=False):
230
    """Return a Network instance or raise ItemNotFound."""
231

    
232
    try:
233
        network_id = int(network_id)
234
        objects = Network.objects
235
        if for_update:
236
            objects = objects.select_for_update()
237
        return objects.get(Q(userid=user_id) | Q(public=True), id=network_id)
238
    except (ValueError, Network.DoesNotExist):
239
        raise ItemNotFound('Network not found.')
240

    
241

    
242
def validate_network_size(cidr_block):
243
    """Return True if network size is allowed."""
244
    return cidr_block <= 29 and cidr_block > MAX_CIDR_BLOCK
245

    
246

    
247
def allocate_public_address(backend):
248
    """Allocate a public IP for a vm."""
249
    for network in backend_public_networks(backend):
250
        try:
251
            address = get_network_free_address(network)
252
            return (network, address)
253
        except EmptyPool:
254
            pass
255
    return (None, None)
256

    
257

    
258
def get_public_ip(backend):
259
    """Reserve an IP from a public network.
260

261
    This method should run inside a transaction.
262

263
    """
264
    address = None
265
    if settings.PUBLIC_USE_POOL:
266
        (network, address) = allocate_public_address(backend)
267
    else:
268
        for net in list(backend_public_networks(backend)):
269
            pool = net.get_pool()
270
            if not pool.empty():
271
                address = 'pool'
272
                network = net
273
                break
274
    if address is None:
275
        log.error("Public networks of backend %s are full", backend)
276
        raise OverLimit("Can not allocate IP for new machine."
277
                        " Public networks are full.")
278
    return (network, address)
279

    
280

    
281
def backend_public_networks(backend):
282
    """Return available public networks of the backend.
283

284
    Iterator for non-deleted public networks that are available
285
    to the specified backend.
286

287
    """
288
    for network in Network.objects.filter(public=True, deleted=False):
289
        if BackendNetwork.objects.filter(network=network,
290
                                         backend=backend).exists():
291
            yield network
292

    
293

    
294
def get_network_free_address(network):
295
    """Reserve an IP address from the IP Pool of the network.
296

297
    Raises EmptyPool
298

299
    """
300

    
301
    pool = network.get_pool()
302
    address = pool.get()
303
    pool.save()
304
    return address
305

    
306

    
307
def get_nic(machine, network):
308
    try:
309
        return NetworkInterface.objects.get(machine=machine, network=network)
310
    except NetworkInterface.DoesNotExist:
311
        raise ItemNotFound('Server not connected to this network.')
312

    
313

    
314
def get_nic_from_index(vm, nic_index):
315
    """Returns the nic_index-th nic of a vm
316
       Error Response Codes: itemNotFound (404), badMediaType (415)
317
    """
318
    matching_nics = vm.nics.filter(index=nic_index)
319
    matching_nics_len = len(matching_nics)
320
    if matching_nics_len < 1:
321
        raise ItemNotFound('NIC not found on VM')
322
    elif matching_nics_len > 1:
323
        raise BadMediaType('NIC index conflict on VM')
324
    nic = matching_nics[0]
325
    return nic
326

    
327

    
328
def get_request_dict(request):
329
    """Returns data sent by the client as a python dict."""
330

    
331
    data = request.raw_post_data
332
    if request.META.get('CONTENT_TYPE').startswith('application/json'):
333
        try:
334
            return json.loads(data)
335
        except ValueError:
336
            raise BadRequest('Invalid JSON data.')
337
    else:
338
        raise BadRequest('Unsupported Content-Type.')
339

    
340

    
341
def update_response_headers(request, response):
342
    if request.serialization == 'xml':
343
        response['Content-Type'] = 'application/xml'
344
    elif request.serialization == 'atom':
345
        response['Content-Type'] = 'application/atom+xml'
346
    else:
347
        response['Content-Type'] = 'application/json'
348

    
349
    if settings.TEST:
350
        response['Date'] = format_date_time(time())
351

    
352
    add_never_cache_headers(response)
353

    
354

    
355
def render_metadata(request, metadata, use_values=False, status=200):
356
    if request.serialization == 'xml':
357
        data = render_to_string('metadata.xml', {'metadata': metadata})
358
    else:
359
        if use_values:
360
            d = {'metadata': {'values': metadata}}
361
        else:
362
            d = {'metadata': metadata}
363
        data = json.dumps(d)
364
    return HttpResponse(data, status=status)
365

    
366

    
367
def render_meta(request, meta, status=200):
368
    if request.serialization == 'xml':
369
        data = render_to_string('meta.xml', dict(key=key, val=val))
370
    else:
371
        data = json.dumps(dict(meta=meta))
372
    return HttpResponse(data, status=status)
373

    
374

    
375
def render_fault(request, fault):
376
    if settings.DEBUG or settings.TEST:
377
        fault.details = format_exc(fault)
378

    
379
    if request.serialization == 'xml':
380
        data = render_to_string('fault.xml', {'fault': fault})
381
    else:
382
        d = {fault.name: {'code': fault.code,
383
                          'message': fault.message,
384
                          'details': fault.details}}
385
        data = json.dumps(d)
386

    
387
    resp = HttpResponse(data, status=fault.code)
388
    update_response_headers(request, resp)
389
    return resp
390

    
391

    
392
def request_serialization(request, atom_allowed=False):
393
    """Return the serialization format requested.
394

395
    Valid formats are 'json', 'xml' and 'atom' if `atom_allowed` is True.
396
    """
397

    
398
    path = request.path
399

    
400
    if path.endswith('.json'):
401
        return 'json'
402
    elif path.endswith('.xml'):
403
        return 'xml'
404
    elif atom_allowed and path.endswith('.atom'):
405
        return 'atom'
406

    
407
    for item in request.META.get('HTTP_ACCEPT', '').split(','):
408
        accept, sep, rest = item.strip().partition(';')
409
        if accept == 'application/json':
410
            return 'json'
411
        elif accept == 'application/xml':
412
            return 'xml'
413
        elif atom_allowed and accept == 'application/atom+xml':
414
            return 'atom'
415

    
416
    return 'json'
417

    
418

    
419
def api_method(http_method=None, atom_allowed=False):
420
    """Decorator function for views that implement an API method."""
421

    
422
    def decorator(func):
423
        @wraps(func)
424
        def wrapper(request, *args, **kwargs):
425
            try:
426
                request.serialization = request_serialization(request,
427
                                                              atom_allowed)
428
                get_user(request, settings.ASTAKOS_URL)
429
                if not request.user_uniq:
430
                    raise Unauthorized('No user found.')
431
                if http_method and request.method != http_method:
432
                    raise BadRequest('Method not allowed.')
433

    
434
                resp = func(request, *args, **kwargs)
435
                update_response_headers(request, resp)
436
                return resp
437
            except VirtualMachine.DeletedError:
438
                fault = BadRequest('Server has been deleted.')
439
                return render_fault(request, fault)
440
            except Network.DeletedError:
441
                fault = BadRequest('Network has been deleted.')
442
                return render_fault(request, fault)
443
            except VirtualMachine.BuildingError:
444
                fault = BuildInProgress('Server is being built.')
445
                return render_fault(request, fault)
446
            except NotAllowedError:
447
                # Image Backend Unathorized
448
                fault = Forbidden('Request not allowed.')
449
                return render_fault(request, fault)
450
            except Fault, fault:
451
                if fault.code >= 500:
452
                    log.exception('API fault')
453
                return render_fault(request, fault)
454
            except BaseException:
455
                log.exception('Unexpected error')
456
                fault = ServiceUnavailable('Unexpected error.')
457
                return render_fault(request, fault)
458
        return wrapper
459
    return decorator
460

    
461

    
462
def construct_nic_id(nic):
463
    return "-".join(["nic", unicode(nic.machine.id), unicode(nic.index)])
464

    
465

    
466
def verify_personality(personality):
467
    """Verify that a a list of personalities is well formed"""
468
    if len(personality) > settings.MAX_PERSONALITY:
469
        raise OverLimit("Maximum number of personalities"
470
                        " exceeded")
471
    for p in personality:
472
        # Verify that personalities are well-formed
473
        try:
474
            assert isinstance(p, dict)
475
            keys = set(p.keys())
476
            allowed = set(['contents', 'group', 'mode', 'owner', 'path'])
477
            assert keys.issubset(allowed)
478
            contents = p['contents']
479
            if len(contents) > settings.MAX_PERSONALITY_SIZE:
480
                # No need to decode if contents already exceed limit
481
                raise OverLimit("Maximum size of personality exceeded")
482
            if len(b64decode(contents)) > settings.MAX_PERSONALITY_SIZE:
483
                raise OverLimit("Maximum size of personality exceeded")
484
        except AssertionError:
485
            raise BadRequest("Malformed personality in request")
486

    
487

    
488
def get_flavor_provider(flavor):
489
    """Extract provider from disk template.
490

491
    Provider for `ext` disk_template is encoded in the disk template
492
    name, which is formed `ext_<provider_name>`. Provider is None
493
    for all other disk templates.
494

495
    """
496
    disk_template = flavor.disk_template
497
    provider = None
498
    if disk_template.startswith("ext"):
499
        disk_template, provider = disk_template.split("_", 1)
500
    return disk_template, provider
501

    
502

    
503
def values_from_flavor(flavor):
504
    """Get Ganeti connectivity info from flavor type.
505

506
    If link or mac_prefix equals to "pool", then the resources
507
    are allocated from the corresponding Pools.
508

509
    """
510
    try:
511
        flavor = Network.FLAVORS[flavor]
512
    except KeyError:
513
        raise BadRequest("Unknown network flavor")
514

    
515
    mode = flavor.get("mode")
516

    
517
    link = flavor.get("link")
518
    if link == "pool":
519
        link = allocate_resource("bridge")
520

    
521
    mac_prefix = flavor.get("mac_prefix")
522
    if mac_prefix == "pool":
523
        mac_prefix = allocate_resource("mac_prefix")
524

    
525
    tags = flavor.get("tags")
526

    
527
    return mode, link, mac_prefix, tags
528

    
529

    
530
def allocate_resource(res_type):
531
    table = get_pool_table(res_type)
532
    pool = table.get_pool()
533
    value = pool.get()
534
    pool.save()
535
    return value
536

    
537

    
538
def release_resource(res_type, value):
539
    table = get_pool_table(res_type)
540
    pool = table.get_pool()
541
    pool.put(value)
542
    pool.save()
543

    
544

    
545
def get_pool_table(res_type):
546
    if res_type == "bridge":
547
        return BridgePoolTable
548
    elif res_type == "mac_prefix":
549
        return MacPrefixPoolTable
550
    else:
551
        raise Exception("Unknown resource type")
552

    
553

    
554
def get_existing_users():
555
    """
556
    Retrieve user ids stored in cyclades user agnostic models.
557
    """
558
    # also check PublicKeys a user with no servers/networks exist
559
    from synnefo.ui.userdata.models import PublicKeyPair
560
    from synnefo.db.models import VirtualMachine, Network
561

    
562
    keypairusernames = PublicKeyPair.objects.filter().values_list('user',
563
                                                                  flat=True)
564
    serverusernames = VirtualMachine.objects.filter().values_list('userid',
565
                                                                  flat=True)
566
    networkusernames = Network.objects.filter().values_list('userid',
567
                                                            flat=True)
568

    
569
    return set(list(keypairusernames) + list(serverusernames) +
570
               list(networkusernames))