Statistics
| Branch: | Tag: | Revision:

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

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.db.models import (NetworkInterface, VirtualMachine,
16
                               VirtualMachineMetadata, IPAddress)
17
from synnefo.db import query as db_query, pools
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
            if action == "BUILD" and vm.serial is not None:
114
                # XXX: Special case for server creation: we must accept the
115
                # commission because the VM has been stored in DB. Also, if
116
                # communication with Ganeti fails, the job will never reach
117
                # Ganeti, and the commission will never be resolved.
118
                quotas.accept_serial(vm.serial)
119

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

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

    
129
            return vm
130
        return wrapper
131
    return decorator
132

    
133

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

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

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

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

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

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

    
177
    # Create the server in Ganeti.
178
    vm = create_server(vm, nics, flavor, image, personality, password)
179

    
180
    return vm
181

    
182

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

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

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

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

    
202

    
203
@server_command("BUILD")
204
def create_server(vm, nics, flavor, image, personality, password):
205
    # dispatch server created signal needed to trigger the 'vmapi', which
206
    # enriches the vm object with the 'config_url' attribute which must be
207
    # passed to the Ganeti job.
208
    server_created.send(sender=vm, created_vm_params={
209
        'img_id': image['backend_id'],
210
        'img_passwd': password,
211
        'img_format': str(image['format']),
212
        'img_personality': json.dumps(personality),
213
        'img_properties': json.dumps(image['metadata']),
214
    })
215
    # send job to Ganeti
216
    try:
217
        jobID = backend.create_instance(vm, nics, flavor, image)
218
    except:
219
        log.exception("Failed create instance '%s'", vm)
220
        jobID = None
221
        vm.operstate = "ERROR"
222
        vm.backendlogmsg = "Failed to send job to Ganeti."
223
        vm.save()
224
        vm.nics.all().update(state="ERROR")
225

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

    
232
    return jobID
233

    
234

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

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

243
    """
244
    nics = []
245
    for network_id in settings.DEFAULT_INSTANCE_NETWORKS:
246
        if network_id == "SNF:ANY_PUBLIC":
247
            ipaddress = util.allocate_public_ip(userid=userid,
248
                                                backend=vm.backend)
249
            nic, ipaddress = create_nic(vm, ipaddress=ipaddress)
250
        else:
251
            try:
252
                network = util.get_network(network_id, userid,
253
                                           non_deleted=True)
254
            except faults.ItemNotFound:
255
                msg = "Invalid configuration. Setting"\
256
                      " 'DEFAULT_INSTANCE_NETWORKS' contains invalid"\
257
                      " network '%s'" % network_id
258
                log.error(msg)
259
                raise faults.InternalServerError(msg)
260
            nic, ipaddress = create_nic(vm, network=network)
261
        nics.append(nic)
262
    for address in floating_ips:
263
        floating_ip = util.get_floating_ip_by_address(vm.userid, address,
264
                                                      for_update=True)
265
        nic, ipaddress = create_nic(vm, ipaddress=floating_ip)
266
        nics.append(nic)
267
    for network_id in private_networks:
268
        network = util.get_network(network_id, userid, non_deleted=True)
269
        if network.public:
270
            raise faults.Forbidden("Can not connect to public network")
271
        nic, ipaddress = create_nic(vm, network=network)
272
        nics.append(nic)
273
    for index, nic in enumerate(nics):
274
        nic.index = index
275
        nic.save()
276
    return nics
277

    
278

    
279
def create_nic(vm, network=None, ipaddress=None, address=None, name=None):
280
    """Helper functions for create NIC objects.
281

282
    Create a NetworkInterface connecting a VirtualMachine to a network with the
283
    IPAddress specified. If no 'ipaddress' is passed and the network has an
284
    IPv4 subnet, then an IPv4 address will be automatically be allocated.
285

286
    """
287
    userid = vm.userid
288

    
289
    if ipaddress is None:
290
        if network.subnets.filter(ipversion=4).exists():
291
            try:
292
                ipaddress = util.allocate_ip(network, userid=userid,
293
                                             address=address)
294
            except pools.ValueNotAvailable:
295
                raise faults.Conflict("Address '%s' is not available." %
296
                                      address)
297

    
298
    if ipaddress is not None and ipaddress.nic is not None:
299
        raise faults.Conflict("IP address '%s' already in use" %
300
                              ipaddress.address)
301

    
302
    if network is None:
303
        network = ipaddress.network
304
    elif network.state != 'ACTIVE':
305
        # TODO: What if is in settings ?
306
        raise faults.BuildInProgress('Network not active yet')
307

    
308
    #device_owner = "router" if vm.router else "vm"
309
    device_owner = "vm"
310
    nic = NetworkInterface.objects.create(machine=vm, network=network,
311
                                          state="BUILD",
312
                                          userid=vm.userid,
313
                                          device_owner=device_owner,
314
                                          name=name)
315
    if ipaddress is not None:
316
        ipaddress.nic = nic
317
        ipaddress.save()
318

    
319
    return nic, ipaddress
320

    
321

    
322
@server_command("DESTROY")
323
def destroy(vm):
324
    log.info("Deleting VM %s", vm)
325
    return backend.delete_instance(vm)
326

    
327

    
328
@server_command("START")
329
def start(vm):
330
    log.info("Starting VM %s", vm)
331
    return backend.startup_instance(vm)
332

    
333

    
334
@server_command("STOP")
335
def stop(vm):
336
    log.info("Stopping VM %s", vm)
337
    return backend.shutdown_instance(vm)
338

    
339

    
340
@server_command("REBOOT")
341
def reboot(vm, reboot_type):
342
    if reboot_type not in ("SOFT", "HARD"):
343
        raise faults.BadRequest("Malformed request. Invalid reboot"
344
                                " type %s" % reboot_type)
345
    log.info("Rebooting VM %s. Type %s", vm, reboot_type)
346

    
347
    return backend.reboot_instance(vm, reboot_type.lower())
348

    
349

    
350
@server_command("RESIZE")
351
def resize(vm, flavor):
352
    old_flavor = vm.flavor
353
    # User requested the same flavor
354
    if old_flavor.id == flavor.id:
355
        raise faults.BadRequest("Server '%s' flavor is already '%s'."
356
                                % (vm, flavor))
357
        return None
358
    # Check that resize can be performed
359
    if old_flavor.disk != flavor.disk:
360
        raise faults.BadRequest("Can not resize instance disk.")
361
    if old_flavor.disk_template != flavor.disk_template:
362
        raise faults.BadRequest("Can not change instance disk template.")
363

    
364
    log.info("Resizing VM from flavor '%s' to '%s", old_flavor, flavor)
365
    commission_info = {"cyclades.cpu": flavor.cpu - old_flavor.cpu,
366
                       "cyclades.ram": 1048576 * (flavor.ram - old_flavor.ram)}
367
    # Save serial to VM, since it is needed by server_command decorator
368
    vm.serial = quotas.issue_commission(user=vm.userid,
369
                                        source=quotas.DEFAULT_SOURCE,
370
                                        provisions=commission_info,
371
                                        name="resource: %s. resize" % vm)
372
    return backend.resize_instance(vm, vcpus=flavor.cpu, memory=flavor.ram)
373

    
374

    
375
@server_command("SET_FIREWALL_PROFILE")
376
def set_firewall_profile(vm, profile, nic):
377
    log.info("Setting VM %s, NIC %s, firewall %s", vm, nic, profile)
378

    
379
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
380
        raise faults.BadRequest("Unsupported firewall profile")
381
    backend.set_firewall_profile(vm, profile=profile, nic=nic)
382
    return None
383

    
384

    
385
@server_command("CONNECT")
386
def connect(vm, network, port=None):
387
    if port is None:
388
        nic, ipaddress = create_nic(vm, network)
389
    else:
390
        nic = port
391
        ipaddress = port.ips.all()[0]
392

    
393
    log.info("Creating NIC %s with IPAddress %s", nic, ipaddress)
394

    
395
    return backend.connect_to_network(vm, nic)
396

    
397

    
398
@server_command("DISCONNECT")
399
def disconnect(vm, nic):
400
    log.info("Removing NIC %s from VM %s", nic, vm)
401
    return backend.disconnect_from_network(vm, nic)
402

    
403

    
404
def console(vm, console_type):
405
    """Arrange for an OOB console of the specified type
406

407
    This method arranges for an OOB console of the specified type.
408
    Only consoles of type "vnc" are supported for now.
409

410
    It uses a running instance of vncauthproxy to setup proper
411
    VNC forwarding with a random password, then returns the necessary
412
    VNC connection info to the caller.
413

414
    """
415
    log.info("Get console  VM %s, type %s", vm, console_type)
416

    
417
    # Use RAPI to get VNC console information for this instance
418
    if vm.operstate != "STARTED":
419
        raise faults.BadRequest('Server not in ACTIVE state.')
420

    
421
    if settings.TEST:
422
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
423
    else:
424
        console_data = backend.get_instance_console(vm)
425

    
426
    if console_data['kind'] != 'vnc':
427
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
428
        raise faults.ServiceUnavailable(message)
429

    
430
    # Let vncauthproxy decide on the source port.
431
    # The alternative: static allocation, e.g.
432
    # sport = console_data['port'] - 1000
433
    sport = 0
434
    daddr = console_data['host']
435
    dport = console_data['port']
436
    password = util.random_password()
437

    
438
    if settings.TEST:
439
        fwd = {'source_port': 1234, 'status': 'OK'}
440
    else:
441
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
442

    
443
    if fwd['status'] != "OK":
444
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
445

    
446
    # Verify that the VNC server settings haven't changed
447
    if not settings.TEST:
448
        if console_data != backend.get_instance_console(vm):
449
            raise faults.ServiceUnavailable('VNC Server settings changed.')
450

    
451
    console = {
452
        'type': 'vnc',
453
        'host': getfqdn(),
454
        'port': fwd['source_port'],
455
        'password': password}
456

    
457
    return console
458

    
459

    
460
@server_command("CONNECT")
461
def add_floating_ip(vm, address):
462
    # Use for_update, to guarantee that floating IP will only by assigned once
463
    # and that it can not be released will it is being attached!
464
    floating_ip = util.get_floating_ip_by_address(vm.userid, address,
465
                                                  for_update=True)
466
    nic, floating_ip = create_nic(vm, ipaddress=floating_ip)
467
    log.info("Created NIC %s with floating IP %s", nic, floating_ip)
468
    return backend.connect_to_network(vm, nic)
469

    
470

    
471
@server_command("DISCONNECT")
472
def remove_floating_ip(vm, address):
473
    try:
474
        floating_ip = db_query.get_server_floating_ip(server=vm,
475
                                                      address=address,
476
                                                      for_update=True)
477
    except IPAddress.DoesNotExist:
478
        raise faults.BadRequest("Server '%s' has no floating ip with"
479
                                " address '%s'" % (vm, address))
480

    
481
    nic = floating_ip.nic
482
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
483
             vm, floating_ip)
484

    
485
    return backend.disconnect_from_network(vm, nic)
486

    
487

    
488
def rename(server, new_name):
489
    """Rename a VirtualMachine."""
490
    old_name = server.name
491
    server.name = new_name
492
    server.save()
493
    log.info("Renamed server '%s' from '%s' to '%s'", server, old_name,
494
             new_name)
495
    return server