Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (15.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 synnefo 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,
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, use_backend=None):
129
    if use_backend is None:
130
        # Allocate backend to host the server. Commit after allocation to
131
        # release the locks hold by the backend allocator.
132
        try:
133
            backend_allocator = BackendAllocator()
134
            use_backend = backend_allocator.allocate(userid, flavor)
135
            if use_backend is None:
136
                log.error("No available backend for VM with flavor %s", flavor)
137
                raise faults.ServiceUnavailable("No available backends")
138
        except:
139
            transaction.rollback()
140
            raise
141
        else:
142
            transaction.commit()
143

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

    
156
    try:
157
        if network is None:
158
            # Allocate IP from public network
159
            (network, address) = util.get_public_ip(use_backend)
160
            nic = {'ip': address, 'network': network.backend_id}
161
        else:
162
            address = util.get_network_free_address(network)
163

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

    
174
        # Create VM's public NIC. Do not wait notification form ganeti hooks to
175
        # create this NIC, because if the hooks never run (e.g. building error)
176
        # the VM's public IP address will never be released!
177
        NetworkInterface.objects.create(machine=vm, network=network, index=0,
178
                                        ipv4=address, state="BUILDING")
179

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

    
182
        # dispatch server created signal
183
        server_created.send(sender=vm, created_vm_params={
184
            'img_id': image['backend_id'],
185
            'img_passwd': password,
186
            'img_format': str(image['format']),
187
            'img_personality': json.dumps(personality),
188
            'img_properties': json.dumps(image['metadata']),
189
        })
190

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

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

    
232
    return vm
233

    
234

    
235
@server_command("DESTROY")
236
def destroy(vm):
237
    log.info("Deleting VM %s", vm)
238
    return backend.delete_instance(vm)
239

    
240

    
241
@server_command("START")
242
def start(vm):
243
    log.info("Starting VM %s", vm)
244
    return backend.startup_instance(vm)
245

    
246

    
247
@server_command("STOP")
248
def stop(vm):
249
    log.info("Stopping VM %s", vm)
250
    return backend.shutdown_instance(vm)
251

    
252

    
253
@server_command("REBOOT")
254
def reboot(vm, reboot_type):
255
    if reboot_type not in ("SOFT", "HARD"):
256
        raise faults.BadRequest("Malformed request. Invalid reboot"
257
                                " type %s" % reboot_type)
258
    log.info("Rebooting VM %s. Type %s", vm, reboot_type)
259

    
260
    return backend.reboot_instance(vm, reboot_type.lower())
261

    
262

    
263
@server_command("RESIZE")
264
def resize(vm, flavor):
265
    old_flavor = vm.flavor
266
    # User requested the same flavor
267
    if old_flavor.id == flavor.id:
268
        raise faults.BadRequest("Server '%s' flavor is already '%s'."
269
                                % (vm, flavor))
270
        return None
271
    # Check that resize can be performed
272
    if old_flavor.disk != flavor.disk:
273
        raise faults.BadRequest("Can not resize instance disk.")
274
    if old_flavor.disk_template != flavor.disk_template:
275
        raise faults.BadRequest("Can not change instance disk template.")
276

    
277
    log.info("Resizing VM from flavor '%s' to '%s", old_flavor, flavor)
278
    commission_info = {"cyclades.cpu": flavor.cpu - old_flavor.cpu,
279
                       "cyclades.ram": flavor.ram - old_flavor.ram}
280
    # Save serial to VM, since it is needed by server_command decorator
281
    vm.serial = quotas.issue_commission(user=vm.userid,
282
                                        source=quotas.DEFAULT_SOURCE,
283
                                        provisions=commission_info)
284
    return backend.resize_instance(vm, vcpus=flavor.cpu, memory=flavor.ram)
285

    
286

    
287
@server_command("SET_FIREWALL_PROFILE")
288
def set_firewall_profile(vm, profile):
289
    log.info("Setting VM %s firewall %s", vm, profile)
290

    
291
    if profile not in [x[0] for x in NetworkInterface.FIREWALL_PROFILES]:
292
        raise faults.BadRequest("Unsupported firewall profile")
293
    backend.set_firewall_profile(vm, profile)
294
    return None
295

    
296

    
297
@server_command("CONNECT")
298
def connect(vm, network):
299
    if network.state != 'ACTIVE':
300
        raise faults.BuildInProgress('Network not active yet')
301

    
302
    address = None
303
    if network.dhcp:
304
        # Get a free IP from the address pool.
305
        address = util.get_network_free_address(network)
306

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

    
309
    return backend.connect_to_network(vm, network, address)
310

    
311

    
312
@server_command("DISCONNECT")
313
def disconnect(vm, nic_index):
314
    nic = util.get_nic_from_index(vm, nic_index)
315

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

    
318
    if nic.dirty:
319
        raise faults.BuildInProgress('Machine is busy.')
320
    else:
321
        vm.nics.all().update(dirty=True)
322

    
323
    return backend.disconnect_from_network(vm, nic)
324

    
325

    
326
def console(vm, console_type):
327
    """Arrange for an OOB console of the specified type
328

329
    This method arranges for an OOB console of the specified type.
330
    Only consoles of type "vnc" are supported for now.
331

332
    It uses a running instance of vncauthproxy to setup proper
333
    VNC forwarding with a random password, then returns the necessary
334
    VNC connection info to the caller.
335

336
    """
337
    log.info("Get console  VM %s, type %s", vm, console_type)
338

    
339
    # Use RAPI to get VNC console information for this instance
340
    if vm.operstate != "STARTED":
341
        raise faults.BadRequest('Server not in ACTIVE state.')
342

    
343
    if settings.TEST:
344
        console_data = {'kind': 'vnc', 'host': 'ganeti_node', 'port': 1000}
345
    else:
346
        console_data = backend.get_instance_console(vm)
347

    
348
    if console_data['kind'] != 'vnc':
349
        message = 'got console of kind %s, not "vnc"' % console_data['kind']
350
        raise faults.ServiceUnavailable(message)
351

    
352
    # Let vncauthproxy decide on the source port.
353
    # The alternative: static allocation, e.g.
354
    # sport = console_data['port'] - 1000
355
    sport = 0
356
    daddr = console_data['host']
357
    dport = console_data['port']
358
    password = util.random_password()
359

    
360
    if settings.TEST:
361
        fwd = {'source_port': 1234, 'status': 'OK'}
362
    else:
363
        fwd = request_vnc_forwarding(sport, daddr, dport, password)
364

    
365
    if fwd['status'] != "OK":
366
        raise faults.ServiceUnavailable('vncauthproxy returned error status')
367

    
368
    # Verify that the VNC server settings haven't changed
369
    if not settings.TEST:
370
        if console_data != backend.get_instance_console(vm):
371
            raise faults.ServiceUnavailable('VNC Server settings changed.')
372

    
373
    console = {
374
        'type': 'vnc',
375
        'host': getfqdn(),
376
        'port': fwd['source_port'],
377
        'password': password}
378

    
379
    return console
380

    
381

    
382
@server_command("CONNECTING")
383
def add_floating_ip(vm, address):
384
    user_id = vm.userid
385
    # Get lock in VM, to guarantee that floating IP will only by assigned once
386
    try:
387
        floating_ip = FloatingIP.objects.select_for_update()\
388
                                        .get(userid=user_id, ipv4=address,
389
                                             deleted=False)
390
    except FloatingIP.DoesNotExist:
391
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
392

    
393
    if floating_ip.in_use():
394
        raise faults.Conflict("Floating IP '%s' already in use" %
395
                              floating_ip.id)
396

    
397
    floating_ip.machine = vm
398
    floating_ip.save()
399

    
400
    log.info("Connecting VM %s to floating IP %s", vm, floating_ip)
401
    return backend.connect_to_network(vm, floating_ip.network, address)
402

    
403

    
404
@server_command("DISCONNECTING")
405
def remove_floating_ip(vm, address):
406
    user_id = vm.userid
407
    try:
408
        floating_ip = FloatingIP.objects.select_for_update()\
409
                                        .get(userid=user_id, ipv4=address,
410
                                             deleted=False, machine=vm)
411
    except FloatingIP.DoesNotExist:
412
        raise faults.ItemNotFound("Floating IP '%s' does not exist" % address)
413

    
414
    try:
415
        nic = NetworkInterface.objects.get(machine=vm, ipv4=address)
416
    except NetworkInterface.DoesNotExist:
417
        raise faults.ItemNotFound("Floating IP '%s' is not attached to"
418
                                  "VM '%s'" % (floating_ip, vm))
419

    
420
    log.info("Removing NIC %s from VM %s. Floating IP '%s'", str(nic.index),
421
             vm, floating_ip)
422

    
423
    return backend.disconnect_from_network(vm, nic)