models.py 15.3 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
Guba Sándor committed
316 317 318 319 320 321 322 323 324 325
        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)
326

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

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

339
    @classmethod
Guba Sándor committed
340 341 342 343
    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)
344
        return disk
345 346

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

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

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

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

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

393
    def restore(self, user=None, task_uuid=None, timeout=15):
394
        """Recover destroyed disk from trash if possible.
395
        """
396 397 398 399 400 401 402
        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)
403

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

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

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

422
        if self.is_in_use:
423 424
            raise self.DiskInUseError(self)

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

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

431
        new_type, new_base = mapping[self.type]
432

433 434
        disk = Disk.create(datastore=self.datastore,
                           base=new_base,
435 436
                           name=self.name, size=self.size,
                           type=new_type)
437

Guba Sándor committed
438
        queue_name = self.get_remote_queue_name("storage", priority="slow")
439 440 441 442 443 444 445 446 447 448 449 450 451 452
        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.")
Guba Sándor committed
453
        return disk