Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.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 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 != "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
        # dispatch server created signal
181
        server_created.send(sender=vm, created_vm_params={
182
            'img_id': image['backend_id'],
183
            'img_passwd': password,
184
            'img_format': str(image['format']),
185
            'img_personality': json.dumps(personality),
186
            'img_properties': json.dumps(image['metadata']),
187
        })
188

    
189
        nics = create_instance_nics(vm, userid, private_networks, floating_ips)
190

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

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

    
232
    return vm
233

    
234

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

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

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

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

    
286

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

    
292

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

    
298

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

    
304

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

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

    
314

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

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

    
339

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

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

    
349

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

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

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

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

    
367
    return backend.connect_to_network(vm, nic)
368

    
369

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

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

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

    
381
    return backend.disconnect_from_network(vm, nic)
382

    
383

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

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

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

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

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

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

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

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

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

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

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

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

    
437
    return console
438

    
439

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

    
451

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

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

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

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

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

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

    
485

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

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

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

    
505
    return backend.disconnect_from_network(vm, nic)