Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.6 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 snf_django.lib.api import faults
60
from synnefo.db.models import (Flavor, VirtualMachine, VirtualMachineMetadata,
61
                               Network, BackendNetwork, NetworkInterface,
62
                               BridgePoolTable, MacPrefixPoolTable)
63
from synnefo.db.pools import EmptyPool
64

    
65
from synnefo.lib.astakos import get_user
66
from synnefo.plankton.utils import image_backend
67
from synnefo.settings import MAX_CIDR_BLOCK
68

    
69

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

    
72

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

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

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

    
83

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

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

    
89

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

    
93
    if not s:
94
        return None
95

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

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

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

    
109
    return utc_since
110

    
111

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

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

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

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

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

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

    
139
    return password
140

    
141

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

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

    
149

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

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

    
158

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

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

    
179

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

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

    
188

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

    
192
    with image_backend(user_id) as backend:
193
        image = backend.get_image(image_id)
194
        if not image:
195
            raise faults.ItemNotFound('Image not found.')
196
        return image
197

    
198

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

    
209
    return image
210

    
211

    
212
def get_flavor(flavor_id, include_deleted=False):
213
    """Return a Flavor instance or raise ItemNotFound."""
214

    
215
    try:
216
        flavor_id = int(flavor_id)
217
        if include_deleted:
218
            return Flavor.objects.get(id=flavor_id)
219
        else:
220
            return Flavor.objects.get(id=flavor_id, deleted=include_deleted)
221
    except (ValueError, Flavor.DoesNotExist):
222
        raise faults.ItemNotFound('Flavor not found.')
223

    
224

    
225
def get_network(network_id, user_id, for_update=False):
226
    """Return a Network instance or raise ItemNotFound."""
227

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

    
237

    
238
def validate_network_params(subnet, gateway=None, subnet6=None, gateway6=None):
239
    try:
240
        # Use strict option to not all subnets with host bits set
241
        network = ipaddr.IPv4Network(subnet, strict=True)
242
    except ValueError:
243
        raise faults.BadRequest("Invalid network IPv4 subnet")
244

    
245
    # Check that network size is allowed!
246
    if not validate_network_size(network.prefixlen):
247
        raise faults.OverLimit(message="Unsupported network size",
248
                        details="Network mask must be in range (%s, 29]" %
249
                                MAX_CIDR_BLOCK)
250

    
251
    # Check that gateway belongs to network
252
    if gateway:
253
        try:
254
            gateway = ipaddr.IPv4Address(gateway)
255
        except ValueError:
256
            raise faults.BadRequest("Invalid network IPv4 gateway")
257
        if not gateway in network:
258
            raise faults.BadRequest("Invalid network IPv4 gateway")
259

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

    
274

    
275
def validate_network_size(cidr_block):
276
    """Return True if network size is allowed."""
277
    return cidr_block <= 29 and cidr_block > MAX_CIDR_BLOCK
278

    
279

    
280
def allocate_public_address(backend):
281
    """Allocate a public IP for a vm."""
282
    for network in backend_public_networks(backend):
283
        try:
284
            address = get_network_free_address(network)
285
            return (network, address)
286
        except EmptyPool:
287
            pass
288
    return (None, None)
289

    
290

    
291
def get_public_ip(backend):
292
    """Reserve an IP from a public network.
293

294
    This method should run inside a transaction.
295

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

    
313

    
314
def backend_public_networks(backend):
315
    """Return available public networks of the backend.
316

317
    Iterator for non-deleted public networks that are available
318
    to the specified backend.
319

320
    """
321
    for network in Network.objects.filter(public=True, deleted=False):
322
        if BackendNetwork.objects.filter(network=network,
323
                                         backend=backend).exists():
324
            yield network
325

    
326

    
327
def get_network_free_address(network):
328
    """Reserve an IP address from the IP Pool of the network.
329

330
    Raises EmptyPool
331

332
    """
333

    
334
    pool = network.get_pool()
335
    address = pool.get()
336
    pool.save()
337
    return address
338

    
339

    
340
def get_nic(machine, network):
341
    try:
342
        return NetworkInterface.objects.get(machine=machine, network=network)
343
    except NetworkInterface.DoesNotExist:
344
        raise faults.ItemNotFound('Server not connected to this network.')
345

    
346

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

    
360

    
361
def get_request_dict(request):
362
    """Returns data sent by the client as a python dict."""
363

    
364
    data = request.raw_post_data
365
    if request.META.get('CONTENT_TYPE').startswith('application/json'):
366
        try:
367
            return json.loads(data)
368
        except ValueError:
369
            raise faults.BadRequest('Invalid JSON data.')
370
    else:
371
        raise faults.BadRequest('Unsupported Content-Type.')
372

    
373

    
374
def update_response_headers(request, response):
375
    if request.serialization == 'xml':
376
        response['Content-Type'] = 'application/xml'
377
    elif request.serialization == 'atom':
378
        response['Content-Type'] = 'application/atom+xml'
379
    else:
380
        response['Content-Type'] = 'application/json'
381

    
382
    if settings.TEST:
383
        response['Date'] = format_date_time(time())
384

    
385
    add_never_cache_headers(response)
386

    
387

    
388
def render_metadata(request, metadata, use_values=False, status=200):
389
    if request.serialization == 'xml':
390
        data = render_to_string('metadata.xml', {'metadata': metadata})
391
    else:
392
        if use_values:
393
            d = {'metadata': {'values': metadata}}
394
        else:
395
            d = {'metadata': metadata}
396
        data = json.dumps(d)
397
    return HttpResponse(data, status=status)
398

    
399

    
400
def render_meta(request, meta, status=200):
401
    if request.serialization == 'xml':
402
        data = render_to_string('meta.xml', dict(key=key, val=val))
403
    else:
404
        data = json.dumps(dict(meta=meta))
405
    return HttpResponse(data, status=status)
406

    
407

    
408
def render_fault(request, fault):
409
    if settings.DEBUG or settings.TEST:
410
        fault.details = format_exc(fault)
411

    
412
    if request.serialization == 'xml':
413
        data = render_to_string('fault.xml', {'fault': fault})
414
    else:
415
        d = {fault.name: {'code': fault.code,
416
                          'message': fault.message,
417
                          'details': fault.details}}
418
        data = json.dumps(d)
419

    
420
    resp = HttpResponse(data, status=fault.code)
421
    update_response_headers(request, resp)
422
    return resp
423

    
424

    
425
def request_serialization(request, atom_allowed=False):
426
    """Return the serialization format requested.
427

428
    Valid formats are 'json', 'xml' and 'atom' if `atom_allowed` is True.
429
    """
430

    
431
    path = request.path
432

    
433
    if path.endswith('.json'):
434
        return 'json'
435
    elif path.endswith('.xml'):
436
        return 'xml'
437
    elif atom_allowed and path.endswith('.atom'):
438
        return 'atom'
439

    
440
    for item in request.META.get('HTTP_ACCEPT', '').split(','):
441
        accept, sep, rest = item.strip().partition(';')
442
        if accept == 'application/json':
443
            return 'json'
444
        elif accept == 'application/xml':
445
            return 'xml'
446
        elif atom_allowed and accept == 'application/atom+xml':
447
            return 'atom'
448

    
449
    return 'json'
450

    
451

    
452
def api_method(http_method=None, atom_allowed=False):
453
    """Decorator function for views that implement an API method."""
454

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

    
467
                resp = func(request, *args, **kwargs)
468
                update_response_headers(request, resp)
469
                return resp
470
            except faults.Fault, fault:
471
                if fault.code >= 500:
472
                    log.exception('API fault')
473
                return render_fault(request, fault)
474
            except BaseException:
475
                log.exception('Unexpected error')
476
                fault = faults.ServiceUnavailable('Unexpected error.')
477
                return render_fault(request, fault)
478
        return wrapper
479
    return decorator
480

    
481

    
482
def construct_nic_id(nic):
483
    return "-".join(["nic", unicode(nic.machine.id), unicode(nic.index)])
484

    
485

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

    
507

    
508
def get_flavor_provider(flavor):
509
    """Extract provider from disk template.
510

511
    Provider for `ext` disk_template is encoded in the disk template
512
    name, which is formed `ext_<provider_name>`. Provider is None
513
    for all other disk templates.
514

515
    """
516
    disk_template = flavor.disk_template
517
    provider = None
518
    if disk_template.startswith("ext"):
519
        disk_template, provider = disk_template.split("_", 1)
520
    return disk_template, provider
521

    
522

    
523
def values_from_flavor(flavor):
524
    """Get Ganeti connectivity info from flavor type.
525

526
    If link or mac_prefix equals to "pool", then the resources
527
    are allocated from the corresponding Pools.
528

529
    """
530
    try:
531
        flavor = Network.FLAVORS[flavor]
532
    except KeyError:
533
        raise faults.BadRequest("Unknown network flavor")
534

    
535
    mode = flavor.get("mode")
536

    
537
    link = flavor.get("link")
538
    if link == "pool":
539
        link = allocate_resource("bridge")
540

    
541
    mac_prefix = flavor.get("mac_prefix")
542
    if mac_prefix == "pool":
543
        mac_prefix = allocate_resource("mac_prefix")
544

    
545
    tags = flavor.get("tags")
546

    
547
    return mode, link, mac_prefix, tags
548

    
549

    
550
def allocate_resource(res_type):
551
    table = get_pool_table(res_type)
552
    pool = table.get_pool()
553
    value = pool.get()
554
    pool.save()
555
    return value
556

    
557

    
558
def release_resource(res_type, value):
559
    table = get_pool_table(res_type)
560
    pool = table.get_pool()
561
    pool.put(value)
562
    pool.save()
563

    
564

    
565
def get_pool_table(res_type):
566
    if res_type == "bridge":
567
        return BridgePoolTable
568
    elif res_type == "mac_prefix":
569
        return MacPrefixPoolTable
570
    else:
571
        raise Exception("Unknown resource type")
572

    
573

    
574
def get_existing_users():
575
    """
576
    Retrieve user ids stored in cyclades user agnostic models.
577
    """
578
    # also check PublicKeys a user with no servers/networks exist
579
    from synnefo.ui.userdata.models import PublicKeyPair
580
    from synnefo.db.models import VirtualMachine, Network
581

    
582
    keypairusernames = PublicKeyPair.objects.filter().values_list('user',
583
                                                                  flat=True)
584
    serverusernames = VirtualMachine.objects.filter().values_list('userid',
585
                                                                  flat=True)
586
    networkusernames = Network.objects.filter().values_list('userid',
587
                                                            flat=True)
588

    
589
    return set(list(keypairusernames) + list(serverusernames) +
590
               list(networkusernames))