Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.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

    
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
from ipaddr import IPNetwork
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_subnet(subnet):
244
    try:
245
        # Use strict option to not all subnets with host bits set
246
        network = IPNetwork(subnet, strict=True)
247
    except ValueError:
248
        raise BadRequest("Invalid network subnet")
249

    
250
    # Check that network size is allowed!
251
    if not validate_network_size(network.prefixlen):
252
        raise OverLimit("Unsupported network size")
253

    
254

    
255
def validate_network_size(cidr_block):
256
    """Return True if network size is allowed."""
257
    return cidr_block <= 29 and cidr_block > MAX_CIDR_BLOCK
258

    
259

    
260
def allocate_public_address(backend):
261
    """Allocate a public IP for a vm."""
262
    for network in backend_public_networks(backend):
263
        try:
264
            address = get_network_free_address(network)
265
            return (network, address)
266
        except EmptyPool:
267
            pass
268
    return (None, None)
269

    
270

    
271
def get_public_ip(backend):
272
    """Reserve an IP from a public network.
273

274
    This method should run inside a transaction.
275

276
    """
277
    address = None
278
    if settings.PUBLIC_USE_POOL:
279
        (network, address) = allocate_public_address(backend)
280
    else:
281
        for net in list(backend_public_networks(backend)):
282
            pool = net.get_pool()
283
            if not pool.empty():
284
                address = 'pool'
285
                network = net
286
                break
287
    if address is None:
288
        log.error("Public networks of backend %s are full", backend)
289
        raise OverLimit("Can not allocate IP for new machine."
290
                        " Public networks are full.")
291
    return (network, address)
292

    
293

    
294
def backend_public_networks(backend):
295
    """Return available public networks of the backend.
296

297
    Iterator for non-deleted public networks that are available
298
    to the specified backend.
299

300
    """
301
    for network in Network.objects.filter(public=True, deleted=False):
302
        if BackendNetwork.objects.filter(network=network,
303
                                         backend=backend).exists():
304
            yield network
305

    
306

    
307
def get_network_free_address(network):
308
    """Reserve an IP address from the IP Pool of the network.
309

310
    Raises EmptyPool
311

312
    """
313

    
314
    pool = network.get_pool()
315
    address = pool.get()
316
    pool.save()
317
    return address
318

    
319

    
320
def get_nic(machine, network):
321
    try:
322
        return NetworkInterface.objects.get(machine=machine, network=network)
323
    except NetworkInterface.DoesNotExist:
324
        raise ItemNotFound('Server not connected to this network.')
325

    
326

    
327
def get_nic_from_index(vm, nic_index):
328
    """Returns the nic_index-th nic of a vm
329
       Error Response Codes: itemNotFound (404), badMediaType (415)
330
    """
331
    matching_nics = vm.nics.filter(index=nic_index)
332
    matching_nics_len = len(matching_nics)
333
    if matching_nics_len < 1:
334
        raise ItemNotFound('NIC not found on VM')
335
    elif matching_nics_len > 1:
336
        raise BadMediaType('NIC index conflict on VM')
337
    nic = matching_nics[0]
338
    return nic
339

    
340

    
341
def get_request_dict(request):
342
    """Returns data sent by the client as a python dict."""
343

    
344
    data = request.raw_post_data
345
    if request.META.get('CONTENT_TYPE').startswith('application/json'):
346
        try:
347
            return json.loads(data)
348
        except ValueError:
349
            raise BadRequest('Invalid JSON data.')
350
    else:
351
        raise BadRequest('Unsupported Content-Type.')
352

    
353

    
354
def update_response_headers(request, response):
355
    if request.serialization == 'xml':
356
        response['Content-Type'] = 'application/xml'
357
    elif request.serialization == 'atom':
358
        response['Content-Type'] = 'application/atom+xml'
359
    else:
360
        response['Content-Type'] = 'application/json'
361

    
362
    if settings.TEST:
363
        response['Date'] = format_date_time(time())
364

    
365
    add_never_cache_headers(response)
366

    
367

    
368
def render_metadata(request, metadata, use_values=False, status=200):
369
    if request.serialization == 'xml':
370
        data = render_to_string('metadata.xml', {'metadata': metadata})
371
    else:
372
        if use_values:
373
            d = {'metadata': {'values': metadata}}
374
        else:
375
            d = {'metadata': metadata}
376
        data = json.dumps(d)
377
    return HttpResponse(data, status=status)
378

    
379

    
380
def render_meta(request, meta, status=200):
381
    if request.serialization == 'xml':
382
        data = render_to_string('meta.xml', dict(key=key, val=val))
383
    else:
384
        data = json.dumps(dict(meta=meta))
385
    return HttpResponse(data, status=status)
386

    
387

    
388
def render_fault(request, fault):
389
    if settings.DEBUG or settings.TEST:
390
        fault.details = format_exc(fault)
391

    
392
    if request.serialization == 'xml':
393
        data = render_to_string('fault.xml', {'fault': fault})
394
    else:
395
        d = {fault.name: {'code': fault.code,
396
                          'message': fault.message,
397
                          'details': fault.details}}
398
        data = json.dumps(d)
399

    
400
    resp = HttpResponse(data, status=fault.code)
401
    update_response_headers(request, resp)
402
    return resp
403

    
404

    
405
def request_serialization(request, atom_allowed=False):
406
    """Return the serialization format requested.
407

408
    Valid formats are 'json', 'xml' and 'atom' if `atom_allowed` is True.
409
    """
410

    
411
    path = request.path
412

    
413
    if path.endswith('.json'):
414
        return 'json'
415
    elif path.endswith('.xml'):
416
        return 'xml'
417
    elif atom_allowed and path.endswith('.atom'):
418
        return 'atom'
419

    
420
    for item in request.META.get('HTTP_ACCEPT', '').split(','):
421
        accept, sep, rest = item.strip().partition(';')
422
        if accept == 'application/json':
423
            return 'json'
424
        elif accept == 'application/xml':
425
            return 'xml'
426
        elif atom_allowed and accept == 'application/atom+xml':
427
            return 'atom'
428

    
429
    return 'json'
430

    
431

    
432
def api_method(http_method=None, atom_allowed=False):
433
    """Decorator function for views that implement an API method."""
434

    
435
    def decorator(func):
436
        @wraps(func)
437
        def wrapper(request, *args, **kwargs):
438
            try:
439
                request.serialization = request_serialization(request,
440
                                                              atom_allowed)
441
                get_user(request, settings.ASTAKOS_URL)
442
                if not request.user_uniq:
443
                    raise Unauthorized('No user found.')
444
                if http_method and request.method != http_method:
445
                    raise BadRequest('Method not allowed.')
446

    
447
                resp = func(request, *args, **kwargs)
448
                update_response_headers(request, resp)
449
                return resp
450
            except VirtualMachine.DeletedError:
451
                fault = BadRequest('Server has been deleted.')
452
                return render_fault(request, fault)
453
            except Network.DeletedError:
454
                fault = BadRequest('Network has been deleted.')
455
                return render_fault(request, fault)
456
            except VirtualMachine.BuildingError:
457
                fault = BuildInProgress('Server is being built.')
458
                return render_fault(request, fault)
459
            except NotAllowedError:
460
                # Image Backend Unathorized
461
                fault = Forbidden('Request not allowed.')
462
                return render_fault(request, fault)
463
            except Fault, fault:
464
                if fault.code >= 500:
465
                    log.exception('API fault')
466
                return render_fault(request, fault)
467
            except BaseException:
468
                log.exception('Unexpected error')
469
                fault = ServiceUnavailable('Unexpected error.')
470
                return render_fault(request, fault)
471
        return wrapper
472
    return decorator
473

    
474

    
475
def construct_nic_id(nic):
476
    return "-".join(["nic", unicode(nic.machine.id), unicode(nic.index)])
477

    
478

    
479
def verify_personality(personality):
480
    """Verify that a a list of personalities is well formed"""
481
    if len(personality) > settings.MAX_PERSONALITY:
482
        raise OverLimit("Maximum number of personalities"
483
                        " exceeded")
484
    for p in personality:
485
        # Verify that personalities are well-formed
486
        try:
487
            assert isinstance(p, dict)
488
            keys = set(p.keys())
489
            allowed = set(['contents', 'group', 'mode', 'owner', 'path'])
490
            assert keys.issubset(allowed)
491
            contents = p['contents']
492
            if len(contents) > settings.MAX_PERSONALITY_SIZE:
493
                # No need to decode if contents already exceed limit
494
                raise OverLimit("Maximum size of personality exceeded")
495
            if len(b64decode(contents)) > settings.MAX_PERSONALITY_SIZE:
496
                raise OverLimit("Maximum size of personality exceeded")
497
        except AssertionError:
498
            raise BadRequest("Malformed personality in request")
499

    
500

    
501
def get_flavor_provider(flavor):
502
    """Extract provider from disk template.
503

504
    Provider for `ext` disk_template is encoded in the disk template
505
    name, which is formed `ext_<provider_name>`. Provider is None
506
    for all other disk templates.
507

508
    """
509
    disk_template = flavor.disk_template
510
    provider = None
511
    if disk_template.startswith("ext"):
512
        disk_template, provider = disk_template.split("_", 1)
513
    return disk_template, provider
514

    
515

    
516
def values_from_flavor(flavor):
517
    """Get Ganeti connectivity info from flavor type.
518

519
    If link or mac_prefix equals to "pool", then the resources
520
    are allocated from the corresponding Pools.
521

522
    """
523
    try:
524
        flavor = Network.FLAVORS[flavor]
525
    except KeyError:
526
        raise BadRequest("Unknown network flavor")
527

    
528
    mode = flavor.get("mode")
529

    
530
    link = flavor.get("link")
531
    if link == "pool":
532
        link = allocate_resource("bridge")
533

    
534
    mac_prefix = flavor.get("mac_prefix")
535
    if mac_prefix == "pool":
536
        mac_prefix = allocate_resource("mac_prefix")
537

    
538
    tags = flavor.get("tags")
539

    
540
    return mode, link, mac_prefix, tags
541

    
542

    
543
def allocate_resource(res_type):
544
    table = get_pool_table(res_type)
545
    pool = table.get_pool()
546
    value = pool.get()
547
    pool.save()
548
    return value
549

    
550

    
551
def release_resource(res_type, value):
552
    table = get_pool_table(res_type)
553
    pool = table.get_pool()
554
    pool.put(value)
555
    pool.save()
556

    
557

    
558
def get_pool_table(res_type):
559
    if res_type == "bridge":
560
        return BridgePoolTable
561
    elif res_type == "mac_prefix":
562
        return MacPrefixPoolTable
563
    else:
564
        raise Exception("Unknown resource type")
565

    
566

    
567
def get_existing_users():
568
    """
569
    Retrieve user ids stored in cyclades user agnostic models.
570
    """
571
    # also check PublicKeys a user with no servers/networks exist
572
    from synnefo.ui.userdata.models import PublicKeyPair
573
    from synnefo.db.models import VirtualMachine, Network
574

    
575
    keypairusernames = PublicKeyPair.objects.filter().values_list('user',
576
                                                                  flat=True)
577
    serverusernames = VirtualMachine.objects.filter().values_list('userid',
578
                                                                  flat=True)
579
    networkusernames = Network.objects.filter().values_list('userid',
580
                                                            flat=True)
581

    
582
    return set(list(keypairusernames) + list(serverusernames) +
583
               list(networkusernames))