models.py 15.4 KB
Newer Older
1 2
# -*- coding: utf-8 -*-

3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE.  If not, see <http://www.gnu.org/licenses/>.

20
from __future__ import unicode_literals
21 22

import logging
23
from os.path import join
24 25
import uuid

Guba Sándor committed
26 27
from celery.contrib.abortable import AbortableAsyncResult
from django.db.models import (Model, BooleanField, CharField, DateTimeField,
28
                              ForeignKey)
29
from django.utils import timezone
30
from django.utils.translation import ugettext_lazy as _
31
from model_utils.models import TimeStampedModel
32
from sizefield.models import FileSizeField
33

Guba Sándor committed
34
from .tasks import local_tasks, storage_tasks
35
from celery.exceptions import TimeoutError
Guba Sándor committed
36
from common.models import WorkerNotFound
37 38 39 40

logger = logging.getLogger(__name__)


41
class DataStore(Model):
Guba Sándor committed
42

43 44
    """Collection of virtual disks.
    """
45 46 47 48
    name = CharField(max_length=100, unique=True, verbose_name=_('name'))
    path = CharField(max_length=200, unique=True, verbose_name=_('path'))
    hostname = CharField(max_length=40, unique=True,
                         verbose_name=_('hostname'))
Guba Sándor committed
49

50 51 52 53 54 55 56 57
    class Meta:
        ordering = ['name']
        verbose_name = _('datastore')
        verbose_name_plural = _('datastores')

    def __unicode__(self):
        return u'%s (%s)' % (self.name, self.path)

58 59
    def get_remote_queue_name(self, queue_id, priority=None,
                              check_worker=True):
60 61
        logger.debug("Checking for storage queue %s.%s",
                     self.hostname, queue_id)
62
        if not check_worker or local_tasks.check_queue(self.hostname,
Guba Sándor committed
63 64 65 66 67 68
                                                       queue_id,
                                                       priority):
            queue_name = self.hostname + '.' + queue_id
            if priority is not None:
                queue_name = queue_name + '.' + priority
            return queue_name
69 70
        else:
            raise WorkerNotFound()
71

72 73 74
    def get_deletable_disks(self):
        return [disk.filename for disk in
                self.disk_set.filter(
75
                    destroyed__isnull=False) if disk.is_deletable]
76

77

Bach Dániel committed
78
class Disk(TimeStampedModel):
Guba Sándor committed
79

80 81 82 83
    """A virtual disk.
    """
    TYPES = [('qcow2-norm', 'qcow2 normal'), ('qcow2-snap', 'qcow2 snapshot'),
             ('iso', 'iso'), ('raw-ro', 'raw read-only'), ('raw-rw', 'raw')]
84
    name = CharField(blank=True, max_length=100, verbose_name=_("name"))
85 86
    filename = CharField(max_length=256, unique=True,
                         verbose_name=_("filename"))
87 88 89
    datastore = ForeignKey(DataStore, verbose_name=_("datastore"),
                           help_text=_("The datastore that holds the disk."))
    type = CharField(max_length=10, choices=TYPES)
90
    size = FileSizeField(null=True, default=None)
91 92 93
    base = ForeignKey('self', blank=True, null=True,
                      related_name='derivatives')
    dev_num = CharField(default='a', max_length=1,
94
                        verbose_name=_("device number"))
95
    destroyed = DateTimeField(blank=True, default=None, null=True)
96

Guba Sándor committed
97 98
    is_ready = BooleanField(default=False)

99 100 101 102
    class Meta:
        ordering = ['name']
        verbose_name = _('disk')
        verbose_name_plural = _('disks')
103 104 105
        permissions = (
            ('create_empty_disk', _('Can create an empty disk.')),
            ('download_disk', _('Can download a disk.')))
106

107 108
    class WrongDiskTypeError(Exception):

109 110 111 112 113 114 115 116
        def __init__(self, type, message=None):
            if message is None:
                message = ("Operation can't be invoked on a disk of type '%s'."
                           % type)

            Exception.__init__(self, message)

            self.type = type
117

118 119
    class DiskInUseError(Exception):

120 121 122 123
        def __init__(self, disk, message=None):
            if message is None:
                message = ("The requested operation can't be performed on "
                           "disk '%s (%s)' because it is in use." %
Dudás Ádám committed
124
                           (disk.name, disk.filename))
125 126 127 128

            Exception.__init__(self, message)

            self.disk = disk
129

Guba Sándor committed
130
    class DiskIsNotReady(Exception):
131

Guba Sándor committed
132 133
        """ Exception for operations that need a deployed disk.
        """
Guba Sándor committed
134 135 136

        def __init__(self, disk, message=None):
            if message is None:
Guba Sándor committed
137
                message = ("The requested operation can't be performed on "
Guba Sándor committed
138 139 140 141 142 143 144
                           "disk '%s (%s)' because it has never been"
                           "deployed." % (disk.name, disk.filename))

            Exception.__init__(self, message)

            self.disk = disk

145 146
    @property
    def path(self):
147 148
        """The path where the files are stored.
        """
149
        return join(self.datastore.path, self.filename)
150 151

    @property
152
    def vm_format(self):
153 154
        """Returns the proper file format for different type of images.
        """
155 156 157
        return {
            'qcow2-norm': 'qcow2',
            'qcow2-snap': 'qcow2',
158
            'iso': 'raw',
159 160 161 162
            'raw-ro': 'raw',
            'raw-rw': 'raw',
        }[self.type]

163
    @property
164
    def format(self):
165 166
        """Returns the proper file format for different types of images.
        """
167 168 169 170 171 172 173 174 175
        return {
            'qcow2-norm': 'qcow2',
            'qcow2-snap': 'qcow2',
            'iso': 'iso',
            'raw-ro': 'raw',
            'raw-rw': 'raw',
        }[self.type]

    @property
176
    def device_type(self):
177 178
        """Returns the proper device prefix for different types of images.
        """
179
        return {
180 181
            'qcow2-norm': 'vd',
            'qcow2-snap': 'vd',
182
            'iso': 'sd',
183 184 185
            'raw-ro': 'vd',
            'raw-rw': 'vd',
        }[self.type]
186

187
    @property
188 189 190 191 192 193 194 195 196 197 198 199
    def device_bus(self):
        """Returns the proper device prefix for different types of images.
        """
        return {
            'qcow2-norm': 'virtio',
            'qcow2-snap': 'virtio',
            'iso': 'scsi',
            'raw-ro': 'virtio',
            'raw-rw': 'virtio',
        }[self.type]

    @property
200
    def is_deletable(self):
201
        """True if the associated file can be deleted.
202
        """
203
        # Check if all children and the disk itself is destroyed.
204
        return (self.destroyed is not None) and self.children_deletable
205

206 207 208
    @property
    def children_deletable(self):
        """True if all children of the disk are deletable.
209
        """
210
        return all(i.is_deletable for i in self.derivatives.all())
211

212
    @property
213
    def is_in_use(self):
214
        """True if disk is attached to an active VM.
215 216 217 218

        'In use' means the disk is attached to a VM which is not STOPPED, as
        any other VMs leave the disk in an inconsistent state.
        """
219
        return any(i.state != 'STOPPED' for i in self.instance_set.all())
220

221
    def get_appliance(self):
Bach Dániel committed
222 223
        """Return the Instance or InstanceTemplate object where the disk
        is used
224
        """
Bach Dániel committed
225 226 227 228 229
        from vm.models import Instance
        try:
            return self.instance_set.get()
        except Instance.DoesNotExist:
            return self.template_set.get()
230

231 232
    def get_exclusive(self):
        """Get an instance of the disk for exclusive usage.
233

234 235 236
        This method manipulates the database only.
        """
        type_mapping = {
237 238 239
            'qcow2-norm': 'qcow2-snap',
            'iso': 'iso',
            'raw-ro': 'raw-rw',
240 241 242 243 244 245
        }

        if self.type not in type_mapping.keys():
            raise self.WrongDiskTypeError(self.type)

        new_type = type_mapping[self.type]
246

247 248 249
        return Disk.create(base=self, datastore=self.datastore,
                           name=self.name, size=self.size,
                           type=new_type)
250 251

    def get_vmdisk_desc(self):
252 253
        """Serialize disk object to the vmdriver.
        """
254
        return {
255
            'source': self.path,
256
            'driver_type': self.vm_format,
257
            'driver_cache': 'none',
258
            'target_device': self.device_type + self.dev_num,
259
            'target_bus': self.device_bus,
260
            'disk_device': 'cdrom' if self.type == 'iso' else 'disk'
261 262
        }

263
    def get_disk_desc(self):
264 265
        """Serialize disk object to the storage driver.
        """
266 267 268 269 270 271
        return {
            'name': self.filename,
            'dir': self.datastore.path,
            'format': self.format,
            'size': self.size,
            'base_name': self.base.filename if self.base else None,
272
            'type': 'snapshot' if self.base else 'normal'
273 274
        }

275 276
    def get_remote_queue_name(self, queue_id='storage', priority=None,
                              check_worker=True):
277 278
        """Returns the proper queue name based on the datastore.
        """
279
        if self.datastore:
280 281
            return self.datastore.get_remote_queue_name(queue_id, priority,
                                                        check_worker)
282 283 284
        else:
            return None

285
    def __unicode__(self):
286
        return u"%s (#%d)" % (self.name, self.id or 0)
287

288
    def clean(self, *args, **kwargs):
Guba Sándor committed
289
        if (self.size is None or "") and self.base:
290 291 292
            self.size = self.base.size
        super(Disk, self).clean(*args, **kwargs)

293
    def deploy(self, user=None, task_uuid=None, timeout=15):
294 295 296 297 298
        """Reify the disk model on the associated data store.

        :param self: the disk model to reify
        :type self: storage.models.Disk

299 300 301 302 303 304 305
        :param user: The user who's issuing the command.
        :type user: django.contrib.auth.models.User

        :param task_uuid: The task's UUID, if the command is being executed
                          asynchronously.
        :type task_uuid: str

306 307 308 309
        :return: True if a new reification of the disk has been created;
                 otherwise, False.
        :rtype: bool
        """
310 311 312 313
        if self.destroyed:
            self.destroyed = None
            self.save()

314
        if self.is_ready:
315
            return True
316 317
        if self.base and not self.base.is_ready:
            raise Exception("Base image is not ready.")
Guba Sándor committed
318 319 320 321 322 323 324 325 326 327
        queue_name = self.get_remote_queue_name('storage', priority="fast")
        disk_desc = self.get_disk_desc()
        if self.base is not None:
            storage_tasks.snapshot.apply_async(args=[disk_desc],
                                               queue=queue_name
                                               ).get(timeout=timeout)
        else:
            storage_tasks.create.apply_async(args=[disk_desc],
                                             queue=queue_name
                                             ).get(timeout=timeout)
328

329 330
        self.is_ready = True
        self.save()
Guba Sándor committed
331
        return True
332

333
    @classmethod
Guba Sándor committed
334 335
    def create(cls, user=None, **params):
        disk = cls.__create(user, params)
Guba Sándor committed
336
        disk.clean()
337
        disk.save()
Guba Sándor committed
338
        logger.debug("Disk created: %s", params)
339
        return disk
340

341
    @classmethod
Guba Sándor committed
342 343 344 345
    def __create(cls, user, params):
        datastore = params.pop('datastore', DataStore.objects.get())
        filename = params.pop('filename', str(uuid.uuid4()))
        disk = cls(filename=filename, datastore=datastore, **params)
346
        return disk
347 348

    @classmethod
Guba Sándor committed
349
    def download(cls, url, task, user=None, **params):
350 351 352 353
        """Create disk object and download data from url synchronusly.

        :param url: image url to download.
        :type url: url
354 355
        :param instance: Instance or template attach the Disk to.
        :type instance: vm.models.Instance or InstanceTemplate or NoneType
356 357
        :param user: owner of the disk
        :type user: django.contrib.auth.User
358 359
        :param task_uuid: UUID of the local task
        :param abortable_task: UUID of the remote running abortable task.
360

361 362
        :return: The created Disk object
        :rtype: Disk
363
        """
Guba Sándor committed
364
        params.setdefault('name', url.split('/')[-1])
365 366 367
        params.setdefault('type', 'iso')
        params.setdefault('size', None)
        disk = cls.__create(params=params, user=user)
Guba Sándor committed
368 369
        queue_name = disk.get_remote_queue_name('storage', priority='slow')
        remote = storage_tasks.download.apply_async(
370
            kwargs={'url': url, 'parent_id': task.request.id,
Guba Sándor committed
371 372 373 374
                    'disk': disk.get_disk_desc()},
            queue=queue_name)
        while True:
            try:
375
                result = remote.get(timeout=5)
Guba Sándor committed
376 377 378 379 380
                break
            except TimeoutError:
                if task is not None and task.is_aborted():
                    AbortableAsyncResult(remote.id).abort()
                    raise Exception("Download aborted by user.")
381 382
        disk.size = result['size']
        disk.type = result['type']
383
        disk.is_ready = True
Guba Sándor committed
384
        disk.save()
385
        return disk
386

387
    def destroy(self, user=None, task_uuid=None):
388 389 390
        if self.destroyed:
            return False

Guba Sándor committed
391 392 393
        self.destroyed = timezone.now()
        self.save()
        return True
394

395
    def restore(self, user=None, task_uuid=None, timeout=15):
396
        """Recover destroyed disk from trash if possible.
397
        """
398 399 400 401 402 403 404
        queue_name = self.datastore.get_remote_queue_name(
            'storage', priority='slow')
        logger.info("Image: %s at Datastore: %s recovered from trash." %
                    (self.filename, self.datastore.path))
        storage_tasks.recover_from_trash.apply_async(
            args=[self.datastore.path, self.filename],
            queue=queue_name).get(timeout=timeout)
405

406
    def save_as(self, task=None, user=None, task_uuid=None, timeout=300):
407 408
        """Save VM as template.

409 410 411 412
        Based on disk type:
        qcow2-norm, qcow2-snap --> qcow2-norm
        iso                    --> iso (with base)

413 414 415
        VM must be in STOPPED state to perform this action.
        The timeout parameter is not used now.
        """
416
        mapping = {
417 418 419
            'qcow2-snap': ('qcow2-norm', None),
            'qcow2-norm': ('qcow2-norm', None),
            'iso': ("iso", self),
420 421 422 423
        }
        if self.type not in mapping.keys():
            raise self.WrongDiskTypeError(self.type)

424
        if self.is_in_use:
425 426
            raise self.DiskInUseError(self)

427
        if not self.is_ready:
Guba Sándor committed
428 429
            raise self.DiskIsNotReady(self)

430 431 432
        # from this point on, the caller has to guarantee that the disk is not
        # going to be used until the operation is complete

433
        new_type, new_base = mapping[self.type]
434

435 436
        disk = Disk.create(datastore=self.datastore,
                           base=new_base,
437
                           name=self.name, size=self.size,
438
                           type=new_type, dev_num=self.dev_num)
439

Guba Sándor committed
440
        queue_name = self.get_remote_queue_name("storage", priority="slow")
441 442 443 444 445 446 447 448 449 450 451 452 453 454
        remote = storage_tasks.merge.apply_async(kwargs={
            "old_json": self.get_disk_desc(),
            "new_json": disk.get_disk_desc()},
            queue=queue_name
        )  # Timeout
        while True:
            try:
                remote.get(timeout=5)
                break
            except TimeoutError:
                if task is not None and task.is_aborted():
                    AbortableAsyncResult(remote.id).abort()
                    disk.destroy()
                    raise Exception("Save as aborted by use.")
455 456
        disk.is_ready = True
        disk.save()
Guba Sándor committed
457
        return disk