Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (19 kB)

1
import logging
2
import datetime
3

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

    
10
from snf_django.lib.api import faults
11
from django.conf import settings
12
from synnefo import quotas
13
from synnefo.api import util
14
from synnefo.logic import backend
15
from synnefo.logic.backend_allocator import BackendAllocator
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 != "STOPPED") or\
48
         (action == "STOP" and operstate != "STARTED") or\
49
         (action == "RESIZE" and operstate != "STOPPED") or\
50
         (action in ["CONNECT", "DISCONNECT"] and operstate != "STOPPED"
51
          and not settings.GANETI_USE_HOTPLUG):
52
        raise faults.BadRequest("Can not perform '%s' action while server is"
53
                                " in '%s' state." % (action, operstate))
54
    return
55

    
56

    
57
def server_command(action):
58
    """Handle execution of a server action.
59

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

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

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

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

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

    
117
            log.info("user: %s, vm: %s, action: %s, job_id: %s, serial: %s",
118
                     user_id, vm.id, action, job_id, vm.serial)
119

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

    
126
            return vm
127
        return wrapper
128
    return decorator
129

    
130

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

    
150
    if private_networks is None:
151
        private_networks = []
152
    if floating_ips is None:
153
        floating_ips = []
154

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

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

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

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

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

    
198
    jobID = None
199
    try:
200
        vm = VirtualMachine.objects.select_for_update().get(id=vm.id)
201
        # dispatch server created signal needed to trigger the 'vmapi', which
202
        # enriches the vm object with the 'config_url' attribute which must be
203
        # passed to the Ganeti job.
204
        server_created.send(sender=vm, created_vm_params={
205
            'img_id': image['backend_id'],
206
            'img_passwd': password,
207
            'img_format': str(image['format']),
208
            'img_personality': json.dumps(personality),
209
            'img_properties': json.dumps(image['metadata']),
210
        })
211

    
212
        jobID = backend.create_instance(vm, nics, flavor, image)
213
        # At this point the job is enqueued in the Ganeti backend
214
        vm.backendjobid = jobID
215
        vm.task = "BUILD"
216
        vm.task_job_id = jobID
217
        vm.save()
218
        transaction.commit()
219
        log.info("User %s created VM %s, NICs %s, Backend %s, JobID %s",
220
                 userid, vm, nics, backend, str(jobID))
221
    except:
222
        # If an exception is raised, then the user will never get the VM id.
223
        # In order to delete it from DB and release it's resources, we
224
        # mock a successful OP_INSTANCE_REMOVE job.
225
        backend.process_op_status(vm=vm, etime=datetime.datetime.now(),
226
                                  jobid=-0,
227
                                  opcode="OP_INSTANCE_REMOVE",
228
                                  status="success",
229
                                  logmsg="Reconciled eventd: VM creation"
230
                                         " failed.")
231
        raise
232

    
233
    return vm
234

    
235

    
236
def create_instance_nics(vm, userid, private_networks=[], floating_ips=[]):
237
    """Create NICs for VirtualMachine.
238

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

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

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

    
287

    
288
@server_command("DESTROY")
289
def destroy(vm):
290
    log.info("Deleting VM %s", vm)
291
    return backend.delete_instance(vm)
292

    
293

    
294
@server_command("START")
295
def start(vm):
296
    log.info("Starting VM %s", vm)
297
    return backend.startup_instance(vm)
298

    
299

    
300
@server_command("STOP")
301
def stop(vm):
302
    log.info("Stopping VM %s", vm)
303
    return backend.shutdown_instance(vm)
304

    
305

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

    
313
    return backend.reboot_instance(vm, reboot_type.lower())
314

    
315

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

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

    
340

    
341
@server_command("SET_FIREWALL_PROFILE")
342
def set_firewall_profile(vm, profile, index=0):
343
    log.info("Setting VM %s, NIC index %s, firewall %s", vm, index, profile)
344

    
345
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
346
        raise faults.BadRequest("Unsupported firewall profile")
347
    backend.set_firewall_profile(vm, profile=profile, index=index)
348
    return None
349

    
350

    
351
@server_command("CONNECT")
352
def connect(vm, network):
353
    if network.state != 'ACTIVE':
354
        raise faults.BuildInProgress('Network not active yet')
355

    
356
    address = None
357
    if network.subnet is not None and network.dhcp:
358
        # Get a free IP from the address pool.
359
        address = util.get_network_free_address(network)
360

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

    
363
    nic = NetworkInterface.objects.create(machine=vm,
364
                                          network=network,
365
                                          ipv4=address,
366
                                          state="BUILDING")
367

    
368
    return backend.connect_to_network(vm, nic)
369

    
370

    
371
@server_command("DISCONNECT")
372
def disconnect(vm, nic_index):
373
    nic = util.get_nic_from_index(vm, nic_index)
374

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

    
377
    if nic.dirty:
378
        raise faults.BuildInProgress('Machine is busy.')
379
    else:
380
        vm.nics.all().update(dirty=True)
381

    
382
    return backend.disconnect_from_network(vm, nic)
383

    
384

    
385
def console(vm, console_type):
386
    """Arrange for an OOB console of the specified type
387

388
    This method arranges for an OOB console of the specified type.
389
    Only consoles of type "vnc" are supported for now.
390

391
    It uses a running instance of vncauthproxy to setup proper
392
    VNC forwarding with a random password, then returns the necessary
393
    VNC connection info to the caller.
394

395
    """
396
    log.info("Get console  VM %s, type %s", vm, console_type)
397

    
398
    # Use RAPI to get VNC console information for this instance
399
    if vm.operstate != "STARTED":
400
        raise faults.BadRequest('Server not in ACTIVE state.')
401

    
402
    if settings.TEST:
403
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
404
    else:
405
        console_data = backend.get_instance_console(vm)
406

    
407
    if console_data['kind'] != 'vnc':
408
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
409
        raise faults.ServiceUnavailable(message)
410

    
411
    # Let vncauthproxy decide on the source port.
412
    # The alternative: static allocation, e.g.
413
    # sport = console_data['port'] - 1000
414
    sport = 0
415
    daddr = console_data['host']
416
    dport = console_data['port']
417
    password = util.random_password()
418

    
419
    if settings.TEST:
420
        fwd = {'source_port': 1234, 'status': 'OK'}
421
    else:
422
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
423

    
424
    if fwd['status'] != "OK":
425
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
426

    
427
    # Verify that the VNC server settings haven't changed
428
    if not settings.TEST:
429
        if console_data != backend.get_instance_console(vm):
430
            raise faults.ServiceUnavailable('VNC Server settings changed.')
431

    
432
    console = {
433
        'type': 'vnc',
434
        'host': getfqdn(),
435
        'port': fwd['source_port'],
436
        'password': password}
437

    
438
    return console
439

    
440

    
441
@server_command("CONNECT")
442
def add_floating_ip(vm, address):
443
    floating_ip = add_floating_ip_to_vm(vm, address)
444
    nic = NetworkInterface.objects.create(machine=vm,
445
                                          network=floating_ip.network,
446
                                          ipv4=floating_ip.ipv4,
447
                                          state="BUILDING")
448
    log.info("Connecting VM %s to floating IP %s. NIC: %s", vm, floating_ip,
449
             nic)
450
    return backend.connect_to_network(vm, nic)
451

    
452

    
453
def add_floating_ip_to_vm(vm, address):
454
    """Get a floating IP by it's address and add it to VirtualMachine.
455

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

461
    """
462
    user_id = vm.userid
463
    try:
464
        # Get lock in VM, to guarantee that floating IP will only by assigned
465
        # once
466
        floating_ip = FloatingIP.objects.select_for_update()\
467
                                        .get(userid=user_id, ipv4=address,
468
                                             deleted=False)
469
    except FloatingIP.DoesNotExist:
470
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
471

    
472
    if floating_ip.in_use():
473
        raise faults.Conflict("Floating IP '%s' already in use" %
474
                              floating_ip.id)
475

    
476
    bnet = floating_ip.network.backend_networks.filter(backend=vm.backend_id)
477
    if not bnet.exists():
478
        msg = "Network '%s' is a floating IP pool, but it not connected"\
479
              " to backend '%s'" % (floating_ip.network, vm.backend)
480
        raise faults.ServiceUnavailable(msg)
481

    
482
    floating_ip.machine = vm
483
    floating_ip.save()
484
    return floating_ip
485

    
486

    
487
@server_command("DISCONNECT")
488
def remove_floating_ip(vm, address):
489
    user_id = vm.userid
490
    try:
491
        floating_ip = FloatingIP.objects.select_for_update()\
492
                                        .get(userid=user_id, ipv4=address,
493
                                             deleted=False, machine=vm)
494
    except FloatingIP.DoesNotExist:
495
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
496

    
497
    try:
498
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
499
    except NetworkInterface.DoesNotExist:
500
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
501
                                  "VM '%s'" % (floating_ip, vm))
502

    
503
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
504
             vm, floating_ip)
505

    
506
    return backend.disconnect_from_network(vm, nic)