Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (13.6 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)
18
from synnefo.db.pools import EmptyPool
19

    
20
from vncauthproxy.client import request_forwarding as request_vnc_forwarding
21

    
22
log = logging.getLogger(__name__)
23

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

    
27

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

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

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

    
44
    # Check if action can be performed to VM's operstate
45
    operstate = vm.operstate
46
    if operstate == "BUILD":
47
        raise faults.BuildInProgress("Server '%s' is being build." % vm.id)
48
    elif (action == "START" and operstate == "STARTED") or\
49
         (action == "STOP" and operstate == "STOPPED") or\
50
         (action == "RESIZE" and operstate == "STARTED"):
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

    
82
            # Resolve(reject) previous serial if it is still pending!!
83
            previous_serial = vm.serial
84
            if previous_serial and not previous_serial.resolved:
85
                quotas.resolve_vm_commission(serial=previous_serial)
86

    
87
            # Check if action is quotable and issue the corresponding
88
            # commission
89
            serial = None
90
            commission_info = quotas.get_commission_info(vm, action=action)
91
            if commission_info is not None:
92
                # Issue new commission, associate it with the VM
93
                serial = quotas.issue_commission(user=user_id,
94
                                                 source=quotas.DEFAULT_SOURCE,
95
                                                 provisions=commission_info,
96
                                                 force=False,
97
                                                 auto_accept=False)
98
            vm.serial = serial
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_manually
128
def create(userid, name, password, flavor, image, metadata={},
129
           personality=[], network=None, use_backend=None):
130
    if use_backend is None:
131
        # Allocate backend to host the server. Commit after allocation to
132
        # release the locks hold by the backend allocator.
133
        try:
134
            backend_allocator = BackendAllocator()
135
            use_backend = backend_allocator.allocate(userid, flavor)
136
            if use_backend is None:
137
                log.error("No available backend for VM with flavor %s", flavor)
138
                raise faults.ServiceUnavailable("No available backends")
139
        except:
140
            transaction.rollback()
141
            raise
142
        else:
143
            transaction.commit()
144

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

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

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

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

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

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

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

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

    
233
    return vm
234

    
235

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

    
241

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

    
247

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

    
253

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

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

    
263

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

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

    
287

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

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

    
297

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

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

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

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

    
312

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

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

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

    
324
    return backend.disconnect_from_network(vm, nic)
325

    
326

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

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

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

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

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

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

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

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

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

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

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

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

    
380
    return console