operations.py 21.4 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
# 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/>.

18
from __future__ import absolute_import, unicode_literals
Dudás Ádám committed
19
from logging import getLogger
20
from re import search
Dudás Ádám committed
21

22
from django.core.exceptions import PermissionDenied
Dudás Ádám committed
23 24 25 26
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from celery.exceptions import TimeLimitExceeded
27

28
from common.operations import Operation, register_operation
29 30 31
from .tasks.local_tasks import (
    abortable_async_instance_operation, abortable_async_node_operation,
)
32
from .models import (
33 34
    Instance, InstanceActivity, InstanceTemplate, Interface, Node,
    NodeActivity,
35
)
Dudás Ádám committed
36 37 38


logger = getLogger(__name__)
39 40


41
class InstanceOperation(Operation):
42
    acl_level = 'owner'
43
    async_operation = abortable_async_instance_operation
44
    host_cls = Instance
45
    concurrency_check = True
Dudás Ádám committed
46

47
    def __init__(self, instance):
48
        super(InstanceOperation, self).__init__(subject=instance)
49 50 51
        self.instance = instance

    def check_precond(self):
52 53
        if self.instance.destroyed_at:
            raise self.instance.InstanceDestroyedError(self.instance)
54 55

    def check_auth(self, user):
56 57 58 59
        if not self.instance.has_level(user, self.acl_level):
            raise PermissionDenied("%s doesn't have the required ACL level." %
                                   user)

60
        super(InstanceOperation, self).check_auth(user=user)
61

62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
    def create_activity(self, parent, user):
        if parent:
            if parent.instance != self.instance:
                raise ValueError("The instance associated with the specified "
                                 "parent activity does not match the instance "
                                 "bound to the operation.")
            if parent.user != user:
                raise ValueError("The user associated with the specified "
                                 "parent activity does not match the user "
                                 "provided as parameter.")

            return parent.create_sub(code_suffix=self.activity_code_suffix)
        else:
            return InstanceActivity.create(
                code_suffix=self.activity_code_suffix, instance=self.instance,
77
                user=user, concurrency_check=self.concurrency_check)
78

79

80 81 82 83 84 85
class AddInterfaceOperation(InstanceOperation):
    activity_code_suffix = 'add_interface'
    id = 'add_interface'
    name = _("add interface")
    description = _("Add a new network interface for the specified VLAN to "
                    "the VM.")
86
    required_perms = ()
87 88 89 90 91 92 93 94 95 96 97 98 99 100

    def _operation(self, activity, user, system, vlan, managed=None):
        if managed is None:
            managed = vlan.managed

        net = Interface.create(base_activity=activity, instance=self.instance,
                               managed=managed, owner=user, vlan=vlan)

        if self.instance.is_running:
            net.deploy()

        return net


Bach Dániel committed
101
register_operation(AddInterfaceOperation)
102 103


104 105 106 107 108
class CreateDiskOperation(InstanceOperation):
    activity_code_suffix = 'create_disk'
    id = 'create_disk'
    name = _("create disk")
    description = _("Create empty disk for the VM.")
109
    required_perms = ('storage.create_empty_disk', )
110 111

    def check_precond(self):
112
        super(CreateDiskOperation, self).check_precond()
113
        # TODO remove check when hot-attach is implemented
114
        if self.instance.status not in ['STOPPED', 'PENDING']:
115 116
            raise self.instance.WrongStateError(self.instance)

117
    def _operation(self, user, size, name=None):
118
        # TODO implement with hot-attach when it'll be available
Bach Dániel committed
119 120
        from storage.models import Disk

121 122 123
        if not name:
            name = "new disk"
        disk = Disk.create(size=size, name=name, type="qcow2-norm")
124
        disk.full_clean()
125 126 127 128 129 130 131 132 133 134 135
        self.instance.disks.add(disk)

register_operation(CreateDiskOperation)


class DownloadDiskOperation(InstanceOperation):
    activity_code_suffix = 'download_disk'
    id = 'download_disk'
    name = _("download disk")
    description = _("Download disk for the VM.")
    abortable = True
136
    has_percentage = True
137
    required_perms = ('storage.download_disk', )
138 139

    def check_precond(self):
140
        super(DownloadDiskOperation, self).check_precond()
141
        # TODO remove check when hot-attach is implemented
142
        if self.instance.status not in ['STOPPED', 'PENDING']:
143 144
            raise self.instance.WrongStateError(self.instance)

145 146
    def _operation(self, user, url, task, activity, name=None):
        activity.result = url
147
        # TODO implement with hot-attach when it'll be available
Bach Dániel committed
148 149
        from storage.models import Disk

150
        disk = Disk.download(url=url, name=name, task=task)
151
        disk.full_clean()
152 153 154 155 156
        self.instance.disks.add(disk)

register_operation(DownloadDiskOperation)


157
class DeployOperation(InstanceOperation):
Dudás Ádám committed
158 159 160
    activity_code_suffix = 'deploy'
    id = 'deploy'
    name = _("deploy")
161
    description = _("Deploy new virtual machine with network.")
162
    required_perms = ()
Dudás Ádám committed
163

164 165 166 167 168
    def check_precond(self):
        super(DeployOperation, self).check_precond()
        if self.instance.status in ['RUNNING', 'SUSPENDED']:
            raise self.instance.WrongStateError(self.instance)

Dudás Ádám committed
169 170 171
    def on_commit(self, activity):
        activity.resultant_state = 'RUNNING'

172
    def _operation(self, activity, timeout=15):
Dudás Ádám committed
173 174 175
        # Allocate VNC port and host node
        self.instance.allocate_vnc_port()
        self.instance.allocate_node()
Dudás Ádám committed
176 177 178

        # Deploy virtual images
        with activity.sub_activity('deploying_disks'):
Dudás Ádám committed
179 180 181 182 183 184 185 186 187 188 189 190 191
            self.instance.deploy_disks()

        # Deploy VM on remote machine
        with activity.sub_activity('deploying_vm') as deploy_act:
            deploy_act.result = self.instance.deploy_vm(timeout=timeout)

        # Establish network connection (vmdriver)
        with activity.sub_activity('deploying_net'):
            self.instance.deploy_net()

        # Resume vm
        with activity.sub_activity('booting'):
            self.instance.resume_vm(timeout=timeout)
Dudás Ádám committed
192

Dudás Ádám committed
193
        self.instance.renew(which='both', base_activity=activity)
Dudás Ádám committed
194 195


196
register_operation(DeployOperation)
Dudás Ádám committed
197 198


199
class DestroyOperation(InstanceOperation):
Dudás Ádám committed
200 201 202
    activity_code_suffix = 'destroy'
    id = 'destroy'
    name = _("destroy")
203
    description = _("Destroy virtual machine and its networks.")
204
    required_perms = ()
Dudás Ádám committed
205 206 207 208

    def on_commit(self, activity):
        activity.resultant_state = 'DESTROYED'

209
    def _operation(self, activity):
Dudás Ádám committed
210
        if self.instance.node:
Dudás Ádám committed
211 212
            # Destroy networks
            with activity.sub_activity('destroying_net'):
213
                self.instance.shutdown_net()
Dudás Ádám committed
214 215 216 217 218
                self.instance.destroy_net()

            # Delete virtual machine
            with activity.sub_activity('destroying_vm'):
                self.instance.delete_vm()
Dudás Ádám committed
219 220 221

        # Destroy disks
        with activity.sub_activity('destroying_disks'):
Dudás Ádám committed
222
            self.instance.destroy_disks()
Dudás Ádám committed
223

Dudás Ádám committed
224 225 226 227 228 229 230 231 232
        # Delete mem. dump if exists
        try:
            self.instance.delete_mem_dump()
        except:
            pass

        # Clear node and VNC port association
        self.instance.yield_node()
        self.instance.yield_vnc_port()
Dudás Ádám committed
233 234 235 236 237

        self.instance.destroyed_at = timezone.now()
        self.instance.save()


238
register_operation(DestroyOperation)
Dudás Ádám committed
239 240


241
class MigrateOperation(InstanceOperation):
Dudás Ádám committed
242 243 244
    activity_code_suffix = 'migrate'
    id = 'migrate'
    name = _("migrate")
245
    description = _("Live migrate running VM to another node.")
246
    required_perms = ()
Dudás Ádám committed
247

248 249 250 251
    def rollback(self, activity):
        with activity.sub_activity('rollback_net'):
            self.instance.deploy_net()

252 253 254 255 256
    def check_precond(self):
        super(MigrateOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

257 258 259 260 261 262
    def check_auth(self, user):
        if not user.is_superuser:
            raise PermissionDenied()

        super(MigrateOperation, self).check_auth(user=user)

263
    def _operation(self, activity, to_node=None, timeout=120):
Dudás Ádám committed
264 265 266 267 268
        if not to_node:
            with activity.sub_activity('scheduling') as sa:
                to_node = self.instance.select_node()
                sa.result = to_node

Dudás Ádám committed
269 270 271
        # Shutdown networks
        with activity.sub_activity('shutdown_net'):
            self.instance.shutdown_net()
Dudás Ádám committed
272

273 274 275 276 277 278
        try:
            with activity.sub_activity('migrate_vm'):
                self.instance.migrate_vm(to_node=to_node, timeout=timeout)
        except Exception as e:
            if hasattr(e, 'libvirtError'):
                self.rollback(activity)
Bach Dániel committed
279
            raise
Dudás Ádám committed
280

Dudás Ádám committed
281 282 283 284 285
        # Refresh node information
        self.instance.node = to_node
        self.instance.save()
        # Estabilish network connection (vmdriver)
        with activity.sub_activity('deploying_net'):
Dudás Ádám committed
286
            self.instance.deploy_net()
Dudás Ádám committed
287 288


289
register_operation(MigrateOperation)
Dudás Ádám committed
290 291


292
class RebootOperation(InstanceOperation):
Dudás Ádám committed
293 294 295
    activity_code_suffix = 'reboot'
    id = 'reboot'
    name = _("reboot")
296
    description = _("Reboot virtual machine with Ctrl+Alt+Del signal.")
297
    required_perms = ()
Dudás Ádám committed
298

299 300 301 302 303
    def check_precond(self):
        super(RebootOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

304
    def _operation(self, timeout=5):
Dudás Ádám committed
305
        self.instance.reboot_vm(timeout=timeout)
Dudás Ádám committed
306 307


308
register_operation(RebootOperation)
Dudás Ádám committed
309 310


311 312 313 314 315
class RemoveInterfaceOperation(InstanceOperation):
    activity_code_suffix = 'remove_interface'
    id = 'remove_interface'
    name = _("remove interface")
    description = _("Remove the specified network interface from the VM.")
316
    required_perms = ()
317 318 319 320 321 322 323 324 325

    def _operation(self, activity, user, system, interface):
        if self.instance.is_running:
            interface.shutdown()

        interface.destroy()
        interface.delete()


Bach Dániel committed
326
register_operation(RemoveInterfaceOperation)
327 328


329 330 331 332 333
class RemoveDiskOperation(InstanceOperation):
    activity_code_suffix = 'remove_disk'
    id = 'remove_disk'
    name = _("remove disk")
    description = _("Remove the specified disk from the VM.")
334
    required_perms = ()
335 336 337 338 339 340 341 342 343 344 345 346

    def check_precond(self):
        super(RemoveDiskOperation, self).check_precond()
        # TODO remove check when hot-detach is implemented
        if self.instance.status not in ['STOPPED']:
            raise self.instance.WrongStateError(self.instance)

    def _operation(self, activity, user, system, disk):
        # TODO implement with hot-detach when it'll be available
        return self.instance.disks.remove(disk)


Guba Sándor committed
347
register_operation(RemoveDiskOperation)
348 349


350
class ResetOperation(InstanceOperation):
Dudás Ádám committed
351 352 353
    activity_code_suffix = 'reset'
    id = 'reset'
    name = _("reset")
354
    description = _("Reset virtual machine (reset button).")
355
    required_perms = ()
Dudás Ádám committed
356

357 358 359 360 361
    def check_precond(self):
        super(ResetOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

362
    def _operation(self, timeout=5):
Dudás Ádám committed
363
        self.instance.reset_vm(timeout=timeout)
Dudás Ádám committed
364

365
register_operation(ResetOperation)
Dudás Ádám committed
366 367


368
class SaveAsTemplateOperation(InstanceOperation):
Dudás Ádám committed
369 370 371 372 373 374 375 376
    activity_code_suffix = 'save_as_template'
    id = 'save_as_template'
    name = _("save as template")
    description = _("""Save Virtual Machine as a Template.

        Template can be shared with groups and users.
        Users can instantiate Virtual Machines from Templates.
        """)
377
    abortable = True
378
    required_perms = ('vm.create_template', )
Dudás Ádám committed
379

380 381 382 383 384 385
    @staticmethod
    def _rename(name):
        m = search(r" v(\d+)$", name)
        if m:
            v = int(m.group(1)) + 1
            name = search(r"^(.*) v(\d+)$", name).group(1)
386
        else:
387 388
            v = 1
        return "%s v%d" % (name, v)
389

390 391 392 393 394
    def on_abort(self, activity, error):
        if getattr(self, 'disks'):
            for disk in self.disks:
                disk.destroy()

395 396 397 398 399
    def check_precond(self):
        super(SaveAsTemplateOperation, self).check_precond()
        if self.instance.status not in ['RUNNING', 'PENDING', 'STOPPED']:
            raise self.instance.WrongStateError(self.instance)

400
    def _operation(self, activity, user, system, timeout=300, name=None,
401
                   with_shutdown=True, task=None, **kwargs):
402
        if with_shutdown:
403 404
            try:
                ShutdownOperation(self.instance).call(parent_activity=activity,
405
                                                      user=user, task=task)
406 407 408
            except Instance.WrongStateError:
                pass

Dudás Ádám committed
409 410 411 412 413 414 415 416
        # prepare parameters
        params = {
            'access_method': self.instance.access_method,
            'arch': self.instance.arch,
            'boot_menu': self.instance.boot_menu,
            'description': self.instance.description,
            'lease': self.instance.lease,  # Can be problem in new VM
            'max_ram_size': self.instance.max_ram_size,
417
            'name': name or self._rename(self.instance.name),
Dudás Ádám committed
418 419 420 421 422 423 424 425 426
            'num_cores': self.instance.num_cores,
            'owner': user,
            'parent': self.instance.template,  # Can be problem
            'priority': self.instance.priority,
            'ram_size': self.instance.ram_size,
            'raw_data': self.instance.raw_data,
            'system': self.instance.system,
        }
        params.update(kwargs)
Bach Dániel committed
427
        params.pop("parent_activity", None)
Dudás Ádám committed
428

429 430
        from storage.models import Disk

Dudás Ádám committed
431 432
        def __try_save_disk(disk):
            try:
Dudás Ádám committed
433
                return disk.save_as()
Dudás Ádám committed
434 435 436
            except Disk.WrongDiskTypeError:
                return disk

437
        self.disks = []
438
        with activity.sub_activity('saving_disks'):
439 440 441 442 443
            for disk in self.instance.disks.all():
                self.disks.append(__try_save_disk(disk))

        for disk in self.disks:
            disk.set_level(user, 'owner')
444

Dudás Ádám committed
445 446 447 448 449
        # create template and do additional setup
        tmpl = InstanceTemplate(**params)
        tmpl.full_clean()  # Avoiding database errors.
        tmpl.save()
        try:
450
            tmpl.disks.add(*self.disks)
Dudás Ádám committed
451 452 453 454 455 456 457 458 459 460
            # create interface templates
            for i in self.instance.interface_set.all():
                i.save_as_template(tmpl)
        except:
            tmpl.delete()
            raise
        else:
            return tmpl


461
register_operation(SaveAsTemplateOperation)
Dudás Ádám committed
462 463


464
class ShutdownOperation(InstanceOperation):
Dudás Ádám committed
465 466 467
    activity_code_suffix = 'shutdown'
    id = 'shutdown'
    name = _("shutdown")
468
    description = _("Shutdown virtual machine with ACPI signal.")
Kálmán Viktor committed
469
    abortable = True
470
    required_perms = ()
Dudás Ádám committed
471

472 473 474 475 476
    def check_precond(self):
        super(ShutdownOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

Dudás Ádám committed
477 478 479
    def on_commit(self, activity):
        activity.resultant_state = 'STOPPED'

480 481
    def _operation(self, task=None):
        self.instance.shutdown_vm(task=task)
Dudás Ádám committed
482 483
        self.instance.yield_node()
        self.instance.yield_vnc_port()
Dudás Ádám committed
484 485


486
register_operation(ShutdownOperation)
Dudás Ádám committed
487 488


489
class ShutOffOperation(InstanceOperation):
Dudás Ádám committed
490 491 492
    activity_code_suffix = 'shut_off'
    id = 'shut_off'
    name = _("shut off")
493
    description = _("Shut off VM (plug-out).")
494
    required_perms = ()
Dudás Ádám committed
495

496 497 498 499 500
    def check_precond(self):
        super(ShutOffOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

501
    def on_commit(self, activity):
Dudás Ádám committed
502 503
        activity.resultant_state = 'STOPPED'

504
    def _operation(self, activity):
Dudás Ádám committed
505 506 507
        # Shutdown networks
        with activity.sub_activity('shutdown_net'):
            self.instance.shutdown_net()
Dudás Ádám committed
508

Dudás Ádám committed
509 510 511 512 513 514 515
        # Delete virtual machine
        with activity.sub_activity('delete_vm'):
            self.instance.delete_vm()

        # Clear node and VNC port association
        self.instance.yield_node()
        self.instance.yield_vnc_port()
Dudás Ádám committed
516 517


518
register_operation(ShutOffOperation)
Dudás Ádám committed
519 520


521
class SleepOperation(InstanceOperation):
Dudás Ádám committed
522 523 524
    activity_code_suffix = 'sleep'
    id = 'sleep'
    name = _("sleep")
525
    description = _("Suspend virtual machine with memory dump.")
526
    required_perms = ()
Dudás Ádám committed
527 528

    def check_precond(self):
529
        super(SleepOperation, self).check_precond()
Dudás Ádám committed
530 531 532 533 534 535 536 537 538 539 540 541
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

    def on_abort(self, activity, error):
        if isinstance(error, TimeLimitExceeded):
            activity.resultant_state = None
        else:
            activity.resultant_state = 'ERROR'

    def on_commit(self, activity):
        activity.resultant_state = 'SUSPENDED'

542
    def _operation(self, activity, timeout=60):
Dudás Ádám committed
543
        # Destroy networks
Dudás Ádám committed
544 545
        with activity.sub_activity('shutdown_net'):
            self.instance.shutdown_net()
Dudás Ádám committed
546 547 548

        # Suspend vm
        with activity.sub_activity('suspending'):
Dudás Ádám committed
549 550 551 552
            self.instance.suspend_vm(timeout=timeout)

        self.instance.yield_node()
        # VNC port needs to be kept
Dudás Ádám committed
553 554


555
register_operation(SleepOperation)
Dudás Ádám committed
556 557


558
class WakeUpOperation(InstanceOperation):
Dudás Ádám committed
559 560 561 562 563 564 565
    activity_code_suffix = 'wake_up'
    id = 'wake_up'
    name = _("wake up")
    description = _("""Wake up Virtual Machine from SUSPENDED state.

        Power on Virtual Machine and load its memory from dump.
        """)
566
    required_perms = ()
Dudás Ádám committed
567 568

    def check_precond(self):
569
        super(WakeUpOperation, self).check_precond()
Dudás Ádám committed
570 571 572 573 574 575 576 577 578
        if self.instance.status not in ['SUSPENDED']:
            raise self.instance.WrongStateError(self.instance)

    def on_abort(self, activity, error):
        activity.resultant_state = 'ERROR'

    def on_commit(self, activity):
        activity.resultant_state = 'RUNNING'

579
    def _operation(self, activity, timeout=60):
Dudás Ádám committed
580
        # Schedule vm
Dudás Ádám committed
581 582
        self.instance.allocate_vnc_port()
        self.instance.allocate_node()
Dudás Ádám committed
583 584 585

        # Resume vm
        with activity.sub_activity('resuming'):
Dudás Ádám committed
586
            self.instance.wake_up_vm(timeout=timeout)
Dudás Ádám committed
587 588 589

        # Estabilish network connection (vmdriver)
        with activity.sub_activity('deploying_net'):
Dudás Ádám committed
590
            self.instance.deploy_net()
Dudás Ádám committed
591 592 593 594 595

        # Renew vm
        self.instance.renew(which='both', base_activity=activity)


596
register_operation(WakeUpOperation)
597 598 599


class NodeOperation(Operation):
600
    async_operation = abortable_async_node_operation
601
    host_cls = Node
602 603 604 605 606

    def __init__(self, node):
        super(NodeOperation, self).__init__(subject=node)
        self.node = node

607 608 609 610 611 612 613 614 615 616 617 618 619 620 621
    def create_activity(self, parent, user):
        if parent:
            if parent.node != self.node:
                raise ValueError("The node associated with the specified "
                                 "parent activity does not match the node "
                                 "bound to the operation.")
            if parent.user != user:
                raise ValueError("The user associated with the specified "
                                 "parent activity does not match the user "
                                 "provided as parameter.")

            return parent.create_sub(code_suffix=self.activity_code_suffix)
        else:
            return NodeActivity.create(code_suffix=self.activity_code_suffix,
                                       node=self.node, user=user)
622 623 624 625 626 627


class FlushOperation(NodeOperation):
    activity_code_suffix = 'flush'
    id = 'flush'
    name = _("flush")
628
    description = _("Disable node and move all instances to other ones.")
629
    required_perms = ()
630

631
    def _operation(self, activity, user):
632 633 634
        self.node.disable(user, activity)
        for i in self.node.instance_set.all():
            with activity.sub_activity('migrate_instance_%d' % i.pk):
Bach Dániel committed
635
                i.migrate(user=user)
636 637


638
register_operation(FlushOperation)
639 640 641 642 643 644 645 646


class ScreenshotOperation(InstanceOperation):
    activity_code_suffix = 'screenshot'
    id = 'screenshot'
    name = _("screenshot")
    description = _("Get screenshot")
    acl_level = "owner"
647
    required_perms = ()
648 649 650 651 652 653

    def check_precond(self):
        super(ScreenshotOperation, self).check_precond()
        if self.instance.status not in ['RUNNING']:
            raise self.instance.WrongStateError(self.instance)

Kálmán Viktor committed
654
    def _operation(self):
655 656 657 658
        return self.instance.get_screenshot(timeout=20)


register_operation(ScreenshotOperation)
659 660 661 662 663 664 665 666 667


class ResourcesOperation(InstanceOperation):
    activity_code_suffix = 'Resources change'
    id = 'resources_change'
    name = _("resources change")
    description = _("Change resources")
    acl_level = "owner"
    concurrency_check = False
668
    required_perms = ('vm.change_resources', )
669 670 671

    def check_precond(self):
        super(ResourcesOperation, self).check_precond()
672
        if self.instance.status not in ["STOPPED", "PENDING"]:
673 674
            raise self.instance.WrongStateError(self.instance)

675 676
    def _operation(self, user, num_cores, ram_size, max_ram_size, priority):

677 678 679 680 681
        self.instance.num_cores = num_cores
        self.instance.ram_size = ram_size
        self.instance.max_ram_size = max_ram_size
        self.instance.priority = priority

682
        self.instance.full_clean()
683 684 685 686
        self.instance.save()


register_operation(ResourcesOperation)