Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / logic / servers.py @ 0c09b1c0

History | View | Annotate | Download (18.1 kB)

1
import logging
2

    
3
from socket import getfqdn
4
from functools import wraps
5
from django import dispatch
6
from django.db import transaction
7
from django.utils import simplejson as json
8

    
9
from snf_django.lib.api import faults
10
from django.conf import settings
11
from synnefo import quotas
12
from synnefo.api import util
13
from synnefo.logic import backend
14
from synnefo.logic.backend_allocator import BackendAllocator
15
from synnefo.logic.rapi import GanetiApiError
16
from synnefo.db.models import (NetworkInterface, VirtualMachine, Network,
17
                               VirtualMachineMetadata, FloatingIP)
18

    
19
from vncauthproxy.client import request_forwarding as request_vnc_forwarding
20

    
21
log = logging.getLogger(__name__)
22

    
23
# server creation signal
24
server_created = dispatch.Signal(providing_args=["created_vm_params"])
25

    
26

    
27
def validate_server_action(vm, action):
28
    if vm.deleted:
29
        raise faults.BadRequest("Server '%s' has been deleted." % vm.id)
30

    
31
    # Destroyin a server should always be permitted
32
    if action == "DESTROY":
33
        return
34

    
35
    # Check that there is no pending action
36
    pending_action = vm.task
37
    if pending_action:
38
        if pending_action == "BUILD":
39
            raise faults.BuildInProgress("Server '%s' is being build." % vm.id)
40
        raise faults.BadRequest("Can not perform '%s' action while there is a"
41
                                " pending '%s'." % (action, pending_action))
42

    
43
    # Check if action can be performed to VM's operstate
44
    operstate = vm.operstate
45
    if operstate == "BUILD":
46
        raise faults.BuildInProgress("Server '%s' is being build." % vm.id)
47
    elif (action == "START" and operstate == "STARTED") or\
48
         (action == "STOP" and operstate == "STOPPED") or\
49
         (action == "RESIZE" and operstate == "STARTED"):
50
        raise faults.BadRequest("Can not perform '%s' action while server is"
51
                                " in '%s' state." % (action, operstate))
52
    return
53

    
54

    
55
def server_command(action):
56
    """Handle execution of a server action.
57

58
    Helper function to validate and execute a server action, handle quota
59
    commission and update the 'task' of the VM in the DB.
60

61
    1) Check if action can be performed. If it can, then there must be no
62
       pending task (with the exception of DESTROY).
63
    2) Handle previous commission if unresolved:
64
       * If it is not pending and it to accept, then accept
65
       * If it is not pending and to reject or is pending then reject it. Since
66
       the action can be performed only if there is no pending task, then there
67
       can be no pending commission. The exception is DESTROY, but in this case
68
       the commission can safely be rejected, and the dispatcher will generate
69
       the correct ones!
70
    3) Issue new commission and associate it with the VM. Also clear the task.
71
    4) Send job to ganeti
72
    5) Update task and commit
73
    """
74
    def decorator(func):
75
        @wraps(func)
76
        @transaction.commit_on_success
77
        def wrapper(vm, *args, **kwargs):
78
            user_id = vm.userid
79
            validate_server_action(vm, action)
80

    
81
            # Resolve(reject) previous serial if it is still pending!!
82
            previous_serial = vm.serial
83
            if previous_serial and not previous_serial.resolved:
84
                quotas.resolve_vm_commission(serial=previous_serial)
85

    
86
            # Check if action is quotable and issue the corresponding
87
            # commission
88
            serial = None
89
            commission_info = quotas.get_commission_info(vm, action=action)
90
            if commission_info is not None:
91
                # Issue new commission, associate it with the VM
92
                commission_name = "client: api, resource %s" % vm
93
                serial = quotas.issue_commission(user=user_id,
94
                                                 source=quotas.DEFAULT_SOURCE,
95
                                                 provisions=commission_info,
96
                                                 name=commission_name,
97
                                                 force=False,
98
                                                 auto_accept=False)
99
            vm.serial = serial
100

    
101
            # Send the job to Ganeti and get the associated jobID
102
            try:
103
                job_id = func(vm, *args, **kwargs)
104
            except Exception as e:
105
                if vm.serial is not None:
106
                    # Since the job never reached Ganeti, reject the commission
107
                    log.debug("Rejecting commission: '%s', could not perform"
108
                              " action '%s': %s" % (vm.serial,  action, e))
109
                    transaction.rollback()
110
                    quotas.reject_serial(vm.serial)
111
                    transaction.commit()
112
                raise
113

    
114
            log.info("user: %s, vm: %s, action: %s, job_id: %s, serial: %s",
115
                     user_id, vm.id, action, job_id, vm.serial)
116

    
117
            # store the new task in the VM
118
            if job_id is not None:
119
                vm.task = action
120
                vm.task_job_id = job_id
121
            vm.save()
122

    
123
            return vm
124
        return wrapper
125
    return decorator
126

    
127

    
128
@transaction.commit_manually
129
def create(userid, name, password, flavor, image, metadata={},
130
           personality=[], private_networks=None, floating_ips=None,
131
           use_backend=None):
132
    if use_backend is None:
133
        # Allocate backend to host the server. Commit after allocation to
134
        # release the locks hold by the backend allocator.
135
        try:
136
            backend_allocator = BackendAllocator()
137
            use_backend = backend_allocator.allocate(userid, flavor)
138
            if use_backend is None:
139
                log.error("No available backend for VM with flavor %s", flavor)
140
                raise faults.ServiceUnavailable("No available backends")
141
        except:
142
            transaction.rollback()
143
            raise
144
        else:
145
            transaction.commit()
146

    
147
    if private_networks is None:
148
        private_networks = []
149
    if floating_ips is None:
150
        floating_ips = []
151

    
152
    # Fix flavor for archipelago
153
    disk_template, provider = util.get_flavor_provider(flavor)
154
    if provider:
155
        flavor.disk_template = disk_template
156
        flavor.disk_provider = provider
157
        flavor.disk_origin = None
158
        if provider == 'vlmc':
159
            flavor.disk_origin = image['checksum']
160
            image['backend_id'] = 'null'
161
    else:
162
        flavor.disk_provider = None
163

    
164
    try:
165
        # We must save the VM instance now, so that it gets a valid
166
        # vm.backend_vm_id.
167
        vm = VirtualMachine.objects.create(
168
            name=name,
169
            backend=use_backend,
170
            userid=userid,
171
            imageid=image["id"],
172
            flavor=flavor,
173
            action="CREATE")
174

    
175
        log.info("Created entry in DB for VM '%s'", vm)
176

    
177
        # dispatch server created signal
178
        server_created.send(sender=vm, created_vm_params={
179
            'img_id': image['backend_id'],
180
            'img_passwd': password,
181
            'img_format': str(image['format']),
182
            'img_personality': json.dumps(personality),
183
            'img_properties': json.dumps(image['metadata']),
184
        })
185

    
186
        nics = create_instance_nics(vm, userid, private_networks, floating_ips)
187

    
188
        # Also we must create the VM metadata in the same transaction.
189
        for key, val in metadata.items():
190
            VirtualMachineMetadata.objects.create(
191
                meta_key=key,
192
                meta_value=val,
193
                vm=vm)
194
        # Issue commission to Quotaholder and accept it since at the end of
195
        # this transaction the VirtualMachine object will be created in the DB.
196
        # Note: the following call does a commit!
197
        quotas.issue_and_accept_commission(vm)
198
    except:
199
        transaction.rollback()
200
        raise
201
    else:
202
        transaction.commit()
203

    
204
    try:
205
        jobID = backend.create_instance(vm, nics, flavor, image)
206
        # At this point the job is enqueued in the Ganeti backend
207
        vm.backendjobid = jobID
208
        vm.task = "BUILD"
209
        vm.task_job_id = jobID
210
        vm.save()
211
        transaction.commit()
212
        log.info("User %s created VM %s, NICs %s, Backend %s, JobID %s",
213
                 userid, vm, nics, backend, str(jobID))
214
    except GanetiApiError as e:
215
        log.exception("Can not communicate to backend %s: %s.",
216
                      backend, e)
217
        # Failed while enqueuing OP_INSTANCE_CREATE to backend. Restore
218
        # already reserved quotas by issuing a negative commission
219
        vm.operstate = "ERROR"
220
        vm.backendlogmsg = "Can not communicate to backend."
221
        vm.deleted = True
222
        vm.save()
223
        quotas.issue_and_accept_commission(vm, delete=True)
224
        raise
225
    except:
226
        transaction.rollback()
227
        raise
228

    
229
    return vm
230

    
231

    
232
def create_instance_nics(vm, userid, private_networks=[], floating_ips=[]):
233
    """Create NICs for VirtualMachine.
234

235
    Helper function for allocating IP addresses and creating NICs in the DB
236
    for a VirtualMachine. Created NICs are the combination of the default
237
    network policy (defined by administration settings) and the private
238
    networks defined by the user.
239

240
    """
241
    attachments = []
242
    for network_id in settings.DEFAULT_INSTANCE_NETWORKS:
243
        network, address = None, None
244
        if network_id == "SNF:ANY_PUBLIC":
245
            network, address = util.allocate_public_address(backend=vm.backend)
246
        else:
247
            try:
248
                network = Network.objects.get(id=network_id, deleted=False)
249
            except Network.DoesNotExist:
250
                msg = "Invalid configuration. Setting"\
251
                      " 'DEFAULT_INSTANCE_NETWORKS' contains invalid"\
252
                      " network '%s'" % network_id
253
                log.error(msg)
254
                raise Exception(msg)
255
            if network.subnet is not None and network.dhcp:
256
                address = util.get_network_free_address(network)
257
        attachments.append((network, address))
258
    for address in floating_ips:
259
        floating_ip = add_floating_ip_to_vm(vm=vm, address=address)
260
        network = floating_ip.network
261
        attachments.append((network, address))
262
    for network_id in private_networks:
263
        network, address = None, None
264
        network = util.get_network(network_id, userid, non_deleted=True)
265
        if network.public:
266
            raise faults.Forbidden("Can not connect to public network")
267
        if network.dhcp:
268
            address = util.get_network_free_address(network)
269
        attachments.append((network, address))
270

    
271
    nics = []
272
    for index, (network, address) in enumerate(attachments):
273
        # Create VM's public NIC. Do not wait notification form ganeti
274
        # hooks to create this NIC, because if the hooks never run (e.g.
275
        # building error) the VM's public IP address will never be
276
        # released!
277
        nic = NetworkInterface.objects.create(machine=vm, network=network,
278
                                              index=index, ipv4=address,
279
                                              state="BUILDING")
280
        nics.append(nic)
281
    return nics
282

    
283

    
284
@server_command("DESTROY")
285
def destroy(vm):
286
    log.info("Deleting VM %s", vm)
287
    return backend.delete_instance(vm)
288

    
289

    
290
@server_command("START")
291
def start(vm):
292
    log.info("Starting VM %s", vm)
293
    return backend.startup_instance(vm)
294

    
295

    
296
@server_command("STOP")
297
def stop(vm):
298
    log.info("Stopping VM %s", vm)
299
    return backend.shutdown_instance(vm)
300

    
301

    
302
@server_command("REBOOT")
303
def reboot(vm, reboot_type):
304
    if reboot_type not in ("SOFT", "HARD"):
305
        raise faults.BadRequest("Malformed request. Invalid reboot"
306
                                " type %s" % reboot_type)
307
    log.info("Rebooting VM %s. Type %s", vm, reboot_type)
308

    
309
    return backend.reboot_instance(vm, reboot_type.lower())
310

    
311

    
312
@server_command("RESIZE")
313
def resize(vm, flavor):
314
    old_flavor = vm.flavor
315
    # User requested the same flavor
316
    if old_flavor.id == flavor.id:
317
        raise faults.BadRequest("Server '%s' flavor is already '%s'."
318
                                % (vm, flavor))
319
        return None
320
    # Check that resize can be performed
321
    if old_flavor.disk != flavor.disk:
322
        raise faults.BadRequest("Can not resize instance disk.")
323
    if old_flavor.disk_template != flavor.disk_template:
324
        raise faults.BadRequest("Can not change instance disk template.")
325

    
326
    log.info("Resizing VM from flavor '%s' to '%s", old_flavor, flavor)
327
    commission_info = {"cyclades.cpu": flavor.cpu - old_flavor.cpu,
328
                       "cyclades.ram": 1048576 * (flavor.ram - old_flavor.ram)}
329
    # Save serial to VM, since it is needed by server_command decorator
330
    vm.serial = quotas.issue_commission(user=vm.userid,
331
                                        source=quotas.DEFAULT_SOURCE,
332
                                        provisions=commission_info,
333
                                        name="resource: %s. resize" % vm)
334
    return backend.resize_instance(vm, vcpus=flavor.cpu, memory=flavor.ram)
335

    
336

    
337
@server_command("SET_FIREWALL_PROFILE")
338
def set_firewall_profile(vm, profile, index=0):
339
    log.info("Setting VM %s, NIC index %s, firewall %s", vm, index, profile)
340

    
341
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
342
        raise faults.BadRequest("Unsupported firewall profile")
343
    backend.set_firewall_profile(vm, profile=profile, index=index)
344
    return None
345

    
346

    
347
@server_command("CONNECT")
348
def connect(vm, network):
349
    if network.state != 'ACTIVE':
350
        raise faults.BuildInProgress('Network not active yet')
351

    
352
    address = None
353
    if network.subnet is not None and network.dhcp:
354
        # Get a free IP from the address pool.
355
        address = util.get_network_free_address(network)
356

    
357
    log.info("Connecting VM %s to Network %s(%s)", vm, network, address)
358

    
359
    return backend.connect_to_network(vm, network, address)
360

    
361

    
362
@server_command("DISCONNECT")
363
def disconnect(vm, nic_index):
364
    nic = util.get_nic_from_index(vm, nic_index)
365

    
366
    log.info("Removing NIC %s from VM %s", str(nic.index), vm)
367

    
368
    if nic.dirty:
369
        raise faults.BuildInProgress('Machine is busy.')
370
    else:
371
        vm.nics.all().update(dirty=True)
372

    
373
    return backend.disconnect_from_network(vm, nic)
374

    
375

    
376
def console(vm, console_type):
377
    """Arrange for an OOB console of the specified type
378

379
    This method arranges for an OOB console of the specified type.
380
    Only consoles of type "vnc" are supported for now.
381

382
    It uses a running instance of vncauthproxy to setup proper
383
    VNC forwarding with a random password, then returns the necessary
384
    VNC connection info to the caller.
385

386
    """
387
    log.info("Get console  VM %s, type %s", vm, console_type)
388

    
389
    # Use RAPI to get VNC console information for this instance
390
    if vm.operstate != "STARTED":
391
        raise faults.BadRequest('Server not in ACTIVE state.')
392

    
393
    if settings.TEST:
394
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
395
    else:
396
        console_data = backend.get_instance_console(vm)
397

    
398
    if console_data['kind'] != 'vnc':
399
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
400
        raise faults.ServiceUnavailable(message)
401

    
402
    # Let vncauthproxy decide on the source port.
403
    # The alternative: static allocation, e.g.
404
    # sport = console_data['port'] - 1000
405
    sport = 0
406
    daddr = console_data['host']
407
    dport = console_data['port']
408
    password = util.random_password()
409

    
410
    if settings.TEST:
411
        fwd = {'source_port': 1234, 'status': 'OK'}
412
    else:
413
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
414

    
415
    if fwd['status'] != "OK":
416
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
417

    
418
    # Verify that the VNC server settings haven't changed
419
    if not settings.TEST:
420
        if console_data != backend.get_instance_console(vm):
421
            raise faults.ServiceUnavailable('VNC Server settings changed.')
422

    
423
    console = {
424
        'type': 'vnc',
425
        'host': getfqdn(),
426
        'port': fwd['source_port'],
427
        'password': password}
428

    
429
    return console
430

    
431

    
432
@server_command("CONNECT")
433
def add_floating_ip(vm, address):
434
    floating_ip = add_floating_ip_to_vm(vm, address)
435
    log.info("Connecting VM %s to floating IP %s", vm, floating_ip)
436
    return backend.connect_to_network(vm, floating_ip.network, address)
437

    
438

    
439
def add_floating_ip_to_vm(vm, address):
440
    """Get a floating IP by it's address and add it to VirtualMachine.
441

442
    Helper function for looking up a FloatingIP by it's address and associating
443
    it with a VirtualMachine object (without adding the NIC in the Ganeti
444
    backend!). This function also checks if the floating IP is currently used
445
    by any instance and if it is available in the Backend that hosts the VM.
446

447
    """
448
    user_id = vm.userid
449
    try:
450
        # Get lock in VM, to guarantee that floating IP will only by assigned
451
        # once
452
        floating_ip = FloatingIP.objects.select_for_update()\
453
                                        .get(userid=user_id, ipv4=address,
454
                                             deleted=False)
455
    except FloatingIP.DoesNotExist:
456
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
457

    
458
    if floating_ip.in_use():
459
        raise faults.Conflict("Floating IP '%s' already in use" %
460
                              floating_ip.id)
461

    
462
    bnet = floating_ip.network.backend_networks.filter(backend=vm.backend_id)
463
    if not bnet.exists():
464
        msg = "Network '%s' is a floating IP pool, but it not connected"\
465
              " to backend '%s'" % (floating_ip.network, vm.backend)
466
        raise faults.ServiceUnavailable(msg)
467

    
468
    floating_ip.machine = vm
469
    floating_ip.save()
470
    return floating_ip
471

    
472

    
473
@server_command("DISCONNECT")
474
def remove_floating_ip(vm, address):
475
    user_id = vm.userid
476
    try:
477
        floating_ip = FloatingIP.objects.select_for_update()\
478
                                        .get(userid=user_id, ipv4=address,
479
                                             deleted=False, machine=vm)
480
    except FloatingIP.DoesNotExist:
481
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
482

    
483
    try:
484
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
485
    except NetworkInterface.DoesNotExist:
486
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
487
                                  "VM '%s'" % (floating_ip, vm))
488

    
489
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
490
             vm, floating_ip)
491

    
492
    return backend.disconnect_from_network(vm, nic)