Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (16.9 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
           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)
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, NIC %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):
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.get_public_ip(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 network_id in private_networks:
252
        network, address = None, None
253
        network = util.get_network(network_id, userid, non_deleted=True)
254
        if network.public:
255
            raise faults.Forbidden("Can not connect to public network")
256
        if network.dhcp:
257
            address = util.get_network_free_address(network)
258
        attachments.append((network, address))
259

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

    
272

    
273
@server_command("DESTROY")
274
def destroy(vm):
275
    log.info("Deleting VM %s", vm)
276
    return backend.delete_instance(vm)
277

    
278

    
279
@server_command("START")
280
def start(vm):
281
    log.info("Starting VM %s", vm)
282
    return backend.startup_instance(vm)
283

    
284

    
285
@server_command("STOP")
286
def stop(vm):
287
    log.info("Stopping VM %s", vm)
288
    return backend.shutdown_instance(vm)
289

    
290

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

    
298
    return backend.reboot_instance(vm, reboot_type.lower())
299

    
300

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

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

    
324

    
325
@server_command("SET_FIREWALL_PROFILE")
326
def set_firewall_profile(vm, profile):
327
    log.info("Setting VM %s firewall %s", vm, profile)
328

    
329
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
330
        raise faults.BadRequest("Unsupported firewall profile")
331
    backend.set_firewall_profile(vm, profile)
332
    return None
333

    
334

    
335
@server_command("CONNECT")
336
def connect(vm, network):
337
    if network.state != 'ACTIVE':
338
        raise faults.BuildInProgress('Network not active yet')
339

    
340
    address = None
341
    if network.dhcp:
342
        # Get a free IP from the address pool.
343
        address = util.get_network_free_address(network)
344

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

    
347
    return backend.connect_to_network(vm, network, address)
348

    
349

    
350
@server_command("DISCONNECT")
351
def disconnect(vm, nic_index):
352
    nic = util.get_nic_from_index(vm, nic_index)
353

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

    
356
    if nic.dirty:
357
        raise faults.BuildInProgress('Machine is busy.')
358
    else:
359
        vm.nics.all().update(dirty=True)
360

    
361
    return backend.disconnect_from_network(vm, nic)
362

    
363

    
364
def console(vm, console_type):
365
    """Arrange for an OOB console of the specified type
366

367
    This method arranges for an OOB console of the specified type.
368
    Only consoles of type "vnc" are supported for now.
369

370
    It uses a running instance of vncauthproxy to setup proper
371
    VNC forwarding with a random password, then returns the necessary
372
    VNC connection info to the caller.
373

374
    """
375
    log.info("Get console  VM %s, type %s", vm, console_type)
376

    
377
    # Use RAPI to get VNC console information for this instance
378
    if vm.operstate != "STARTED":
379
        raise faults.BadRequest('Server not in ACTIVE state.')
380

    
381
    if settings.TEST:
382
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
383
    else:
384
        console_data = backend.get_instance_console(vm)
385

    
386
    if console_data['kind'] != 'vnc':
387
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
388
        raise faults.ServiceUnavailable(message)
389

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

    
398
    if settings.TEST:
399
        fwd = {'source_port': 1234, 'status': 'OK'}
400
    else:
401
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
402

    
403
    if fwd['status'] != "OK":
404
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
405

    
406
    # Verify that the VNC server settings haven't changed
407
    if not settings.TEST:
408
        if console_data != backend.get_instance_console(vm):
409
            raise faults.ServiceUnavailable('VNC Server settings changed.')
410

    
411
    console = {
412
        'type': 'vnc',
413
        'host': getfqdn(),
414
        'port': fwd['source_port'],
415
        'password': password}
416

    
417
    return console
418

    
419

    
420
@server_command("CONNECTING")
421
def add_floating_ip(vm, address):
422
    user_id = vm.userid
423
    # Get lock in VM, to guarantee that floating IP will only by assigned once
424
    try:
425
        floating_ip = FloatingIP.objects.select_for_update()\
426
                                        .get(userid=user_id, ipv4=address,
427
                                             deleted=False)
428
    except FloatingIP.DoesNotExist:
429
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
430

    
431
    if floating_ip.in_use():
432
        raise faults.Conflict("Floating IP '%s' already in use" %
433
                              floating_ip.id)
434

    
435
    bnet = floating_ip.network.backend_networks.filter(backend=vm.backend_id)
436
    if not bnet.exists():
437
        msg = "Network '%s' is a floating IP pool, but it not connected"\
438
              " to backend '%s'" % (floating_ip.network, vm.backend)
439
        raise faults.ServiceUnavailable(msg)
440

    
441
    floating_ip.machine = vm
442
    floating_ip.save()
443

    
444
    log.info("Connecting VM %s to floating IP %s", vm, floating_ip)
445
    return backend.connect_to_network(vm, floating_ip.network, address)
446

    
447

    
448
@server_command("DISCONNECTING")
449
def remove_floating_ip(vm, address):
450
    user_id = vm.userid
451
    try:
452
        floating_ip = FloatingIP.objects.select_for_update()\
453
                                        .get(userid=user_id, ipv4=address,
454
                                             deleted=False, machine=vm)
455
    except FloatingIP.DoesNotExist:
456
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
457

    
458
    try:
459
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
460
    except NetworkInterface.DoesNotExist:
461
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
462
                                  "VM '%s'" % (floating_ip, vm))
463

    
464
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
465
             vm, floating_ip)
466

    
467
    return backend.disconnect_from_network(vm, nic)