Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.8 kB)

1
import logging
2
import datetime
3

    
4
from socket import getfqdn
5
from functools import wraps
6
from django import dispatch
7
from django.db import transaction
8
from django.utils import simplejson as json
9

    
10
from snf_django.lib.api import faults
11
from django.conf import settings
12
from synnefo import quotas
13
from synnefo.api import util
14
from synnefo.logic import backend
15
from synnefo.logic.backend_allocator import BackendAllocator
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" and action != "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
            commission_name = "client: api, resource: %s" % vm
85
            quotas.handle_resource_commission(vm, action=action,
86
                                              commission_name=commission_name)
87
            vm.save()
88

    
89
            # XXX: Special case for server creation!
90
            if action == "BUILD":
91
                # Perform a commit, because the VirtualMachine must be saved to
92
                # DB before the OP_INSTANCE_CREATE job in enqueued in Ganeti.
93
                # Otherwise, messages will arrive from snf-dispatcher about
94
                # this instance, before the VM is stored in DB.
95
                transaction.commit()
96
                # After committing the locks are released. Refetch the instance
97
                # to guarantee x-lock.
98
                vm = VirtualMachine.objects.select_for_update().get(id=vm.id)
99

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

    
113
            log.info("user: %s, vm: %s, action: %s, job_id: %s, serial: %s",
114
                     user_id, vm.id, action, job_id, vm.serial)
115

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

    
122
            return vm
123
        return wrapper
124
    return decorator
125

    
126

    
127
@transaction.commit_on_success
128
def create(userid, name, password, flavor, image, metadata={},
129
           personality=[], private_networks=None, floating_ips=None,
130
           use_backend=None):
131
    if use_backend is None:
132
        # Allocate server to a Ganeti backend
133
        use_backend = allocate_new_server(userid, flavor)
134

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

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

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

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

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

    
170
    try:
171
        # Create the server in Ganeti.
172
        create_server(vm, nics, flavor, image, personality, password)
173
    except:
174
        # If an exception is raised, then the user will never get the VM id.
175
        # In order to delete it from DB and release it's resources, we
176
        # mock a successful OP_INSTANCE_REMOVE job.
177
        backend.process_op_status(vm=vm, etime=datetime.datetime.now(),
178
                                  jobid=-0,
179
                                  opcode="OP_INSTANCE_REMOVE",
180
                                  status="success",
181
                                  logmsg="Reconciled eventd: VM creation"
182
                                         " failed.")
183
        raise
184

    
185
    return vm
186

    
187

    
188
@transaction.commit_on_success
189
def allocate_new_server(userid, flavor):
190
    """Allocate a new server to a Ganeti backend.
191

192
    Allocation is performed based on the owner of the server and the specified
193
    flavor. Also, backends that do not have a public IPv4 address are excluded
194
    from server allocation.
195

196
    This function runs inside a transaction, because after allocating the
197
    instance a commit must be performed in order to release all locks.
198

199
    """
200
    backend_allocator = BackendAllocator()
201
    use_backend = backend_allocator.allocate(userid, flavor)
202
    if use_backend is None:
203
        log.error("No available backend for VM with flavor %s", flavor)
204
        raise faults.ServiceUnavailable("No available backends")
205
    return use_backend
206

    
207

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

    
228
    return jobID
229

    
230

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

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

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

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

    
282

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

    
288

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

    
294

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

    
300

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

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

    
310

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

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

    
335

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

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

    
345

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

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

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

    
358
    nic = NetworkInterface.objects.create(machine=vm,
359
                                          network=network,
360
                                          ipv4=address,
361
                                          state="BUILDING")
362

    
363
    return backend.connect_to_network(vm, nic)
364

    
365

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

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

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

    
377
    return backend.disconnect_from_network(vm, nic)
378

    
379

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

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

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

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

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

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

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

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

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

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

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

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

    
433
    return console
434

    
435

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

    
447

    
448
def add_floating_ip_to_vm(vm, address):
449
    """Get a floating IP by it's address and add it to VirtualMachine.
450

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

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

    
467
    if floating_ip.in_use():
468
        raise faults.Conflict("Floating IP '%s' already in use" %
469
                              floating_ip.id)
470

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

    
477
    floating_ip.machine = vm
478
    floating_ip.save()
479
    return floating_ip
480

    
481

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

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

    
498
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
499
             vm, floating_ip)
500

    
501
    return backend.disconnect_from_network(vm, nic)