Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.2 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

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

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

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

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

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

    
125
            return vm
126
        return wrapper
127
    return decorator
128

    
129

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

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

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

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

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

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

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

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

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

    
231
    return vm
232

    
233

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

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

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

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

    
285

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

    
291

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

    
297

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

    
303

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

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

    
313

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

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

    
338

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

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

    
348

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

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

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

    
361
    return backend.connect_to_network(vm, network, address)
362

    
363

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

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

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

    
375
    return backend.disconnect_from_network(vm, nic)
376

    
377

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

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

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

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

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

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

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

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

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

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

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

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

    
431
    return console
432

    
433

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

    
440

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

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

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

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

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

    
470
    floating_ip.machine = vm
471
    floating_ip.save()
472
    return floating_ip
473

    
474

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

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

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

    
494
    return backend.disconnect_from_network(vm, nic)