Statistics
| Branch: | Tag: | Revision:

root / db / models.py @ 4e6f9904

History | View | Annotate | Download (19.8 kB)

1
# vim: ts=4 sts=4 et ai sw=4 fileencoding=utf-8
2

    
3
from django.conf import settings
4
from django.db import models
5
from django.db import transaction
6

    
7
import datetime
8

    
9
backend_prefix_id = settings.BACKEND_PREFIX_ID
10

    
11
class SynnefoUser(models.Model):
12
    name = models.CharField('Synnefo Username', max_length=255)
13
    credit = models.IntegerField('Credit Balance')
14
    created = models.DateTimeField('Time of creation', auto_now_add=True)
15
    updated = models.DateTimeField('Time of last update', auto_now=True)
16
    #
17
    # We do not rely on Django's user authentication mechanism.
18
    # Hence, no references to the User model.
19
    # [vkoukis], after discussion with [faidon].
20
    # user = models.ForeignKey(User)
21
    
22
    class Meta:
23
        verbose_name = u'Synnefo User'
24
    
25
    def __unicode__(self):
26
        return self.name 
27

    
28
    def get_limit(self, limit_name):
29
        """Returns the limit value for the specified limit"""
30
        limit_objs = Limit.objects.filter(name=limit_name, user=self)        
31
        if len(limit_objs) == 1:
32
            return limit_objs[0].value
33
        
34
        return 0
35
        
36
    def _get_credit_quota(self):
37
        """Internal getter function for credit quota"""
38
        return self.get_limit('QUOTA_CREDIT')
39
        
40
    credit_quota = property(_get_credit_quota)
41
    
42
    def _get_monthly_rate(self):
43
        """Internal getter function for monthly credit issue rate"""
44
        return self.get_limit('MONTHLY_RATE')
45
        
46
    monthly_rate = property(_get_monthly_rate)
47
    
48
    def _get_min_credits(self):
49
        """Internal getter function for maximum number of violations"""
50
        return self.get_limit('MIN_CREDITS')
51
        
52
    min_credits = property(_get_min_credits)
53

    
54
    @transaction.commit_on_success
55
    def debit_account(self, amount, vm, description):
56
        """Charges the user with the specified amount of credits for a vm (resource)"""
57
        date_now = datetime.datetime.now()
58
        self.credit = self.credit - amount
59
        self.save()
60

    
61
        # then write the debit entry
62
        debit = Debit()
63

    
64
        debit.user = self
65
        debit.vm = vm
66
        debit.when = date_now
67
        debit.description = description
68

    
69
        debit.save()
70

    
71
    def credit_account(self, amount, creditor, description):
72
        """No clue :)"""
73
        return
74

    
75

    
76
class Image(models.Model):
77
    # This is WIP, FIXME
78
    IMAGE_STATES = (
79
        ('ACTIVE', 'Active'),
80
        ('SAVING', 'Saving'),
81
        ('DELETED', 'Deleted')
82
    )
83

    
84
    name = models.CharField('Image name', max_length=255)
85
    state = models.CharField('Current Image State', choices=IMAGE_STATES, max_length=30)
86
    description = models.TextField('General description')
87
    size = models.PositiveIntegerField('Image size in MBs')
88
    owner = models.ForeignKey(SynnefoUser, blank=True, null=True)
89
    created = models.DateTimeField('Time of creation', auto_now_add=True)
90
    updated = models.DateTimeField('Time of last update', auto_now=True)
91
    sourcevm = models.ForeignKey("VirtualMachine", null=True)
92

    
93
    class Meta:
94
        verbose_name = u'Image'
95

    
96
    def __unicode__(self):
97
        return u'%s' % ( self.name, )
98

    
99

    
100
class ImageMetadata(models.Model):
101
    meta_key = models.CharField('Image metadata key name', max_length=50)
102
    meta_value = models.CharField('Image metadata value', max_length=500)
103
    image = models.ForeignKey(Image)
104
    
105
    class Meta:
106
        verbose_name = u'Key-value pair of Image metadata.'
107
    
108
    def __unicode__(self):
109
        return u'%s, %s for %s' % (self.meta_key, self.meta_value, self.image.name)
110

    
111

    
112
class Limit(models.Model):
113
    LIMITS = (
114
        ('QUOTA_CREDIT', 'Maximum amount of credits per user'),
115
        ('MIN_CREDITS', 'Minimum amount of credits per user'),
116
        ('MONTHLY_RATE', 'Monthly credit issue rate')
117
    )
118
    user = models.ForeignKey(SynnefoUser)
119
    name = models.CharField('Limit key name', choices=LIMITS, max_length=30, null=False)
120
    value = models.IntegerField('Limit current value')
121
    
122
    class Meta:
123
        verbose_name = u'Enforced limit for user'
124
    
125
    def __unicode__(self):
126
        return u'Limit %s for user %s: %d' % (self.value, self.user, self.value)
127

    
128

    
129
class Flavor(models.Model):
130
    cpu = models.IntegerField('Number of CPUs', default=0)
131
    ram = models.IntegerField('Size of RAM', default=0)
132
    disk = models.IntegerField('Size of Disk space', default=0)
133
    
134
    class Meta:
135
        verbose_name = u'Virtual machine flavor'
136
        unique_together = ("cpu","ram","disk")
137
            
138
    def _get_name(self):
139
        """Returns flavor name (generated)"""
140
        return u'C%dR%dD%d' % (self.cpu, self.ram, self.disk)
141

    
142
    def _current_cost(self, active):
143
        """Returns active/inactive cost value
144

145
        set active = True to get active cost and False for the inactive.
146

147
        """
148
        fch_list = FlavorCost.objects.filter(flavor=self).order_by('-effective_from')
149
        if len(fch_list) > 0:
150
            if active:
151
                return fch_list[0].cost_active
152
            else:
153
                return fch_list[0].cost_inactive
154

    
155
        return 0
156
        
157

    
158
    def _current_cost_active(self):
159
        """Returns current active cost (property method)"""
160
        return self._current_cost(True)
161

    
162
    def _current_cost_inactive(self):
163
        """Returns current inactive cost (property method)"""
164
        return self._current_cost(False)
165

    
166
    name = property(_get_name)
167
    current_cost_active = property(_current_cost_active)
168
    current_cost_inactive = property(_current_cost_inactive)
169

    
170
    def __unicode__(self):
171
        return self.name
172

    
173
    def _get_costs(self, start_datetime, end_datetime, active):
174
        """Return a list with FlavorCost objects for the specified duration"""
175
        def between(enh_fc, a_date):
176
            """Checks if a date is between a FlavorCost duration"""
177
            if enh_fc.effective_from <= a_date and enh_fc.effective_to is None:
178
                return True
179

    
180
            return enh_fc.effective_from <= a_date and enh_fc.effective_to >= a_date
181

    
182
        def calculate_cost(start_date, end_date, cost):
183
            """Calculate the total cost for the specified duration"""
184
            td = end_date - start_date
185
            sec = float(td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / float(10**6)
186
            total_hours = float(sec) / float(60.0*60.0)
187
            total_cost = float(cost)*total_hours
188

    
189
            return round(total_cost)
190

    
191
        # Get the related FlavorCost objects, sorted.
192
        price_list = FlavorCost.objects.filter(flavor=self).order_by('effective_from')
193

    
194
        # add the extra field FlavorCost.effective_to
195
        for idx in range(0, len(price_list)):
196
            if idx + 1 == len(price_list):
197
                price_list[idx].effective_to = None
198
            else:
199
                price_list[idx].effective_to = price_list[idx + 1].effective_from
200

    
201
        price_result = []
202
        found_start = False
203

    
204
        # Find the affected FlavorCost, according to the
205
        # dates, and put them in price_result
206
        for p in price_list:
207
            if between(p, start_datetime):
208
                found_start = True
209
                p.effective_from = start_datetime
210
            if between(p, end_datetime):
211
                p.effective_to = end_datetime
212
                price_result.append(p)
213
                break
214
            if found_start:
215
                price_result.append(p)
216

    
217
        results = []
218

    
219
        # Create the list and the result tuples
220
        for p in price_result:
221
            if active:
222
                cost = p.cost_active
223
            else:
224
                cost = p.cost_inactive
225

    
226
            results.append( ( p.effective_from, calculate_cost(p.effective_from, p.effective_to, cost)) )
227

    
228
        return results
229

    
230
    def get_cost_active(self, start_datetime, end_datetime):
231
        """Returns a list with the active costs for the specified duration"""
232
        return self._get_costs(start_datetime, end_datetime, True)
233

    
234
    def get_cost_inactive(self, start_datetime, end_datetime):
235
        """Returns a list with the inactive costs for the specified duration"""
236
        return self._get_costs(start_datetime, end_datetime, False)
237

    
238

    
239
class FlavorCost(models.Model):
240
    cost_active = models.PositiveIntegerField('Active Cost')
241
    cost_inactive = models.PositiveIntegerField('Inactive Cost')
242
    effective_from = models.DateTimeField()
243
    flavor = models.ForeignKey(Flavor)
244
    
245
    class Meta:
246
        verbose_name = u'Pricing history for flavors'
247
    
248
    def __unicode__(self):
249
        return u'Costs (up, down)=(%d, %d) for %s since %s' % (self.cost_active, self.cost_inactive, flavor.name, self.effective_from)
250

    
251

    
252
class VirtualMachine(models.Model):
253
    # The list of possible actions for a VM
254
    ACTIONS = (
255
       ('CREATE', 'Create VM'),
256
       ('START', 'Start VM'),
257
       ('STOP', 'Shutdown VM'),
258
       ('SUSPEND', 'Admin Suspend VM'),
259
       ('REBOOT', 'Reboot VM'),
260
       ('DESTROY', 'Destroy VM')
261
    )
262
    # The internal operating state of a VM
263
    OPER_STATES = (
264
        ('BUILD', 'Queued for creation'),
265
        ('ERROR', 'Creation failed'),
266
        ('STOPPED', 'Stopped'),
267
        ('STARTED', 'Started'),
268
        ('DESTROYED', 'Destroyed')
269
    )
270
    # The list of possible operations on the backend
271
    BACKEND_OPCODES = (
272
        ('OP_INSTANCE_CREATE', 'Create Instance'),
273
        ('OP_INSTANCE_REMOVE', 'Remove Instance'),
274
        ('OP_INSTANCE_STARTUP', 'Startup Instance'),
275
        ('OP_INSTANCE_SHUTDOWN', 'Shutdown Instance'),
276
        ('OP_INSTANCE_REBOOT', 'Reboot Instance')
277
    )
278
    # A backend job may be in one of the following possible states
279
    BACKEND_STATUSES = (
280
        ('queued', 'request queued'),
281
        ('waiting', 'request waiting for locks'),
282
        ('canceling', 'request being canceled'),
283
        ('running', 'request running'),
284
        ('canceled', 'request canceled'),
285
        ('success', 'request completed successfully'),
286
        ('error', 'request returned error')
287
    )
288

    
289
    # The operating state of a VM,
290
    # upon the successful completion of a backend operation.
291
    OPER_STATE_FROM_OPCODE = {
292
        'OP_INSTANCE_CREATE': 'STARTED',
293
        'OP_INSTANCE_REMOVE': 'DESTROYED',
294
        'OP_INSTANCE_STARTUP': 'STARTED',
295
        'OP_INSTANCE_SHUTDOWN': 'STOPPED',
296
        'OP_INSTANCE_REBOOT': 'STARTED'
297
    }
298

    
299
    # This dictionary contains the correspondence between
300
    # internal operating states and Server States as defined
301
    # by the Rackspace API.
302
    RSAPI_STATE_FROM_OPER_STATE = {
303
        "BUILD": "BUILD",
304
        "ERROR": "ERROR",
305
        "STOPPED": "STOPPED",
306
        "STARTED": "ACTIVE",
307
        "DESTROYED": "DELETED"
308
    }
309

    
310
    name = models.CharField('Virtual Machine Name', max_length=255)
311
    owner = models.ForeignKey(SynnefoUser)
312
    created = models.DateTimeField(auto_now_add=True)
313
    updated = models.DateTimeField(auto_now=True)
314
    charged = models.DateTimeField(default=datetime.datetime.now())
315
    sourceimage = models.ForeignKey("Image", null=False) 
316
    hostid = models.CharField(max_length=100)
317
    ipfour = models.IPAddressField()
318
    ipsix = models.CharField(max_length=100)
319
    flavor = models.ForeignKey(Flavor)
320
    deleted = models.BooleanField('Deleted', default=False)
321
    suspended = models.BooleanField('Administratively Suspended', default=False)
322

    
323
    # VM State 
324
    # The following fields are volatile data, in the sense
325
    # that they need not be persistent in the DB, but rather
326
    # get generated at runtime by quering Ganeti and applying
327
    # updates received from Ganeti.
328
    #
329
    # In the future they could be moved to a separate caching layer
330
    # and removed from the database.
331
    # [vkoukis] after discussion with [faidon].
332
    _action = models.CharField(choices=ACTIONS, max_length=30, null=True)
333
    _operstate = models.CharField(choices=OPER_STATES, max_length=30, null=True)
334
    _backendjobid = models.PositiveIntegerField(null=True)
335
    _backendopcode = models.CharField(choices=BACKEND_OPCODES, max_length=30, null=True)
336
    _backendjobstatus = models.CharField(choices=BACKEND_STATUSES, max_length=30, null=True)
337
    _backendlogmsg = models.TextField(null=True)
338

    
339
    # Error classes
340
    class InvalidBackendIdError(Exception):
341
         def __init__(self, value):
342
            self.value = value
343
         def __str__(self):
344
            return repr(self.value)
345

    
346
    class InvalidBackendMsgError(Exception):
347
         def __init__(self, opcode, status):
348
            self.opcode = opcode
349
            self.status = status
350
         def __str__(self):
351
            return repr("<opcode: %s, status: %s>" % (str(self.opcode), str(self.status)))
352

    
353
    class InvalidActionError(Exception):
354
         def __init__(self, action):
355
            self._action = action
356
         def __str__(self):
357
            return repr(str(self._action))
358

    
359
    @staticmethod
360
    def id_from_instance_name(name):
361
        """Returns VirtualMachine's Django id, given a ganeti machine name.
362

363
        Strips the ganeti prefix atm. Needs a better name!
364
        
365
        """
366
        if not str(name).startswith(backend_prefix_id):
367
            raise VirtualMachine.InvalidBackendIdError(str(name))
368
        ns = str(name).lstrip(backend_prefix_id)
369
        if not ns.isdigit():
370
            raise VirtualMachine.InvalidBackendIdError(str(name))
371
        return int(ns)
372

    
373
    def __init__(self, *args, **kw):
374
        """Initialize state for just created VM instances."""
375
        super(VirtualMachine, self).__init__(*args, **kw)
376
        # This gets called BEFORE an instance gets save()d for
377
        # the first time.
378
        if not self.pk: 
379
            self._action = None
380
            self._backendjobid = None
381
            self._backendjobstatus = None
382
            self._backendopcode = None
383
            self._backendlogmsg = None
384
            # Do not use _update_state() for this, 
385
            # as this may cause save() to get called in __init__(),
386
            # breaking VirtualMachine.object.create() among other things.
387
            self._operstate = 'BUILD'
388

    
389
    def process_backend_msg(self, jobid, opcode, status, logmsg):
390
        """Process a job progress notification from the backend.
391

392
        Process an incoming message from the backend (currently Ganeti).
393
        Job notifications with a terminating status (sucess, error, or canceled),
394
        also update the operating state of the VM.
395

396
        """
397
        if (opcode not in [x[0] for x in VirtualMachine.BACKEND_OPCODES] or
398
           status not in [x[0] for x in VirtualMachine.BACKEND_STATUSES]):
399
            raise VirtualMachine.InvalidBackendMsgError(opcode, status)
400

    
401
        self._backendjobid = jobid
402
        self._backendjobstatus = status
403
        self._backendopcode = opcode
404
        self._backendlogmsg = logmsg
405

    
406
        # Notifications of success change the operating state
407
        if status == 'success':
408
            self._update_state(VirtualMachine.OPER_STATE_FROM_OPCODE[opcode])
409
        # Special cases OP_INSTANCE_CREATE fails --> ERROR
410
        if status in ('canceled', 'error') and opcode == 'OP_INSTANCE_CREATE':
411
            self._update_state('ERROR')
412
        # Any other notification of failure leaves the operating state unchanged
413

    
414
        self.save()
415

    
416
    def start_action(self, action):
417
        """Update the state of a VM when a new action is initiated."""
418
        if not action in [x[0] for x in VirtualMachine.ACTIONS]:
419
            raise VirtualMachine.InvalidActionError(action)
420

    
421
        # No actions to deleted and no actions beside destroy to suspended VMs
422
        if self.deleted:
423
            raise VirtualMachine.InvalidActionError(action)
424

    
425
        self._action = action
426
        self._backendjobid = None
427
        self._backendopcode = None
428
        self._backendjobstatus = None
429
        self._backendlogmsg = None
430

    
431
        # Update the relevant flags if the VM is being suspended or destroyed
432
        if action == "DESTROY":
433
            self.deleted = True
434
        elif action == "SUSPEND":
435
            self.suspended = True
436
        elif action == "START":
437
            self.suspended = False
438
        self.save()
439

    
440
    # FIXME: Perhaps move somewhere else, outside the model?
441
    def _get_rsapi_state(self):
442
        try:
443
            r = VirtualMachine.RSAPI_STATE_FROM_OPER_STATE[self._operstate]
444
        except KeyError:
445
            return "UNKNOWN"
446
        # A machine is in REBOOT if an OP_INSTANCE_REBOOT request is in progress
447
        if r == 'ACTIVE' and self._backendopcode == 'OP_INSTANCE_REBOOT' and \
448
            self._backendjobstatus in ('queued', 'waiting', 'running'):
449
            return "REBOOT"
450
        return r 
451

    
452
    rsapi_state = property(_get_rsapi_state)
453

    
454
    def _get_backend_id(self):
455
        """Returns the backend id for this VM by prepending backend-prefix."""
456
        return '%s%s' % (backend_prefix_id, str(self.id))
457

    
458
    backend_id = property(_get_backend_id)
459

    
460
    class Meta:
461
        verbose_name = u'Virtual machine instance'
462
        get_latest_by = 'created'
463
    
464
    def __unicode__(self):
465
        return self.name
466

    
467
    def _update_state(self, new_operstate):
468
        """Wrapper around updates of the _operstate field
469

470
        Currently calls the charge() method when necessary.
471

472
        """
473

    
474
        # Call charge() unconditionally before any change of
475
        # internal state.
476
        self.charge()
477
        self._operstate = new_operstate
478

    
479
    @transaction.commit_on_success
480
    def charge(self):
481
        """Charges the owner of this VM.
482
        
483
        Charges the owner of a VM for the period
484
        from vm.charged to datetime.now(), based on the
485
        current operating state.
486
        
487
        """
488
        charged_states = ('STARTED', 'STOPPED')
489
       
490
        start_datetime = self.charged
491
        self.charged = datetime.datetime.now()
492

    
493
        # Only charge for a specific set of states
494
        if self._operstate in charged_states:
495
            cost_list = []
496

    
497
            # remember, we charge only for Started and Stopped
498
            if self._operstate == 'STARTED':
499
                cost_list = self.flavor.get_cost_active(start_datetime, self.charged)
500
            elif self._operstate == 'STOPPED':
501
                cost_list = self.flavor.get_cost_inactive(start_datetime, self.charged)
502

    
503
            # find the total vost
504
            total_cost = sum([x[1] for x in cost_list])
505

    
506
            # add the debit entry
507
            description = "Server = %s, charge = %d for state: %s" % (self.name, total_cost, self._operstate)
508
            self.owner.debit_account(total_cost, self, description)
509
        
510
        self.save()
511

    
512

    
513
class VirtualMachineGroup(models.Model):
514
    """Groups of VMs for SynnefoUsers"""
515
    name = models.CharField(max_length=255)
516
    created = models.DateTimeField('Time of creation', auto_now_add=True)
517
    updated = models.DateTimeField('Time of last update', auto_now=True)
518
    owner = models.ForeignKey(SynnefoUser)
519
    machines = models.ManyToManyField(VirtualMachine)
520

    
521
    class Meta:
522
        verbose_name = u'Virtual Machine Group'
523
        verbose_name_plural = 'Virtual Machine Groups'
524
        ordering = ['name']
525
    
526
    def __unicode__(self):
527
        return self.name
528

    
529

    
530
class VirtualMachineMetadata(models.Model):
531
    meta_key = models.CharField(max_length=50)
532
    meta_value = models.CharField(max_length=500)
533
    vm = models.ForeignKey(VirtualMachine)
534
    
535
    class Meta:
536
        verbose_name = u'Key-value pair of metadata for a VM.'
537
    
538
    def __unicode__(self):
539
        return u'%s, %s for %s' % (self.meta_key, self.meta_value, self.vm.name)
540

    
541

    
542
class Debit(models.Model):
543
    when = models.DateTimeField()
544
    user = models.ForeignKey(SynnefoUser)
545
    vm = models.ForeignKey(VirtualMachine)
546
    description = models.TextField()
547
    
548
    class Meta:
549
        verbose_name = u'Accounting log'
550

    
551
    def __unicode__(self):
552
        return u'%s - %s - %s - %s' % ( self.user.id, self.vm.name, str(self.when), self.description)
553

    
554

    
555
class Disk(models.Model):
556
    name = models.CharField(max_length=255)
557
    created = models.DateTimeField('Time of creation', auto_now_add=True)
558
    updated = models.DateTimeField('Time of last update', auto_now=True)
559
    size = models.PositiveIntegerField('Disk size in GBs')
560
    vm = models.ForeignKey(VirtualMachine, blank=True, null=True)
561
    owner = models.ForeignKey(SynnefoUser, blank=True, null=True)  
562

    
563
    class Meta:
564
        verbose_name = u'Disk instance'
565

    
566
    def __unicode__(self):
567
        return self.name