Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (17.8 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 == "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
    if private_networks is None:
146
        private_networks = []
147
    if floating_ips is None:
148
        floating_ips = []
149

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

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

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

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

    
184
        nics = create_instance_nics(vm, userid, private_networks, floating_ips)
185

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

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

    
227
    return vm
228

    
229

    
230
def create_instance_nics(vm, userid, private_networks=[], floating_ips=[]):
231
    """Create NICs for VirtualMachine.
232

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

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

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

    
281

    
282
@server_command("DESTROY")
283
def destroy(vm):
284
    log.info("Deleting VM %s", vm)
285
    return backend.delete_instance(vm)
286

    
287

    
288
@server_command("START")
289
def start(vm):
290
    log.info("Starting VM %s", vm)
291
    return backend.startup_instance(vm)
292

    
293

    
294
@server_command("STOP")
295
def stop(vm):
296
    log.info("Stopping VM %s", vm)
297
    return backend.shutdown_instance(vm)
298

    
299

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

    
307
    return backend.reboot_instance(vm, reboot_type.lower())
308

    
309

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

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

    
333

    
334
@server_command("SET_FIREWALL_PROFILE")
335
def set_firewall_profile(vm, profile):
336
    log.info("Setting VM %s firewall %s", vm, profile)
337

    
338
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
339
        raise faults.BadRequest("Unsupported firewall profile")
340
    backend.set_firewall_profile(vm, profile)
341
    return None
342

    
343

    
344
@server_command("CONNECT")
345
def connect(vm, network):
346
    if network.state != 'ACTIVE':
347
        raise faults.BuildInProgress('Network not active yet')
348

    
349
    address = None
350
    if network.dhcp:
351
        # Get a free IP from the address pool.
352
        address = util.get_network_free_address(network)
353

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

    
356
    return backend.connect_to_network(vm, network, address)
357

    
358

    
359
@server_command("DISCONNECT")
360
def disconnect(vm, nic_index):
361
    nic = util.get_nic_from_index(vm, nic_index)
362

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

    
365
    if nic.dirty:
366
        raise faults.BuildInProgress('Machine is busy.')
367
    else:
368
        vm.nics.all().update(dirty=True)
369

    
370
    return backend.disconnect_from_network(vm, nic)
371

    
372

    
373
def console(vm, console_type):
374
    """Arrange for an OOB console of the specified type
375

376
    This method arranges for an OOB console of the specified type.
377
    Only consoles of type "vnc" are supported for now.
378

379
    It uses a running instance of vncauthproxy to setup proper
380
    VNC forwarding with a random password, then returns the necessary
381
    VNC connection info to the caller.
382

383
    """
384
    log.info("Get console  VM %s, type %s", vm, console_type)
385

    
386
    # Use RAPI to get VNC console information for this instance
387
    if vm.operstate != "STARTED":
388
        raise faults.BadRequest('Server not in ACTIVE state.')
389

    
390
    if settings.TEST:
391
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
392
    else:
393
        console_data = backend.get_instance_console(vm)
394

    
395
    if console_data['kind'] != 'vnc':
396
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
397
        raise faults.ServiceUnavailable(message)
398

    
399
    # Let vncauthproxy decide on the source port.
400
    # The alternative: static allocation, e.g.
401
    # sport = console_data['port'] - 1000
402
    sport = 0
403
    daddr = console_data['host']
404
    dport = console_data['port']
405
    password = util.random_password()
406

    
407
    if settings.TEST:
408
        fwd = {'source_port': 1234, 'status': 'OK'}
409
    else:
410
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
411

    
412
    if fwd['status'] != "OK":
413
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
414

    
415
    # Verify that the VNC server settings haven't changed
416
    if not settings.TEST:
417
        if console_data != backend.get_instance_console(vm):
418
            raise faults.ServiceUnavailable('VNC Server settings changed.')
419

    
420
    console = {
421
        'type': 'vnc',
422
        'host': getfqdn(),
423
        'port': fwd['source_port'],
424
        'password': password}
425

    
426
    return console
427

    
428

    
429
@server_command("CONNECT")
430
def add_floating_ip(vm, address):
431
    floating_ip = add_floating_ip_to_vm(vm, address)
432
    log.info("Connecting VM %s to floating IP %s", vm, floating_ip)
433
    return backend.connect_to_network(vm, floating_ip.network, address)
434

    
435

    
436
def add_floating_ip_to_vm(vm, address):
437
    """Get a floating IP by it's address and add it to VirtualMachine.
438

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

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

    
455
    if floating_ip.in_use():
456
        raise faults.Conflict("Floating IP '%s' already in use" %
457
                              floating_ip.id)
458

    
459
    bnet = floating_ip.network.backend_networks.filter(backend=vm.backend_id)
460
    if not bnet.exists():
461
        msg = "Network '%s' is a floating IP pool, but it not connected"\
462
              " to backend '%s'" % (floating_ip.network, vm.backend)
463
        raise faults.ServiceUnavailable(msg)
464

    
465
    floating_ip.machine = vm
466
    floating_ip.save()
467
    return floating_ip
468

    
469

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

    
480
    try:
481
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
482
    except NetworkInterface.DoesNotExist:
483
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
484
                                  "VM '%s'" % (floating_ip, vm))
485

    
486
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
487
             vm, floating_ip)
488

    
489
    return backend.disconnect_from_network(vm, nic)