Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (19.3 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
import ipaddr
36

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

    
48
import dateutil.parser
49

    
50
from Crypto.Cipher import AES
51

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

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

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

    
71

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

    
74

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

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

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

    
85

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

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

    
91

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

    
95
    if not s:
96
        return None
97

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

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

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

    
111
    return utc_since
112

    
113

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

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

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

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

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

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

    
141
    return password
142

    
143

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

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

    
151

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

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

    
160

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

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

    
181

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

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

    
190

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

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

    
203

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

    
214
    return image
215

    
216

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

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

    
229

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

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

    
242

    
243
def validate_network_params(subnet, gateway=None, subnet6=None, gateway6=None):
244
    try:
245
        # Use strict option to not all subnets with host bits set
246
        network = ipaddr.IPv4Network(subnet, strict=True)
247
    except ValueError:
248
        raise BadRequest("Invalid network IPv4 subnet")
249

    
250
    # Check that network size is allowed!
251
    if not validate_network_size(network.prefixlen):
252
        raise OverLimit(message="Unsupported network size",
253
                        details="Network mask must be in range (%s, 29]" %
254
                                MAX_CIDR_BLOCK)
255

    
256
    # Check that gateway belongs to network
257
    if gateway:
258
        try:
259
            gateway = ipaddr.IPv4Address(gateway)
260
        except ValueError:
261
            raise BadRequest("Invalid network IPv4 gateway")
262
        if not gateway in network:
263
            raise BadRequest("Invalid network IPv4 gateway")
264

    
265
    if subnet6:
266
        try:
267
            # Use strict option to not all subnets with host bits set
268
            network6 = ipaddr.IPv6Network(subnet6, strict=True)
269
        except ValueError:
270
            raise BadRequest("Invalid network IPv6 subnet")
271
        if gateway6:
272
            try:
273
                gateway6 = ipaddr.IPv6Address(gateway6)
274
            except ValueError:
275
                raise BadRequest("Invalid network IPv6 gateway")
276
            if not gateway6 in network6:
277
                raise BadRequest("Invalid network IPv6 gateway")
278

    
279

    
280
def validate_network_size(cidr_block):
281
    """Return True if network size is allowed."""
282
    return cidr_block <= 29 and cidr_block > MAX_CIDR_BLOCK
283

    
284

    
285
def allocate_public_address(backend):
286
    """Allocate a public IP for a vm."""
287
    for network in backend_public_networks(backend):
288
        try:
289
            address = get_network_free_address(network)
290
            return (network, address)
291
        except EmptyPool:
292
            pass
293
    return (None, None)
294

    
295

    
296
def get_public_ip(backend):
297
    """Reserve an IP from a public network.
298

299
    This method should run inside a transaction.
300

301
    """
302
    address = None
303
    if settings.PUBLIC_USE_POOL:
304
        (network, address) = allocate_public_address(backend)
305
    else:
306
        for net in list(backend_public_networks(backend)):
307
            pool = net.get_pool()
308
            if not pool.empty():
309
                address = 'pool'
310
                network = net
311
                break
312
    if address is None:
313
        log.error("Public networks of backend %s are full", backend)
314
        raise OverLimit("Can not allocate IP for new machine."
315
                        " Public networks are full.")
316
    return (network, address)
317

    
318

    
319
def backend_public_networks(backend):
320
    """Return available public networks of the backend.
321

322
    Iterator for non-deleted public networks that are available
323
    to the specified backend.
324

325
    """
326
    for network in Network.objects.filter(public=True, deleted=False):
327
        if BackendNetwork.objects.filter(network=network,
328
                                         backend=backend).exists():
329
            yield network
330

    
331

    
332
def get_network_free_address(network):
333
    """Reserve an IP address from the IP Pool of the network.
334

335
    Raises EmptyPool
336

337
    """
338

    
339
    pool = network.get_pool()
340
    address = pool.get()
341
    pool.save()
342
    return address
343

    
344

    
345
def get_nic(machine, network):
346
    try:
347
        return NetworkInterface.objects.get(machine=machine, network=network)
348
    except NetworkInterface.DoesNotExist:
349
        raise ItemNotFound('Server not connected to this network.')
350

    
351

    
352
def get_nic_from_index(vm, nic_index):
353
    """Returns the nic_index-th nic of a vm
354
       Error Response Codes: itemNotFound (404), badMediaType (415)
355
    """
356
    matching_nics = vm.nics.filter(index=nic_index)
357
    matching_nics_len = len(matching_nics)
358
    if matching_nics_len < 1:
359
        raise ItemNotFound('NIC not found on VM')
360
    elif matching_nics_len > 1:
361
        raise BadMediaType('NIC index conflict on VM')
362
    nic = matching_nics[0]
363
    return nic
364

    
365

    
366
def get_request_dict(request):
367
    """Returns data sent by the client as a python dict."""
368

    
369
    data = request.raw_post_data
370
    if request.META.get('CONTENT_TYPE').startswith('application/json'):
371
        try:
372
            return json.loads(data)
373
        except ValueError:
374
            raise BadRequest('Invalid JSON data.')
375
    else:
376
        raise BadRequest('Unsupported Content-Type.')
377

    
378

    
379
def update_response_headers(request, response):
380
    if request.serialization == 'xml':
381
        response['Content-Type'] = 'application/xml'
382
    elif request.serialization == 'atom':
383
        response['Content-Type'] = 'application/atom+xml'
384
    else:
385
        response['Content-Type'] = 'application/json'
386

    
387
    if settings.TEST:
388
        response['Date'] = format_date_time(time())
389

    
390
    add_never_cache_headers(response)
391

    
392

    
393
def render_metadata(request, metadata, use_values=False, status=200):
394
    if request.serialization == 'xml':
395
        data = render_to_string('metadata.xml', {'metadata': metadata})
396
    else:
397
        if use_values:
398
            d = {'metadata': {'values': metadata}}
399
        else:
400
            d = {'metadata': metadata}
401
        data = json.dumps(d)
402
    return HttpResponse(data, status=status)
403

    
404

    
405
def render_meta(request, meta, status=200):
406
    if request.serialization == 'xml':
407
        data = render_to_string('meta.xml', dict(key=key, val=val))
408
    else:
409
        data = json.dumps(dict(meta=meta))
410
    return HttpResponse(data, status=status)
411

    
412

    
413
def render_fault(request, fault):
414
    if settings.DEBUG or settings.TEST:
415
        fault.details = format_exc(fault)
416

    
417
    if request.serialization == 'xml':
418
        data = render_to_string('fault.xml', {'fault': fault})
419
    else:
420
        d = {fault.name: {'code': fault.code,
421
                          'message': fault.message,
422
                          'details': fault.details}}
423
        data = json.dumps(d)
424

    
425
    resp = HttpResponse(data, status=fault.code)
426
    update_response_headers(request, resp)
427
    return resp
428

    
429

    
430
def request_serialization(request, atom_allowed=False):
431
    """Return the serialization format requested.
432

433
    Valid formats are 'json', 'xml' and 'atom' if `atom_allowed` is True.
434
    """
435

    
436
    path = request.path
437

    
438
    if path.endswith('.json'):
439
        return 'json'
440
    elif path.endswith('.xml'):
441
        return 'xml'
442
    elif atom_allowed and path.endswith('.atom'):
443
        return 'atom'
444

    
445
    for item in request.META.get('HTTP_ACCEPT', '').split(','):
446
        accept, sep, rest = item.strip().partition(';')
447
        if accept == 'application/json':
448
            return 'json'
449
        elif accept == 'application/xml':
450
            return 'xml'
451
        elif atom_allowed and accept == 'application/atom+xml':
452
            return 'atom'
453

    
454
    return 'json'
455

    
456

    
457
def api_method(http_method=None, atom_allowed=False):
458
    """Decorator function for views that implement an API method."""
459

    
460
    def decorator(func):
461
        @wraps(func)
462
        def wrapper(request, *args, **kwargs):
463
            try:
464
                request.serialization = request_serialization(request,
465
                                                              atom_allowed)
466
                get_user(request, settings.ASTAKOS_URL)
467
                if not request.user_uniq:
468
                    raise Unauthorized('No user found.')
469
                if http_method and request.method != http_method:
470
                    raise BadRequest('Method not allowed.')
471

    
472
                resp = func(request, *args, **kwargs)
473
                update_response_headers(request, resp)
474
                return resp
475
            except VirtualMachine.DeletedError:
476
                fault = BadRequest('Server has been deleted.')
477
                return render_fault(request, fault)
478
            except Network.DeletedError:
479
                fault = BadRequest('Network has been deleted.')
480
                return render_fault(request, fault)
481
            except VirtualMachine.BuildingError:
482
                fault = BuildInProgress('Server is being built.')
483
                return render_fault(request, fault)
484
            except NotAllowedError:
485
                # Image Backend Unathorized
486
                fault = Forbidden('Request not allowed.')
487
                return render_fault(request, fault)
488
            except Fault, fault:
489
                if fault.code >= 500:
490
                    log.exception('API fault')
491
                return render_fault(request, fault)
492
            except BaseException:
493
                log.exception('Unexpected error')
494
                fault = ServiceUnavailable('Unexpected error.')
495
                return render_fault(request, fault)
496
        return wrapper
497
    return decorator
498

    
499

    
500
def construct_nic_id(nic):
501
    return "-".join(["nic", unicode(nic.machine.id), unicode(nic.index)])
502

    
503

    
504
def verify_personality(personality):
505
    """Verify that a a list of personalities is well formed"""
506
    if len(personality) > settings.MAX_PERSONALITY:
507
        raise OverLimit("Maximum number of personalities"
508
                        " exceeded")
509
    for p in personality:
510
        # Verify that personalities are well-formed
511
        try:
512
            assert isinstance(p, dict)
513
            keys = set(p.keys())
514
            allowed = set(['contents', 'group', 'mode', 'owner', 'path'])
515
            assert keys.issubset(allowed)
516
            contents = p['contents']
517
            if len(contents) > settings.MAX_PERSONALITY_SIZE:
518
                # No need to decode if contents already exceed limit
519
                raise OverLimit("Maximum size of personality exceeded")
520
            if len(b64decode(contents)) > settings.MAX_PERSONALITY_SIZE:
521
                raise OverLimit("Maximum size of personality exceeded")
522
        except AssertionError:
523
            raise BadRequest("Malformed personality in request")
524

    
525

    
526
def get_flavor_provider(flavor):
527
    """Extract provider from disk template.
528

529
    Provider for `ext` disk_template is encoded in the disk template
530
    name, which is formed `ext_<provider_name>`. Provider is None
531
    for all other disk templates.
532

533
    """
534
    disk_template = flavor.disk_template
535
    provider = None
536
    if disk_template.startswith("ext"):
537
        disk_template, provider = disk_template.split("_", 1)
538
    return disk_template, provider
539

    
540

    
541
def values_from_flavor(flavor):
542
    """Get Ganeti connectivity info from flavor type.
543

544
    If link or mac_prefix equals to "pool", then the resources
545
    are allocated from the corresponding Pools.
546

547
    """
548
    try:
549
        flavor = Network.FLAVORS[flavor]
550
    except KeyError:
551
        raise BadRequest("Unknown network flavor")
552

    
553
    mode = flavor.get("mode")
554

    
555
    link = flavor.get("link")
556
    if link == "pool":
557
        link = allocate_resource("bridge")
558

    
559
    mac_prefix = flavor.get("mac_prefix")
560
    if mac_prefix == "pool":
561
        mac_prefix = allocate_resource("mac_prefix")
562

    
563
    tags = flavor.get("tags")
564

    
565
    return mode, link, mac_prefix, tags
566

    
567

    
568
def allocate_resource(res_type):
569
    table = get_pool_table(res_type)
570
    pool = table.get_pool()
571
    value = pool.get()
572
    pool.save()
573
    return value
574

    
575

    
576
def release_resource(res_type, value):
577
    table = get_pool_table(res_type)
578
    pool = table.get_pool()
579
    pool.put(value)
580
    pool.save()
581

    
582

    
583
def get_pool_table(res_type):
584
    if res_type == "bridge":
585
        return BridgePoolTable
586
    elif res_type == "mac_prefix":
587
        return MacPrefixPoolTable
588
    else:
589
        raise Exception("Unknown resource type")
590

    
591

    
592
def get_existing_users():
593
    """
594
    Retrieve user ids stored in cyclades user agnostic models.
595
    """
596
    # also check PublicKeys a user with no servers/networks exist
597
    from synnefo.ui.userdata.models import PublicKeyPair
598
    from synnefo.db.models import VirtualMachine, Network
599

    
600
    keypairusernames = PublicKeyPair.objects.filter().values_list('user',
601
                                                                  flat=True)
602
    serverusernames = VirtualMachine.objects.filter().values_list('userid',
603
                                                                  flat=True)
604
    networkusernames = Network.objects.filter().values_list('userid',
605
                                                            flat=True)
606

    
607
    return set(list(keypairusernames) + list(serverusernames) +
608
               list(networkusernames))