diff --git a/instances/jobs.py b/instances/jobs.py
index 7d97038c..ea5f2184 100644
--- a/instances/jobs.py
+++ b/instances/jobs.py
@@ -434,6 +434,28 @@ def _snapshot_definitions(proxy, params):
return any(definition.xpath(HOST_DEVICES) for definition in definitions), definitions
+def _revert_quota(user, instance, proxy, params):
+ """A revert gives the VM the vCPUs and memory it had then: what that adds
+ to what it has now (running or at its next start) must fit the user's
+ quota, as a resize must."""
+ if user.is_superuser or instance.is_template:
+ return
+ sizes = [(d.findtext("vcpu"), d.findtext("memory")) for d in _snapshot_definitions(proxy, params)[1]]
+ if not sizes or any(None in size for size in sizes): # an old snapshot may record only the VM's UUID
+ raise util.OperationError(_("Snapshot %(name)s does not record the VM's vCPUs and memory: "
+ "the quota cannot be checked."), name=params["name"])
+ with proxy.cached_reads():
+ cpus = max(int(vcpu) for vcpu, _memory in sizes) - max(proxy.get_vcpu(), proxy.get_vcpu(config=True))
+ memory = (max(int(memory) // 1024 for _vcpu, memory in sizes)
+ - max(proxy.get_memory(), proxy.get_memory(config=True)))
+ quota = utils.check_user_quota(user, 0, cpus, memory, 0)
+ if quota == utils.QUOTA_UNVERIFIED:
+ raise util.OperationError(utils.QUOTA_UNVERIFIED_TEXT)
+ if quota:
+ raise util.OperationError(_("%(quota)s: cannot revert to snapshot %(name)s."),
+ quota=utils.quota_message(quota), name=params["name"])
+
+
@register
class RevertSnapshot(Kind):
name = "snapshot.revert"
@@ -445,13 +467,21 @@ class RevertSnapshot(Kind):
"external": bool(raw.get("external"))}
def validate(self, user, params):
- _proxy(_authorized(user, params["instance"]))._snapshot(params["name"], params["external"])
+ instance = _authorized(user, params["instance"])
+ proxy = _proxy(instance)
+ proxy._snapshot(params["name"], params["external"])
+ _revert_quota(user, instance, proxy, params)
def plan(self, user, params):
plan = _vm_plan(params)
+ instance = _instance(params["instance"])
# a revert that gives the VM host devices holds the whole compute: no other VM takes them meanwhile
- if _snapshot_definitions(_proxy(_instance(params["instance"])), params)[0]:
+ if _snapshot_definitions(_proxy(instance), params)[0]:
plan.claims[claims.compute_key(plan.intent["compute"])] = Claim.EXCLUSIVE
+ # the user's quota until it ends: the worker's check and the revert are one step to other
+ # increases (a resize, another revert), as a resize's check and change are
+ if not user.is_superuser and not instance.is_template:
+ plan.claims[claims.quota_key(user.pk)] = Claim.EXCLUSIVE
return plan
def precheck(self, op, ctx):
@@ -460,6 +490,8 @@ class RevertSnapshot(Kind):
proxy.check_external_revert(op.params["name"])
else:
proxy.check_internal_revert()
+ # again: a resize while it was queued may have used the quota
+ _revert_quota(op.user, _instance(op.params["instance"]), proxy, op.params)
# a revert gives the VM the host devices it had then: not one given since to another VM
has_hostdevs, definitions = _snapshot_definitions(proxy, op.params)
if has_hostdevs:
diff --git a/instances/tests/test_host_devices.py b/instances/tests/test_host_devices.py
index 7f21f9ae..78de37f5 100644
--- a/instances/tests/test_host_devices.py
+++ b/instances/tests/test_host_devices.py
@@ -167,8 +167,8 @@ class RevertTests(TestCase):
def test_the_compute_is_held_only_for_a_revert_with_host_devices(self):
compute = f"compute:{self.instance.compute_id}"
self.snapshot(by_model())
- self.assertEqual(self.kind.plan(None, self.params).claims[compute], "exclusive")
+ self.assertEqual(self.kind.plan(MagicMock(is_superuser=True), self.params).claims[compute], "exclusive")
self.snapshot("")
- self.assertEqual(self.kind.plan(None, self.params).claims[compute], "shared")
+ self.assertEqual(self.kind.plan(MagicMock(is_superuser=True), self.params).claims[compute], "shared")
self.precheck() # no host device: the host's devices are not read
self.proxy.wvm.listAllDevices.assert_not_called()
diff --git a/instances/tests/test_snapshot_jobs.py b/instances/tests/test_snapshot_jobs.py
index bd5299e4..89091a51 100644
--- a/instances/tests/test_snapshot_jobs.py
+++ b/instances/tests/test_snapshot_jobs.py
@@ -1,14 +1,17 @@
"""Snapshot create and revert run as jobs: the page queues them, the worker calls libvirt."""
from types import SimpleNamespace
-from unittest.mock import patch
+from unittest.mock import PropertyMock, patch
from vrtManager.util import OperationError
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from django.urls import reverse
+from accounts.models import UserAttributes, UserInstance
from appsettings.settings import get_settings
+from django.contrib.auth.models import Permission
+from django.contrib.messages import get_messages
from computes.models import Compute
from instances.jobs import CreateSnapshot, RevertSnapshot
from instances.models import Instance
@@ -30,6 +33,10 @@ class SnapshotJobsTestCase(TransactionTestCase):
self.proxy._snapshot.return_value.getXMLDesc.return_value = ""
self.proxy.instance.isActive.return_value = False
self.proxy.instance.hasCurrentSnapshot.return_value = 0
+ # the quota's reads of the user's VMs
+ reads = patch("instances.utils.wvmInstance")
+ self.vm_reads = reads.start().return_value
+ self.addCleanup(reads.stop)
def queue_and_run(self, view, data):
"""Post the form; nothing runs in the request; then run the job as the worker does."""
@@ -95,6 +102,49 @@ class SnapshotJobsTestCase(TransactionTestCase):
self.assertEqual((op.kind, result), ("snapshot.revert", {"left_behind": ["/pool/a.one"]}))
self.proxy.revert_external_snapshot.assert_called_once_with("a")
+ def test_a_revert_that_would_exceed_the_owner_s_quota_is_refused(self):
+ # snapshot at 4 vCPUs, shrunk to 2, the 2 given to another VM: reverting gives 6 of 4
+ owner = get_user_model().objects.create_user("snap_owner", password="pw")
+ owner.user_permissions.add(Permission.objects.get(codename="snapshot_instances"))
+ UserAttributes.objects.update_or_create(user=owner, defaults={"max_cpus": 4, "max_memory": 2048,
+ "max_instances": -1, "max_disk_size": -1})
+ UserInstance.objects.create(user=owner, instance=self.instance, is_change=True)
+ for proxy in (self.proxy, self.vm_reads): # the VM now: 2 vCPUs, 1 GiB, running and at its next start
+ proxy.get_vcpu.return_value, proxy.get_memory.return_value = 2, 1024
+ proxy.get_disk_devices.return_value = []
+ other = Instance.objects.create(compute=self.instance.compute, name="snap-other", uuid="21212121-3434-5656-7878-909090909091")
+ UserInstance.objects.create(user=owner, instance=other, is_change=True)
+ patch.object(Compute, "status", new_callable=PropertyMock, return_value=True).start()
+ self.addCleanup(patch.stopall)
+ for vcpu, memory, admitted in ((4, 1024, False), (2, 2048, False), (2, 1024, True)):
+ with self.subTest(vcpu=vcpu, memory=memory):
+ self.client.force_login(owner) # a new session: no messages of the last one
+ self.proxy._snapshot.return_value.getXMLDesc.return_value = (
+ f"{vcpu}{memory * 1024}")
+ response = self.client.post(reverse("instances:revert_snapshot", args=[self.instance.pk]),
+ {"name": "a", "submission_key": f"key-snap{vcpu}{memory}"})
+ self.assertEqual(Operation.objects.exists(), admitted)
+ shown = " ".join(str(m) for m in get_messages(response.wsgi_request))
+ self.assertEqual("cannot revert to snapshot a" in shown, not admitted, shown)
+ self.client.logout()
+ # the admitted revert holds the owner's quota until it ends: no resize or other revert
+ # passes the check meanwhile (its own check in the worker and its revert are one step)
+ op = Operation.objects.get()
+ self.assertIn((f"quota:user:{owner.pk}", "exclusive"), set(Claim.objects.filter(operation=op).values_list("resource", "mode")))
+ # the worker checks again: the VM may have been shrunk while it was queued (2 -> 1 vCPU, used elsewhere)
+ self.proxy.get_vcpu.return_value = 1
+ with self.assertRaisesMessage(OperationError, "cannot revert to snapshot a"):
+ RevertSnapshot().precheck(op, None)
+ # an old snapshot may record only the VM's UUID: refused, not a server error
+ Operation.objects.all().delete()
+ Claim.objects.all().delete()
+ self.proxy._snapshot.return_value.getXMLDesc.return_value = "x"
+ self.client.force_login(owner)
+ response = self.client.post(reverse("instances:revert_snapshot", args=[self.instance.pk]),
+ {"name": "a", "submission_key": "key-snapold1"})
+ self.assertFalse(Operation.objects.exists())
+ self.assertIn("does not record the VM's vCPUs and memory", " ".join(str(m) for m in get_messages(response.wsgi_request)))
+
def test_a_revert_libvirt_cannot_do_is_refused_before_effects(self):
self.proxy.check_external_revert.side_effect = OperationError("taken by an older version")
with self.assertRaises(OperationError):
diff --git a/locale/tr/LC_MESSAGES/django.mo b/locale/tr/LC_MESSAGES/django.mo
index 1c863a3f..2984eb01 100644
Binary files a/locale/tr/LC_MESSAGES/django.mo and b/locale/tr/LC_MESSAGES/django.mo differ
diff --git a/locale/tr/LC_MESSAGES/django.po b/locale/tr/LC_MESSAGES/django.po
index aee9fcd0..7f7ade86 100644
--- a/locale/tr/LC_MESSAGES/django.po
+++ b/locale/tr/LC_MESSAGES/django.po
@@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-11-07 12:30+0000\n"
+"POT-Creation-Date: 2026-11-07 13:19+0000\n"
"PO-Revision-Date: 2020-11-04 12:00+0000\n"
"Last-Translator: catborise , 2020\n"
"Language-Team: Turkish (https://www.transifex.com/catborise/teams/110663/tr/)\n"
@@ -2750,7 +2750,7 @@ msgstr "Klonun adını boş adlar listesinden seçin"
msgid "%(quota)s: cannot create '%(clone_name)s'."
msgstr "%(quota)s: '%(clone_name)s' oluşturulamıyor."
-#: instances/jobs.py:200 instances/jobs.py:554
+#: instances/jobs.py:200 instances/jobs.py:586
#, python-format
msgid "Instance '%(clone_name)s' already exists!"
msgstr "'%(clone_name)s' sanal makinesi zaten mevcut!"
@@ -2844,19 +2844,33 @@ msgstr "libvirt anlık görüntüyü hâlâ alıyor"
msgid "The snapshot was not taken"
msgstr "Anlık görüntü alınmadı"
-#: instances/jobs.py:440
+#: instances/jobs.py:445
+#, python-format
+msgid ""
+"Snapshot %(name)s does not record the VM's vCPUs and memory: the quota cannot"
+" be checked."
+msgstr ""
+"%(name)s anlık görüntüsü sanal makinenin vCPU ve bellek değerlerini "
+"içermiyor: kota denetlenemiyor."
+
+#: instances/jobs.py:455
+#, python-format
+msgid "%(quota)s: cannot revert to snapshot %(name)s."
+msgstr "%(quota)s: %(name)s anlık görüntüsüne dönülemiyor."
+
+#: instances/jobs.py:462
msgid "Revert snapshot"
msgstr "Snapshota dön"
-#: instances/jobs.py:471
+#: instances/jobs.py:503
msgid "Reverting the VM"
msgstr "Sanal makine geri döndürülüyor"
-#: instances/jobs.py:482
+#: instances/jobs.py:514
msgid "libvirt is still reverting the VM"
msgstr "libvirt sanal makineyi hâlâ geri döndürüyor"
-#: instances/jobs.py:486
+#: instances/jobs.py:518
msgid ""
"It cannot be told whether the VM was reverted; check it, then revert again or"
" resolve this job."
@@ -2864,12 +2878,12 @@ msgstr ""
"Sanal makinenin geri döndürülüp döndürülmediği bilinemiyor; kontrol edin, "
"sonra yeniden geri döndürün ya da bu işi çözün."
-#: instances/jobs.py:503
+#: instances/jobs.py:535
msgid "The VM must be running, paused or shut off to be cloned"
msgstr ""
"Klonlanmak için sanal makine çalışıyor, duraklatılmış ya da kapalı olmalı"
-#: instances/jobs.py:504
+#: instances/jobs.py:536
#, python-format
msgid ""
"The guest's file systems could not be thawed (%(error)s); thaw them with: "
@@ -2878,19 +2892,19 @@ msgstr ""
"Konuğun dosya sistemleri çözülemedi (%(error)s); şununla çözün: virsh "
"domfsthaw %(vm)s"
-#: instances/jobs.py:523
+#: instances/jobs.py:555
msgid "Clone VM"
msgstr "Sanal makineyi klonla"
-#: instances/jobs.py:564 instances/jobs.py:571 instances/jobs.py:849
+#: instances/jobs.py:596 instances/jobs.py:603 instances/jobs.py:881
msgid "Copying disks"
msgstr "Diskler kopyalanıyor"
-#: instances/jobs.py:623
+#: instances/jobs.py:655
msgid "libvirt has no result of the copy"
msgstr "libvirt'te kopyanın sonucu yok"
-#: instances/jobs.py:634
+#: instances/jobs.py:666
#, python-format
msgid ""
"The copy failed: %(error)s. The guest's file systems could not be thawed "
@@ -2899,11 +2913,11 @@ msgstr ""
"Kopyalama başarısız oldu: %(error)s. Konuğun dosya sistemleri çözülemedi "
"(%(thaw)s); şununla çözün: virsh domfsthaw %(vm)s"
-#: instances/jobs.py:637 instances/jobs.py:724
+#: instances/jobs.py:669 instances/jobs.py:756
msgid "The copy failed"
msgstr "Kopyalama başarısız oldu"
-#: instances/jobs.py:662
+#: instances/jobs.py:694
#, python-format
msgid ""
"The VM is no longer on the compute the job ran on; check these volumes, then "
@@ -2912,7 +2926,7 @@ msgstr ""
"Sanal makine artık işin çalıştığı sunucuda değil; şu birimleri kontrol edin, "
"sonra bu işi çözün: %(volumes)s"
-#: instances/jobs.py:700
+#: instances/jobs.py:732
#, python-format
msgid ""
"A volume of the clone was being created; check these volumes, then resolve "
@@ -2921,15 +2935,15 @@ msgstr ""
"Klonun bir birimi oluşturuluyordu; şu birimleri kontrol edin, sonra bu işi "
"çözün: %(volumes)s"
-#: instances/jobs.py:704
+#: instances/jobs.py:736
msgid "The job stopped before the copy started"
msgstr "İş, kopyalama başlamadan durdu"
-#: instances/jobs.py:711
+#: instances/jobs.py:743
msgid "libvirt is still copying the disks"
msgstr "libvirt diskleri hâlâ kopyalıyor"
-#: instances/jobs.py:714
+#: instances/jobs.py:746
#, python-format
msgid ""
"Another backup runs on the VM; check these volumes, then resolve this job: "
@@ -2938,7 +2952,7 @@ msgstr ""
"Sanal makinede başka bir yedekleme çalışıyor; şu birimleri kontrol edin, "
"sonra bu işi çözün: %(volumes)s"
-#: instances/jobs.py:719
+#: instances/jobs.py:751
#, python-format
msgid ""
"libvirt has no result of the copy; check these volumes, then resolve this "
@@ -2947,7 +2961,7 @@ msgstr ""
"libvirt'te kopyanın sonucu yok; şu birimleri kontrol edin, sonra bu işi "
"çözün: %(volumes)s"
-#: instances/jobs.py:731
+#: instances/jobs.py:763
#, python-format
msgid ""
"The disks were copied, but the clone could not be defined: %(error)s; check "
@@ -2956,74 +2970,74 @@ msgstr ""
"Diskler kopyalandı, ancak klon tanımlanamadı: %(error)s; şu birimleri kontrol"
" edin, sonra bu işi çözün: %(volumes)s"
-#: instances/jobs.py:754 instances/templates/create_instance_w1.html:60
+#: instances/jobs.py:786 instances/templates/create_instance_w1.html:60
#: instances/templates/create_instance_w2.html:356
msgid "Create VM"
msgstr "Sanal makine oluştur"
-#: instances/jobs.py:778 instances/jobs.py:823 instances/views.py:2474
+#: instances/jobs.py:810 instances/jobs.py:855 instances/views.py:2474
msgid "A virtual machine with this name already exists"
msgstr "Bu isimde bir sanal makine mevcut"
-#: instances/jobs.py:780 instances/jobs.py:820
+#: instances/jobs.py:812 instances/jobs.py:852
msgid "There is an instance with same name. Remove it and try again!"
msgstr "Aynı isimde başka bir sanal makine var. Onu silip yeniden deneyin!"
-#: instances/jobs.py:783
+#: instances/jobs.py:815
msgid "First you need to create or select an image"
msgstr "Öncelikle bir imaj oluşturun ya da mevcut bir imajı seçin"
-#: instances/jobs.py:785
+#: instances/jobs.py:817
msgid "Choose one disk source: a new disk, existing volumes or a template"
msgstr ""
"Tek bir disk kaynağı seçin: yeni bir disk, mevcut birimler ya da bir şablon"
-#: instances/jobs.py:787
+#: instances/jobs.py:819
msgid "Image has already exist. Please check volumes or change instance name"
msgstr ""
"İmaj zaten mevcut. Lütfen ya makine adını değişin ya da disk alanlarını "
"kontrol edin"
-#: instances/jobs.py:789
+#: instances/jobs.py:821
msgid "Invalid cache mode"
msgstr "Geçersiz önbellek modu"
-#: instances/jobs.py:791
+#: instances/jobs.py:823
#, python-format
msgid "This host offers no CPU mode or model %(mode)s"
msgstr "Bu sunucu %(mode)s CPU modunu veya modelini sunmuyor"
-#: instances/jobs.py:793
+#: instances/jobs.py:825
#, python-format
msgid "A CD-ROM cannot use the %(bus)s bus"
msgstr "CD-ROM %(bus)s veri yolunu kullanamaz"
-#: instances/jobs.py:796
+#: instances/jobs.py:828
msgid "The cloud-init seed is read from a CD-ROM: choose its bus"
msgstr "cloud-init tohumu bir CD-ROM'dan okunur: veri yolunu seçin"
-#: instances/jobs.py:799
+#: instances/jobs.py:831
msgid "The cloud-init seed needs a directory storage pool for the disk"
msgstr "cloud-init tohumu, disk için bir dizin depolama havuzu gerektirir"
-#: instances/jobs.py:805
+#: instances/jobs.py:837
#, python-format
msgid "This host offers no firmware %(firmware)s"
msgstr "Bu sunucu %(firmware)s firmware'ini sunmuyor"
-#: instances/jobs.py:807 vrtManager/instance.py:3010
+#: instances/jobs.py:839 vrtManager/instance.py:3010
msgid "This host offers no TPM 2.0: install swtpm on it"
msgstr "Bu sunucu TPM 2.0 sunmuyor: üzerine swtpm kurun"
-#: instances/jobs.py:868
+#: instances/jobs.py:900
msgid "Writing the cloud-init seed"
msgstr "cloud-init tohumu yazılıyor"
-#: instances/jobs.py:875
+#: instances/jobs.py:907
msgid "Defining the VM"
msgstr "Sanal makine tanımlanıyor"
-#: instances/jobs.py:908
+#: instances/jobs.py:940
#, python-format
msgid ""
"The panel failed unexpectedly; its log has the details. Remove these volumes "
@@ -3032,114 +3046,114 @@ msgstr ""
"Panel beklenmedik bir hatayla karşılaştı; ayrıntılar panelin günlüğünde. Bu "
"birimleri elle kaldırın: %(volumes)s"
-#: instances/jobs.py:912
+#: instances/jobs.py:944
#, python-format
msgid "%(error)s. Remove these volumes by hand: %(volumes)s"
msgstr "%(error)s. Şu birimleri elle silin: %(volumes)s"
-#: instances/jobs.py:937
+#: instances/jobs.py:969
#, python-format
msgid "The instance is already on %(compute)s"
msgstr "Sanal makine zaten %(compute)s üzerinde"
-#: instances/jobs.py:939
+#: instances/jobs.py:971
#, python-format
msgid "%(compute)s is not reachable"
msgstr "%(compute)s sunucusuna ulaşılamıyor"
-#: instances/jobs.py:941
+#: instances/jobs.py:973
#, python-format
msgid "%(compute)s is the VM's own host under another name"
msgstr "%(compute)s, sanal makinenin kendi sunucusunun başka bir adı"
-#: instances/jobs.py:981
+#: instances/jobs.py:1013
#, python-format
msgid ""
"The VM migrated, but autostart could not be set on the destination: %(error)s"
msgstr ""
"Sanal makine taşındı, ancak hedefte otomatik başlatma ayarlanamadı: %(error)s"
-#: instances/jobs.py:988
+#: instances/jobs.py:1020
#, python-format
msgid "The VM migrated, but its NVRAM file stays on the source: %(error)s"
msgstr "Sanal makine taşındı, ancak NVRAM dosyası kaynakta kaldı: %(error)s"
-#: instances/jobs.py:996
+#: instances/jobs.py:1028
msgid "Migrate VM"
msgstr "Sanal makineyi taşı"
-#: instances/jobs.py:1043
+#: instances/jobs.py:1075
msgid "Migrating the VM"
msgstr "Sanal makine taşınıyor"
-#: instances/jobs.py:1093
+#: instances/jobs.py:1125
msgid "libvirt is still migrating the VM"
msgstr "libvirt sanal makineyi hâlâ taşıyor"
-#: instances/jobs.py:1104
+#: instances/jobs.py:1136
msgid "The VM stayed on the source host"
msgstr "Sanal makine kaynak sunucuda kaldı"
-#: instances/jobs.py:1106
+#: instances/jobs.py:1138
msgid ""
"The VM is on both computes or on neither; check them, then resolve this job."
msgstr ""
"Sanal makine ya iki sunucuda da var ya da hiçbirinde yok; kontrol edin, sonra"
" bu işi çözün."
-#: instances/jobs.py:1154
+#: instances/jobs.py:1186
msgid "Move disk"
msgstr "Diski taşı"
-#: instances/jobs.py:1161
+#: instances/jobs.py:1193
msgid "Choose a disk of the VM"
msgstr "Sanal makinenin bir diskini seçin"
-#: instances/jobs.py:1185
+#: instances/jobs.py:1217
msgid "Copying the disk"
msgstr "Disk kopyalanıyor"
-#: instances/jobs.py:1228
+#: instances/jobs.py:1260
#, python-format
msgid "Another block job runs on %(dev)s; check the VM"
msgstr ""
"%(dev)s üzerinde başka bir blok işi çalışıyor; sanal makineyi kontrol edin"
-#: instances/jobs.py:1234
+#: instances/jobs.py:1266
#, python-format
msgid "The disk stayed in %(path)s; remove %(dest)s if it is there"
msgstr "Disk %(path)s içinde kaldı; varsa %(dest)s dosyasını silin"
-#: instances/jobs.py:1236
+#: instances/jobs.py:1268
#, python-format
msgid "The definitions of %(dev)s name %(paths)s; check the VM"
msgstr "%(dev)s tanımları %(paths)s gösteriyor; sanal makineyi kontrol edin"
-#: instances/jobs.py:1243
+#: instances/jobs.py:1275
#, python-format
msgid "Disk %(path)s was kept: a disk file that stays needs it"
msgstr "%(path)s diski korundu: kalan bir disk dosyası ona ihtiyaç duyuyor"
-#: instances/jobs.py:1244
+#: instances/jobs.py:1276
#, python-format
msgid "The VM is removed, but disk %(path)s was not deleted: %(err)s"
msgstr "Sanal makine kaldırıldı, ancak %(path)s diski silinmedi: %(err)s"
-#: instances/jobs.py:1245
+#: instances/jobs.py:1277
#, python-format
msgid "Disk %(path)s is used by another VM and was kept"
msgstr ""
"%(path)s diski başka bir sanal makine tarafından kullanılıyor ve korundu"
-#: instances/jobs.py:1299
+#: instances/jobs.py:1331
msgid "Delete VM"
msgstr "Sanal makineyi sil"
-#: instances/jobs.py:1329
+#: instances/jobs.py:1361
msgid "Deleting the VM"
msgstr "Sanal makine siliniyor"
-#: instances/jobs.py:1341
+#: instances/jobs.py:1373
msgid "The VM was not deleted; it may have been shut off"
msgstr "Sanal makine silinmedi; kapatılmış olabilir"
diff --git a/locale/zh/LC_MESSAGES/django.mo b/locale/zh/LC_MESSAGES/django.mo
index 8a0ff608..89cd34c8 100644
Binary files a/locale/zh/LC_MESSAGES/django.mo and b/locale/zh/LC_MESSAGES/django.mo differ
diff --git a/locale/zh/LC_MESSAGES/django.po b/locale/zh/LC_MESSAGES/django.po
index 59bb8a3a..1e737cb3 100644
--- a/locale/zh/LC_MESSAGES/django.po
+++ b/locale/zh/LC_MESSAGES/django.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-11-07 12:30+0000\n"
+"POT-Creation-Date: 2026-11-07 13:19+0000\n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
@@ -2653,7 +2653,7 @@ msgstr "请从可用名称列表中选择克隆的名称"
msgid "%(quota)s: cannot create '%(clone_name)s'."
msgstr "%(quota)s:无法创建“%(clone_name)s”。"
-#: instances/jobs.py:200 instances/jobs.py:554
+#: instances/jobs.py:200 instances/jobs.py:586
#, python-format
msgid "Instance '%(clone_name)s' already exists!"
msgstr "虚拟机 '%(clone_name)s' 已存在!"
@@ -2742,275 +2742,287 @@ msgstr "libvirt 仍在创建快照"
msgid "The snapshot was not taken"
msgstr "未创建快照"
-#: instances/jobs.py:440
+#: instances/jobs.py:445
+#, python-format
+msgid ""
+"Snapshot %(name)s does not record the VM's vCPUs and memory: the quota cannot"
+" be checked."
+msgstr "快照“%(name)s”未记录虚拟机的 vCPU 和内存:无法检查配额。"
+
+#: instances/jobs.py:455
+#, python-format
+msgid "%(quota)s: cannot revert to snapshot %(name)s."
+msgstr "%(quota)s:无法恢复到快照“%(name)s”。"
+
+#: instances/jobs.py:462
msgid "Revert snapshot"
msgstr "恢复快照"
-#: instances/jobs.py:471
+#: instances/jobs.py:503
msgid "Reverting the VM"
msgstr "正在恢复虚拟机"
-#: instances/jobs.py:482
+#: instances/jobs.py:514
msgid "libvirt is still reverting the VM"
msgstr "libvirt 仍在恢复虚拟机"
-#: instances/jobs.py:486
+#: instances/jobs.py:518
msgid ""
"It cannot be told whether the VM was reverted; check it, then revert again or"
" resolve this job."
msgstr "无法判断虚拟机是否已恢复;请检查后再次恢复,或处理此任务。"
-#: instances/jobs.py:503
+#: instances/jobs.py:535
msgid "The VM must be running, paused or shut off to be cloned"
msgstr "虚拟机必须处于运行、暂停或关机状态才能克隆"
-#: instances/jobs.py:504
+#: instances/jobs.py:536
#, python-format
msgid ""
"The guest's file systems could not be thawed (%(error)s); thaw them with: "
"virsh domfsthaw %(vm)s"
msgstr "无法解冻客户机的文件系统(%(error)s);请用以下命令解冻:virsh domfsthaw %(vm)s"
-#: instances/jobs.py:523
+#: instances/jobs.py:555
msgid "Clone VM"
msgstr "克隆虚拟机"
-#: instances/jobs.py:564 instances/jobs.py:571 instances/jobs.py:849
+#: instances/jobs.py:596 instances/jobs.py:603 instances/jobs.py:881
msgid "Copying disks"
msgstr "正在复制磁盘"
-#: instances/jobs.py:623
+#: instances/jobs.py:655
msgid "libvirt has no result of the copy"
msgstr "libvirt 没有复制的结果"
-#: instances/jobs.py:634
+#: instances/jobs.py:666
#, python-format
msgid ""
"The copy failed: %(error)s. The guest's file systems could not be thawed "
"(%(thaw)s); thaw them with: virsh domfsthaw %(vm)s"
msgstr "复制失败:%(error)s。无法解冻客户机的文件系统(%(thaw)s);请用以下命令解冻:virsh domfsthaw %(vm)s"
-#: instances/jobs.py:637 instances/jobs.py:724
+#: instances/jobs.py:669 instances/jobs.py:756
msgid "The copy failed"
msgstr "复制失败"
-#: instances/jobs.py:662
+#: instances/jobs.py:694
#, python-format
msgid ""
"The VM is no longer on the compute the job ran on; check these volumes, then "
"resolve this job: %(volumes)s"
msgstr "虚拟机已不在运行该任务的计算节点上;请检查这些卷,然后处理此任务:%(volumes)s"
-#: instances/jobs.py:700
+#: instances/jobs.py:732
#, python-format
msgid ""
"A volume of the clone was being created; check these volumes, then resolve "
"this job: %(volumes)s"
msgstr "克隆的一个卷正在创建中;请检查这些卷,然后处理此任务:%(volumes)s"
-#: instances/jobs.py:704
+#: instances/jobs.py:736
msgid "The job stopped before the copy started"
msgstr "任务在复制开始前停止"
-#: instances/jobs.py:711
+#: instances/jobs.py:743
msgid "libvirt is still copying the disks"
msgstr "libvirt 仍在复制磁盘"
-#: instances/jobs.py:714
+#: instances/jobs.py:746
#, python-format
msgid ""
"Another backup runs on the VM; check these volumes, then resolve this job: "
"%(volumes)s"
msgstr "虚拟机上正在运行另一个备份;请检查这些卷,然后处理此任务:%(volumes)s"
-#: instances/jobs.py:719
+#: instances/jobs.py:751
#, python-format
msgid ""
"libvirt has no result of the copy; check these volumes, then resolve this "
"job: %(volumes)s"
msgstr "libvirt 没有复制的结果;请检查这些卷,然后处理此任务:%(volumes)s"
-#: instances/jobs.py:731
+#: instances/jobs.py:763
#, python-format
msgid ""
"The disks were copied, but the clone could not be defined: %(error)s; check "
"these volumes, then resolve this job: %(volumes)s"
msgstr "磁盘已复制,但无法定义克隆:%(error)s;请检查这些卷,然后处理此任务:%(volumes)s"
-#: instances/jobs.py:754 instances/templates/create_instance_w1.html:60
+#: instances/jobs.py:786 instances/templates/create_instance_w1.html:60
#: instances/templates/create_instance_w2.html:356
msgid "Create VM"
msgstr "创建虚拟机"
-#: instances/jobs.py:778 instances/jobs.py:823 instances/views.py:2474
+#: instances/jobs.py:810 instances/jobs.py:855 instances/views.py:2474
msgid "A virtual machine with this name already exists"
msgstr "同名虚拟机已存在"
-#: instances/jobs.py:780 instances/jobs.py:820
+#: instances/jobs.py:812 instances/jobs.py:852
msgid "There is an instance with same name. Remove it and try again!"
msgstr "已存在同名虚拟机。请删除后重试!"
-#: instances/jobs.py:783
+#: instances/jobs.py:815
msgid "First you need to create or select an image"
msgstr "请先创建或选择一个镜像"
-#: instances/jobs.py:785
+#: instances/jobs.py:817
msgid "Choose one disk source: a new disk, existing volumes or a template"
msgstr "请选择一种磁盘来源:新磁盘、现有卷或模板"
-#: instances/jobs.py:787
+#: instances/jobs.py:819
msgid "Image has already exist. Please check volumes or change instance name"
msgstr "镜像已存在。请检查卷或更改虚拟机名称"
-#: instances/jobs.py:789
+#: instances/jobs.py:821
msgid "Invalid cache mode"
msgstr "无效的缓存模式"
-#: instances/jobs.py:791
+#: instances/jobs.py:823
#, python-format
msgid "This host offers no CPU mode or model %(mode)s"
msgstr "此主机不提供 CPU 模式或型号 %(mode)s"
-#: instances/jobs.py:793
+#: instances/jobs.py:825
#, python-format
msgid "A CD-ROM cannot use the %(bus)s bus"
msgstr "CD-ROM 不能使用 %(bus)s 总线"
-#: instances/jobs.py:796
+#: instances/jobs.py:828
msgid "The cloud-init seed is read from a CD-ROM: choose its bus"
msgstr "cloud-init 种子从 CD-ROM 读取:请选择其总线"
-#: instances/jobs.py:799
+#: instances/jobs.py:831
msgid "The cloud-init seed needs a directory storage pool for the disk"
msgstr "cloud-init 种子需要为磁盘使用目录类型的存储池"
-#: instances/jobs.py:805
+#: instances/jobs.py:837
#, python-format
msgid "This host offers no firmware %(firmware)s"
msgstr "此主机不提供固件 %(firmware)s"
-#: instances/jobs.py:807 vrtManager/instance.py:3010
+#: instances/jobs.py:839 vrtManager/instance.py:3010
msgid "This host offers no TPM 2.0: install swtpm on it"
msgstr "此主机不提供 TPM 2.0:请在主机上安装 swtpm"
-#: instances/jobs.py:868
+#: instances/jobs.py:900
msgid "Writing the cloud-init seed"
msgstr "正在写入 cloud-init 种子"
-#: instances/jobs.py:875
+#: instances/jobs.py:907
msgid "Defining the VM"
msgstr "正在定义虚拟机"
-#: instances/jobs.py:908
+#: instances/jobs.py:940
#, python-format
msgid ""
"The panel failed unexpectedly; its log has the details. Remove these volumes "
"by hand: %(volumes)s"
msgstr "面板发生意外错误;详细信息见面板日志。请手动删除这些卷:%(volumes)s"
-#: instances/jobs.py:912
+#: instances/jobs.py:944
#, python-format
msgid "%(error)s. Remove these volumes by hand: %(volumes)s"
msgstr "%(error)s。请手动删除这些卷:%(volumes)s"
-#: instances/jobs.py:937
+#: instances/jobs.py:969
#, python-format
msgid "The instance is already on %(compute)s"
msgstr "虚拟机已在 %(compute)s 上"
-#: instances/jobs.py:939
+#: instances/jobs.py:971
#, python-format
msgid "%(compute)s is not reachable"
msgstr "无法连接 %(compute)s"
-#: instances/jobs.py:941
+#: instances/jobs.py:973
#, python-format
msgid "%(compute)s is the VM's own host under another name"
msgstr "%(compute)s 是该虚拟机所在主机的另一个名称"
-#: instances/jobs.py:981
+#: instances/jobs.py:1013
#, python-format
msgid ""
"The VM migrated, but autostart could not be set on the destination: %(error)s"
msgstr "虚拟机已迁移,但无法在目标主机上设置自动启动:%(error)s"
-#: instances/jobs.py:988
+#: instances/jobs.py:1020
#, python-format
msgid "The VM migrated, but its NVRAM file stays on the source: %(error)s"
msgstr "虚拟机已迁移,但其 NVRAM 文件仍留在源主机上:%(error)s"
-#: instances/jobs.py:996
+#: instances/jobs.py:1028
msgid "Migrate VM"
msgstr "迁移虚拟机"
-#: instances/jobs.py:1043
+#: instances/jobs.py:1075
msgid "Migrating the VM"
msgstr "正在迁移虚拟机"
-#: instances/jobs.py:1093
+#: instances/jobs.py:1125
msgid "libvirt is still migrating the VM"
msgstr "libvirt 仍在迁移虚拟机"
-#: instances/jobs.py:1104
+#: instances/jobs.py:1136
msgid "The VM stayed on the source host"
msgstr "虚拟机留在了源主机上"
-#: instances/jobs.py:1106
+#: instances/jobs.py:1138
msgid ""
"The VM is on both computes or on neither; check them, then resolve this job."
msgstr "虚拟机同时位于两个计算节点上,或都不在;请检查后处理此任务。"
-#: instances/jobs.py:1154
+#: instances/jobs.py:1186
msgid "Move disk"
msgstr "移动磁盘"
-#: instances/jobs.py:1161
+#: instances/jobs.py:1193
msgid "Choose a disk of the VM"
msgstr "请选择虚拟机的一个磁盘"
-#: instances/jobs.py:1185
+#: instances/jobs.py:1217
msgid "Copying the disk"
msgstr "正在复制磁盘"
-#: instances/jobs.py:1228
+#: instances/jobs.py:1260
#, python-format
msgid "Another block job runs on %(dev)s; check the VM"
msgstr "%(dev)s 上正在运行另一个块任务;请检查虚拟机"
-#: instances/jobs.py:1234
+#: instances/jobs.py:1266
#, python-format
msgid "The disk stayed in %(path)s; remove %(dest)s if it is there"
msgstr "磁盘仍在 %(path)s;如果 %(dest)s 存在,请将其删除"
-#: instances/jobs.py:1236
+#: instances/jobs.py:1268
#, python-format
msgid "The definitions of %(dev)s name %(paths)s; check the VM"
msgstr "%(dev)s 的定义指向 %(paths)s;请检查虚拟机"
-#: instances/jobs.py:1243
+#: instances/jobs.py:1275
#, python-format
msgid "Disk %(path)s was kept: a disk file that stays needs it"
msgstr "磁盘 %(path)s 已保留:某个保留的磁盘文件依赖它"
-#: instances/jobs.py:1244
+#: instances/jobs.py:1276
#, python-format
msgid "The VM is removed, but disk %(path)s was not deleted: %(err)s"
msgstr "虚拟机已删除,但磁盘 %(path)s 未被删除:%(err)s"
-#: instances/jobs.py:1245
+#: instances/jobs.py:1277
#, python-format
msgid "Disk %(path)s is used by another VM and was kept"
msgstr "磁盘 %(path)s 正被另一台虚拟机使用,已保留"
-#: instances/jobs.py:1299
+#: instances/jobs.py:1331
msgid "Delete VM"
msgstr "删除虚拟机"
-#: instances/jobs.py:1329
+#: instances/jobs.py:1361
msgid "Deleting the VM"
msgstr "正在删除虚拟机"
-#: instances/jobs.py:1341
+#: instances/jobs.py:1373
msgid "The VM was not deleted; it may have been shut off"
msgstr "虚拟机未被删除;它可能已被关闭"