Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (17.7 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 synnefo 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
                serial = quotas.issue_commission(user=user_id,
93
                                                 source=quotas.DEFAULT_SOURCE,
94
                                                 provisions=commission_info,
95
                                                 force=False,
96
                                                 auto_accept=False)
97
            vm.serial = serial
98

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

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

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

    
121
            return vm
122
        return wrapper
123
    return decorator
124

    
125

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

    
145
    # Fix flavor for archipelago
146
    disk_template, provider = util.get_flavor_provider(flavor)
147
    if provider:
148
        flavor.disk_template = disk_template
149
        flavor.disk_provider = provider
150
        flavor.disk_origin = None
151
        if provider == 'vlmc':
152
            flavor.disk_origin = image['checksum']
153
            image['backend_id'] = 'null'
154
    else:
155
        flavor.disk_provider = None
156

    
157
    try:
158
        # We must save the VM instance now, so that it gets a valid
159
        # vm.backend_vm_id.
160
        vm = VirtualMachine.objects.create(
161
            name=name,
162
            backend=use_backend,
163
            userid=userid,
164
            imageid=image["id"],
165
            flavor=flavor,
166
            action="CREATE")
167

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

    
170
        # dispatch server created signal
171
        server_created.send(sender=vm, created_vm_params={
172
            'img_id': image['backend_id'],
173
            'img_passwd': password,
174
            'img_format': str(image['format']),
175
            'img_personality': json.dumps(personality),
176
            'img_properties': json.dumps(image['metadata']),
177
        })
178

    
179
        nics = create_instance_nics(vm, userid, private_networks, floating_ips)
180

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

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

    
222
    return vm
223

    
224

    
225
def create_instance_nics(vm, userid, private_networks=[], floating_ips=[]):
226
    """Create NICs for VirtualMachine.
227

228
    Helper function for allocating IP addresses and creating NICs in the DB
229
    for a VirtualMachine. Created NICs are the combination of the default
230
    network policy (defined by administration settings) and the private
231
    networks defined by the user.
232

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

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

    
276

    
277
@server_command("DESTROY")
278
def destroy(vm):
279
    log.info("Deleting VM %s", vm)
280
    return backend.delete_instance(vm)
281

    
282

    
283
@server_command("START")
284
def start(vm):
285
    log.info("Starting VM %s", vm)
286
    return backend.startup_instance(vm)
287

    
288

    
289
@server_command("STOP")
290
def stop(vm):
291
    log.info("Stopping VM %s", vm)
292
    return backend.shutdown_instance(vm)
293

    
294

    
295
@server_command("REBOOT")
296
def reboot(vm, reboot_type):
297
    if reboot_type not in ("SOFT", "HARD"):
298
        raise faults.BadRequest("Malformed request. Invalid reboot"
299
                                " type %s" % reboot_type)
300
    log.info("Rebooting VM %s. Type %s", vm, reboot_type)
301

    
302
    return backend.reboot_instance(vm, reboot_type.lower())
303

    
304

    
305
@server_command("RESIZE")
306
def resize(vm, flavor):
307
    old_flavor = vm.flavor
308
    # User requested the same flavor
309
    if old_flavor.id == flavor.id:
310
        raise faults.BadRequest("Server '%s' flavor is already '%s'."
311
                                % (vm, flavor))
312
        return None
313
    # Check that resize can be performed
314
    if old_flavor.disk != flavor.disk:
315
        raise faults.BadRequest("Can not resize instance disk.")
316
    if old_flavor.disk_template != flavor.disk_template:
317
        raise faults.BadRequest("Can not change instance disk template.")
318

    
319
    log.info("Resizing VM from flavor '%s' to '%s", old_flavor, flavor)
320
    commission_info = {"cyclades.cpu": flavor.cpu - old_flavor.cpu,
321
                       "cyclades.ram": flavor.ram - old_flavor.ram}
322
    # Save serial to VM, since it is needed by server_command decorator
323
    vm.serial = quotas.issue_commission(user=vm.userid,
324
                                        source=quotas.DEFAULT_SOURCE,
325
                                        provisions=commission_info)
326
    return backend.resize_instance(vm, vcpus=flavor.cpu, memory=flavor.ram)
327

    
328

    
329
@server_command("SET_FIREWALL_PROFILE")
330
def set_firewall_profile(vm, profile):
331
    log.info("Setting VM %s firewall %s", vm, profile)
332

    
333
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
334
        raise faults.BadRequest("Unsupported firewall profile")
335
    backend.set_firewall_profile(vm, profile)
336
    return None
337

    
338

    
339
@server_command("CONNECT")
340
def connect(vm, network):
341
    if network.state != 'ACTIVE':
342
        raise faults.BuildInProgress('Network not active yet')
343

    
344
    address = None
345
    if network.dhcp:
346
        # Get a free IP from the address pool.
347
        address = util.get_network_free_address(network)
348

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

    
351
    return backend.connect_to_network(vm, network, address)
352

    
353

    
354
@server_command("DISCONNECT")
355
def disconnect(vm, nic_index):
356
    nic = util.get_nic_from_index(vm, nic_index)
357

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

    
360
    if nic.dirty:
361
        raise faults.BuildInProgress('Machine is busy.')
362
    else:
363
        vm.nics.all().update(dirty=True)
364

    
365
    return backend.disconnect_from_network(vm, nic)
366

    
367

    
368
def console(vm, console_type):
369
    """Arrange for an OOB console of the specified type
370

371
    This method arranges for an OOB console of the specified type.
372
    Only consoles of type "vnc" are supported for now.
373

374
    It uses a running instance of vncauthproxy to setup proper
375
    VNC forwarding with a random password, then returns the necessary
376
    VNC connection info to the caller.
377

378
    """
379
    log.info("Get console  VM %s, type %s", vm, console_type)
380

    
381
    # Use RAPI to get VNC console information for this instance
382
    if vm.operstate != "STARTED":
383
        raise faults.BadRequest('Server not in ACTIVE state.')
384

    
385
    if settings.TEST:
386
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
387
    else:
388
        console_data = backend.get_instance_console(vm)
389

    
390
    if console_data['kind'] != 'vnc':
391
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
392
        raise faults.ServiceUnavailable(message)
393

    
394
    # Let vncauthproxy decide on the source port.
395
    # The alternative: static allocation, e.g.
396
    # sport = console_data['port'] - 1000
397
    sport = 0
398
    daddr = console_data['host']
399
    dport = console_data['port']
400
    password = util.random_password()
401

    
402
    if settings.TEST:
403
        fwd = {'source_port': 1234, 'status': 'OK'}
404
    else:
405
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
406

    
407
    if fwd['status'] != "OK":
408
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
409

    
410
    # Verify that the VNC server settings haven't changed
411
    if not settings.TEST:
412
        if console_data != backend.get_instance_console(vm):
413
            raise faults.ServiceUnavailable('VNC Server settings changed.')
414

    
415
    console = {
416
        'type': 'vnc',
417
        'host': getfqdn(),
418
        'port': fwd['source_port'],
419
        'password': password}
420

    
421
    return console
422

    
423

    
424
@server_command("CONNECT")
425
def add_floating_ip(vm, address):
426
    floating_ip = add_floating_ip_to_vm(vm, address)
427
    log.info("Connecting VM %s to floating IP %s", vm, floating_ip)
428
    return backend.connect_to_network(vm, floating_ip.network, address)
429

    
430

    
431
def add_floating_ip_to_vm(vm, address):
432
    """Get a floating IP by it's address and add it to VirtualMachine.
433

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

439
    """
440
    user_id = vm.userid
441
    try:
442
        # Get lock in VM, to guarantee that floating IP will only by assigned
443
        # once
444
        floating_ip = FloatingIP.objects.select_for_update()\
445
                                        .get(userid=user_id, ipv4=address,
446
                                             deleted=False)
447
    except FloatingIP.DoesNotExist:
448
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
449

    
450
    if floating_ip.in_use():
451
        raise faults.Conflict("Floating IP '%s' already in use" %
452
                              floating_ip.id)
453

    
454
    bnet = floating_ip.network.backend_networks.filter(backend=vm.backend_id)
455
    if not bnet.exists():
456
        msg = "Network '%s' is a floating IP pool, but it not connected"\
457
              " to backend '%s'" % (floating_ip.network, vm.backend)
458
        raise faults.ServiceUnavailable(msg)
459

    
460
    floating_ip.machine = vm
461
    floating_ip.save()
462
    return floating_ip
463

    
464

    
465
@server_command("DISCONNECT")
466
def remove_floating_ip(vm, address):
467
    user_id = vm.userid
468
    try:
469
        floating_ip = FloatingIP.objects.select_for_update()\
470
                                        .get(userid=user_id, ipv4=address,
471
                                             deleted=False, machine=vm)
472
    except FloatingIP.DoesNotExist:
473
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
474

    
475
    try:
476
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
477
    except NetworkInterface.DoesNotExist:
478
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
479
                                  "VM '%s'" % (floating_ip, vm))
480

    
481
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
482
             vm, floating_ip)
483

    
484
    return backend.disconnect_from_network(vm, nic)