Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.4 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.db.models import (NetworkInterface, VirtualMachine, Network,
16
                               VirtualMachineMetadata, FloatingIP)
17

    
18
from vncauthproxy.client import request_forwarding as request_vnc_forwarding
19

    
20
log = logging.getLogger(__name__)
21

    
22
# server creation signal
23
server_created = dispatch.Signal(providing_args=["created_vm_params"])
24

    
25

    
26
def validate_server_action(vm, action):
27
    if vm.deleted:
28
        raise faults.BadRequest("Server '%s' has been deleted." % vm.id)
29

    
30
    # Destroyin a server should always be permitted
31
    if action == "DESTROY":
32
        return
33

    
34
    # Check that there is no pending action
35
    pending_action = vm.task
36
    if pending_action:
37
        if pending_action == "BUILD":
38
            raise faults.BuildInProgress("Server '%s' is being build." % vm.id)
39
        raise faults.BadRequest("Can not perform '%s' action while there is a"
40
                                " pending '%s'." % (action, pending_action))
41

    
42
    # Check if action can be performed to VM's operstate
43
    operstate = vm.operstate
44
    if operstate == "BUILD" and action != "BUILD":
45
        raise faults.BuildInProgress("Server '%s' is being build." % vm.id)
46
    elif (action == "START" and operstate != "STOPPED") or\
47
         (action == "STOP" and operstate != "STARTED") or\
48
         (action == "RESIZE" and operstate != "STOPPED") or\
49
         (action in ["CONNECT", "DISCONNECT"] and operstate != "STOPPED"
50
          and not settings.GANETI_USE_HOTPLUG):
51
        raise faults.BadRequest("Can not perform '%s' action while server is"
52
                                " in '%s' state." % (action, operstate))
53
    return
54

    
55

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

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

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

    
83
            commission_name = "client: api, resource: %s" % vm
84
            quotas.handle_resource_commission(vm, action=action,
85
                                              commission_name=commission_name)
86
            vm.save()
87

    
88
            # XXX: Special case for server creation!
89
            if action == "BUILD":
90
                # Perform a commit, because the VirtualMachine must be saved to
91
                # DB before the OP_INSTANCE_CREATE job in enqueued in Ganeti.
92
                # Otherwise, messages will arrive from snf-dispatcher about
93
                # this instance, before the VM is stored in DB.
94
                transaction.commit()
95
                # After committing the locks are released. Refetch the instance
96
                # to guarantee x-lock.
97
                vm = VirtualMachine.objects.select_for_update().get(id=vm.id)
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_on_success
127
def create(userid, name, password, flavor, image, metadata={},
128
           personality=[], private_networks=None, floating_ips=None,
129
           use_backend=None):
130
    if use_backend is None:
131
        # Allocate server to a Ganeti backend
132
        use_backend = allocate_new_server(userid, flavor)
133

    
134
    if private_networks is None:
135
        private_networks = []
136
    if floating_ips is None:
137
        floating_ips = []
138

    
139
    # Fix flavor for archipelago
140
    disk_template, provider = util.get_flavor_provider(flavor)
141
    if provider:
142
        flavor.disk_template = disk_template
143
        flavor.disk_provider = provider
144
        flavor.disk_origin = None
145
        if provider == 'vlmc':
146
            flavor.disk_origin = image['checksum']
147
            image['backend_id'] = 'null'
148
    else:
149
        flavor.disk_provider = None
150

    
151
    # We must save the VM instance now, so that it gets a valid
152
    # vm.backend_vm_id.
153
    vm = VirtualMachine.objects.create(name=name,
154
                                       backend=use_backend,
155
                                       userid=userid,
156
                                       imageid=image["id"],
157
                                       flavor=flavor,
158
                                       operstate="BUILD")
159
    log.info("Created entry in DB for VM '%s'", vm)
160

    
161
    nics = create_instance_nics(vm, userid, private_networks, floating_ips)
162

    
163
    for key, val in metadata.items():
164
        VirtualMachineMetadata.objects.create(
165
            meta_key=key,
166
            meta_value=val,
167
            vm=vm)
168

    
169
    try:
170
        # Create the server in Ganeti.
171
        create_server(vm, nics, flavor, image, personality, password)
172
    except:
173
        log.exception("Failed create instance '%s'", vm)
174
        vm.operstate = "ERROR"
175
        vm.backendlogmsg = "Failed to send job to Ganeti."
176
        vm.save()
177
        vm.nics.all().update(state="ERROR")
178

    
179
    return vm
180

    
181

    
182
@transaction.commit_on_success
183
def allocate_new_server(userid, flavor):
184
    """Allocate a new server to a Ganeti backend.
185

186
    Allocation is performed based on the owner of the server and the specified
187
    flavor. Also, backends that do not have a public IPv4 address are excluded
188
    from server allocation.
189

190
    This function runs inside a transaction, because after allocating the
191
    instance a commit must be performed in order to release all locks.
192

193
    """
194
    backend_allocator = BackendAllocator()
195
    use_backend = backend_allocator.allocate(userid, flavor)
196
    if use_backend is None:
197
        log.error("No available backend for VM with flavor %s", flavor)
198
        raise faults.ServiceUnavailable("No available backends")
199
    return use_backend
200

    
201

    
202
@server_command("BUILD")
203
def create_server(vm, nics, flavor, image, personality, password):
204
    # dispatch server created signal needed to trigger the 'vmapi', which
205
    # enriches the vm object with the 'config_url' attribute which must be
206
    # passed to the Ganeti job.
207
    server_created.send(sender=vm, created_vm_params={
208
        'img_id': image['backend_id'],
209
        'img_passwd': password,
210
        'img_format': str(image['format']),
211
        'img_personality': json.dumps(personality),
212
        'img_properties': json.dumps(image['metadata']),
213
    })
214
    # send job to Ganeti
215
    jobID = backend.create_instance(vm, nics, flavor, image)
216
    # At this point the job is enqueued in the Ganeti backend
217
    vm.backendjobid = jobID
218
    vm.save()
219
    log.info("User %s created VM %s, NICs %s, Backend %s, JobID %s",
220
             vm.userid, vm, nics, backend, str(jobID))
221

    
222
    return jobID
223

    
224

    
225
def create_instance_nics(vm, userid, private_networks=[], floating_ips=[]):
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 == "SNF:ANY_PUBLIC":
238
            network, address = util.allocate_public_address(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.subnet is not None and network.dhcp:
249
                address = util.get_network_free_address(network)
250
        attachments.append((network, address))
251
    for address in floating_ips:
252
        floating_ip = add_floating_ip_to_vm(vm=vm, address=address)
253
        network = floating_ip.network
254
        attachments.append((network, address))
255
    for network_id in private_networks:
256
        network, address = None, None
257
        network = util.get_network(network_id, userid, non_deleted=True)
258
        if network.public:
259
            raise faults.Forbidden("Can not connect to public network")
260
        if network.dhcp:
261
            address = util.get_network_free_address(network)
262
        attachments.append((network, address))
263

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

    
276

    
277
@server_command("DESTROY")
278
def destroy(vm):
279
    log.info("Deleting VM %s", vm)
280
    return backend.delete_instance(vm)
281

    
282

    
283
@server_command("START")
284
def start(vm):
285
    log.info("Starting VM %s", vm)
286
    return backend.startup_instance(vm)
287

    
288

    
289
@server_command("STOP")
290
def stop(vm):
291
    log.info("Stopping VM %s", vm)
292
    return backend.shutdown_instance(vm)
293

    
294

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

    
302
    return backend.reboot_instance(vm, reboot_type.lower())
303

    
304

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

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

    
329

    
330
@server_command("SET_FIREWALL_PROFILE")
331
def set_firewall_profile(vm, profile, index=0):
332
    log.info("Setting VM %s, NIC index %s, firewall %s", vm, index, profile)
333

    
334
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
335
        raise faults.BadRequest("Unsupported firewall profile")
336
    backend.set_firewall_profile(vm, profile=profile, index=index)
337
    return None
338

    
339

    
340
@server_command("CONNECT")
341
def connect(vm, network):
342
    if network.state != 'ACTIVE':
343
        raise faults.BuildInProgress('Network not active yet')
344

    
345
    address = None
346
    if network.subnet is not None and network.dhcp:
347
        # Get a free IP from the address pool.
348
        address = util.get_network_free_address(network)
349

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

    
352
    nic = NetworkInterface.objects.create(machine=vm,
353
                                          network=network,
354
                                          ipv4=address,
355
                                          state="BUILDING")
356

    
357
    return backend.connect_to_network(vm, nic)
358

    
359

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

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

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

    
371
    return backend.disconnect_from_network(vm, nic)
372

    
373

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

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

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

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

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

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

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

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

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

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

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

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

    
427
    return console
428

    
429

    
430
@server_command("CONNECT")
431
def add_floating_ip(vm, address):
432
    floating_ip = add_floating_ip_to_vm(vm, address)
433
    nic = NetworkInterface.objects.create(machine=vm,
434
                                          network=floating_ip.network,
435
                                          ipv4=floating_ip.ipv4,
436
                                          state="BUILDING")
437
    log.info("Connecting VM %s to floating IP %s. NIC: %s", vm, floating_ip,
438
             nic)
439
    return backend.connect_to_network(vm, nic)
440

    
441

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

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

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

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

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

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

    
475

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

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

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

    
495
    return backend.disconnect_from_network(vm, nic)