Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (15.8 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
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
    return image
212

    
213

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

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

    
226

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

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

    
239

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

    
244

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

    
255

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

259
    This method should run inside a transaction.
260

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

    
278

    
279
def backend_public_networks(backend):
280
    """Return available public networks of the backend.
281

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

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

    
291

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

295
    Raises EmptyPool
296

297
    """
298

    
299
    pool = network.get_pool()
300
    address = pool.get()
301
    pool.save()
302
    return address
303

    
304

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

    
311

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

    
325

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

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

    
338

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

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

    
350
    add_never_cache_headers(response)
351

    
352

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

    
364

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

    
372

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

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

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

    
390

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

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

    
397
    path = request.path
398

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

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

    
415
    return 'json'
416

    
417

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

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

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

    
456

    
457
def construct_nic_id(nic):
458
    return "-".join(["nic", unicode(nic.machine.id), unicode(nic.index)])
459

    
460

    
461
def net_resources(net_type):
462
    mac_prefix = settings.MAC_POOL_BASE
463
    if net_type == 'PRIVATE_MAC_FILTERED':
464
        link = settings.PRIVATE_MAC_FILTERED_BRIDGE
465
        mac_pool = MacPrefixPoolTable.get_pool()
466
        mac_prefix = mac_pool.get()
467
        mac_pool.save()
468
    elif net_type == 'PRIVATE_PHYSICAL_VLAN':
469
        pool = BridgePoolTable.get_pool()
470
        link = pool.get()
471
        pool.save()
472
    elif net_type == 'CUSTOM_ROUTED':
473
        link = settings.CUSTOM_ROUTED_ROUTING_TABLE
474
    elif net_type == 'CUSTOM_BRIDGED':
475
        link = settings.CUSTOM_BRIDGED_BRIDGE
476
    elif net_type == 'PUBLIC_ROUTED':
477
        link = settings.PUBLIC_ROUTED_ROUTING_TABLE
478
    else:
479
        raise BadRequest('Unknown network type')
480

    
481
    return link, mac_prefix
482

    
483

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