Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (18.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 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
            if action == "BUILD" and vm.serial is not None:
113
                # XXX: Special case for server creation: we must accept the
114
                # commission because the VM has been stored in DB. Also, if
115
                # communication with Ganeti fails, the job will never reach
116
                # Ganeti, and the commission will never be resolved.
117
                quotas.accept_serial(vm.serial)
118

    
119
            log.info("user: %s, vm: %s, action: %s, job_id: %s, serial: %s",
120
                     user_id, vm.id, action, job_id, vm.serial)
121

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

    
128
            return vm
129
        return wrapper
130
    return decorator
131

    
132

    
133
@transaction.commit_on_success
134
def create(userid, name, password, flavor, image, metadata={},
135
           personality=[], private_networks=None, floating_ips=None,
136
           use_backend=None):
137
    if use_backend is None:
138
        # Allocate server to a Ganeti backend
139
        use_backend = allocate_new_server(userid, flavor)
140

    
141
    if private_networks is None:
142
        private_networks = []
143
    if floating_ips is None:
144
        floating_ips = []
145

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

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

    
168
    nics = create_instance_nics(vm, userid, private_networks, floating_ips)
169

    
170
    for key, val in metadata.items():
171
        VirtualMachineMetadata.objects.create(
172
            meta_key=key,
173
            meta_value=val,
174
            vm=vm)
175

    
176
    # Create the server in Ganeti.
177
    vm = create_server(vm, nics, flavor, image, personality, password)
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
    try:
216
        jobID = backend.create_instance(vm, nics, flavor, image)
217
    except:
218
        log.exception("Failed create instance '%s'", vm)
219
        jobID = None
220
        vm.operstate = "ERROR"
221
        vm.backendlogmsg = "Failed to send job to Ganeti."
222
        vm.save()
223
        vm.nics.all().update(state="ERROR")
224

    
225
    # At this point the job is enqueued in the Ganeti backend
226
    vm.backendjobid = jobID
227
    vm.save()
228
    log.info("User %s created VM %s, NICs %s, Backend %s, JobID %s",
229
             vm.userid, vm, nics, backend, str(jobID))
230

    
231
    return jobID
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, nic):
341
    log.info("Setting VM %s, NIC %s, firewall %s", vm, nic, 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, nic=nic)
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
    nic = NetworkInterface.objects.create(machine=vm, network=network,
359
                                          ip_type="STATIC", ipv4=address,
360
                                          state="BUILDING")
361
    log.info("Connecting VM %s to Network %s. NIC: %s", vm, network, nic)
362

    
363
    return backend.connect_to_network(vm, nic)
364

    
365

    
366
@server_command("DISCONNECT")
367
def disconnect(vm, nic):
368
    log.info("Removing NIC %s from VM %s", nic, vm)
369
    return backend.disconnect_from_network(vm, nic)
370

    
371

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

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

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

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

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

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

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

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

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

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

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

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

    
425
    return console
426

    
427

    
428
@server_command("CONNECT")
429
def add_floating_ip(vm, address):
430
    floating_ip = add_floating_ip_to_vm(vm, address)
431
    nic = NetworkInterface.objects.create(machine=vm,
432
                                          network=floating_ip.network,
433
                                          ipv4=floating_ip.ipv4,
434
                                          ip_type="FLOATING",
435
                                          state="BUILDING")
436
    log.info("Connecting VM %s to floating IP %s. NIC: %s", vm, floating_ip,
437
             nic)
438
    return backend.connect_to_network(vm, nic)
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)
495

    
496

    
497
def rename(server, new_name):
498
    """Rename a VirtualMachine."""
499
    old_name = server.name
500
    server.name = new_name
501
    server.save()
502
    log.info("Renamed server '%s' from '%s' to '%s'", server, old_name,
503
             new_name)
504
    return server