1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00

fix(computes): a deleted compute's VMs keep their owners

Deleting a compute removed its VMs from the panel together with their
owners, while the VMs kept running on the host: added again, the host's
VMs came back with no owners. A compute's delete now treats each VM as
one that left its compute: its owners follow the same UUID on another
compute or wait in a tombstone for the retention period. One function,
forget(), does this for the refresh and for the delete; the API's
compute delete goes through the same model delete.

The delete holds the compute exclusive and locks its row before its
VMs', as the reconcile does: a reconcile, VM operation or job on the
compute makes it busy instead of losing owners to a concurrent restore
or deadlocking with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
catborise 2026-10-22 11:49:42 +03:00
parent fbe93d9aee
commit 2dee5ba41c
7 changed files with 92 additions and 14 deletions

View file

@ -578,7 +578,7 @@ A version from before v3.0.0 has no worker: `systemctl disable --now webvirt-wor
- The admin's first-login password change survives losing `data/admin_password` (migration `accounts.0007`); run `migrate` as a user who can read that file. - The admin's first-login password change survives losing `data/admin_password` (migration `accounts.0007`); run `migrate` as a user who can read that file.
- "VM Clone Auto Migrate" is removed; a clone stays on its source host. - "VM Clone Auto Migrate" is removed; a clone stays on its source host.
- A template VM is changed or deleted only by superusers and staff owners with the matching permission; other owners can still view, open the console and clone it. Power cycle no longer starts a template. - A template VM is changed or deleted only by superusers and staff owners with the matching permission; other owners can still view, open the console and clone it. Power cycle no longer starts a template.
- A VM that vanishes from its compute keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers list and remove these records on the "Removed VMs" admin page. - A VM that vanishes from its compute, or whose compute is deleted, keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers list and remove these records on the "Removed VMs" admin page.
- The log rotates (10 MB × 5) in `data/webvirtcloud.log`. - The log rotates (10 MB × 5) in `data/webvirtcloud.log`.
- A disk can be grown while its VM runs or is paused, not only when it is shut off. A disk on an LVM pool cannot be grown (libvirt cannot resize its volumes): the resize says so instead of failing, and the VM's other disks still grow. - A disk can be grown while its VM runs or is paused, not only when it is shut off. A disk on an LVM pool cannot be grown (libvirt cannot resize its volumes): the resize says so instead of failing, and the VM's other disks still grow.
- Enabling vCPU hot plug keeps the VM's current vCPU count (it enabled all of them up to the maximum). - Enabling vCPU hot plug keeps the VM's current vCPU count (it enabled all of them up to the maximum).

View file

@ -1,5 +1,6 @@
from ipaddress import ip_address from ipaddress import ip_address
from django.db import transaction
from django.db.models import CharField, IntegerField, Model from django.db.models import CharField, IntegerField, Model
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -88,3 +89,17 @@ class Compute(Model):
def __str__(self): def __str__(self):
return self.name return self.name
def delete(self, *args, **kwargs):
"""Its VMs keep running on the host: their owners wait in tombstones,
or follow a VM already listed on another compute. The compute is held
exclusive, so no reconcile, VM operation or job on it runs meanwhile
(Busy if one does); its row is locked before its VMs', as the
reconcile does."""
from computes.utils import forget, libvirt_compute_lock
with libvirt_compute_lock(self), transaction.atomic():
Compute.objects.select_for_update().filter(pk=self.pk).first()
for inst in self.instance_set.select_for_update():
forget(inst)
return super().delete(*args, **kwargs)

View file

@ -8,8 +8,12 @@ from computes.models import Compute
from computes.utils import refresh_instance_database from computes.utils import refresh_instance_database
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from instances.models import Instance, InstanceTombstone from instances.models import Instance, InstanceTombstone
from operations import claims
from operations.models import Claim
from rest_framework.test import APIClient
UUID = "44444444-4444-4444-4444-444444444444" UUID = "44444444-4444-4444-4444-444444444444"
@ -111,3 +115,52 @@ class OwnershipTestCase(TestCase):
refresh_instance_database(self.host_a) refresh_instance_database(self.host_a)
self.assertEqual(self.ownership(self.host_a), (True, [])) self.assertEqual(self.ownership(self.host_a), (True, []))
# a deleted compute's VMs keep running on the host: their owners wait in
# tombstones as for a VM that disappeared, or follow a VM already on another compute
def delete(self, compute):
admin = get_user_model().objects.create_superuser("cd-admin", "cd@example.com", "pw")
self.client.force_login(admin)
self.client.post(reverse("compute_delete", args=[compute.id]))
self.assertFalse(Compute.objects.filter(pk=compute.pk).exists())
def test_a_host_added_again_gets_its_vms_back_with_their_owners(self):
self.owned_vm(self.host_a)
self.delete(self.host_a)
self.assertEqual(InstanceTombstone.objects.get().owners[0]["user"], self.owner.id)
again = self.compute("host-a-again")
self.on(again, [("vm", UUID)])
refresh_instance_database(again)
self.assertEqual(self.ownership(again), (True, [(self.owner, True, False, True)]))
def test_a_vm_already_on_another_compute_keeps_its_owners_there(self):
self.owned_vm(self.host_a)
self.on(self.host_b, [("vm", UUID)])
refresh_instance_database(self.host_b) # listed on B too: a copy for now
self.delete(self.host_a)
self.assertEqual(self.ownership(self.host_b), (True, [(self.owner, True, False, True)]))
self.assertFalse(InstanceTombstone.objects.exists())
def test_the_api_delete_keeps_the_owners_too(self):
self.owned_vm(self.host_a)
api = APIClient()
api.force_login(get_user_model().objects.create_superuser("cd-api", "cda@example.com", "pw"))
self.assertEqual(api.delete(reverse("compute-detail", args=[self.host_a.id])).status_code, 204)
self.assertEqual(InstanceTombstone.objects.get().owners[0]["user"], self.owner.id)
def test_a_compute_in_use_is_not_deleted(self):
# a reconcile, VM operation or job holds it: the delete waits for none of them
vm = self.owned_vm(self.host_a)
claims.admit({claims.compute_key(self.host_a.id): Claim.SHARED}, holder="192.0.2.30:1:1")
admin = get_user_model().objects.create_superuser("cd-busy", "cdb@example.com", "pw")
self.client.force_login(admin)
api = APIClient()
api.force_login(admin)
url = reverse("compute_delete", args=[self.host_a.id])
self.assertRedirects(self.client.post(url, HTTP_REFERER=f"http://testserver{url}"), url,
fetch_redirect_response=False)
self.assertEqual(api.delete(reverse("compute-detail", args=[self.host_a.id])).status_code, 409)
self.assertTrue(Compute.objects.filter(pk=self.host_a.pk).exists())
self.assertEqual(list(vm.userinstance_set.values_list("user", flat=True)), [self.owner.id])
self.assertFalse(InstanceTombstone.objects.exists())

View file

@ -62,6 +62,19 @@ def _move_ownership(source, target):
target.save(update_fields=["is_template"]) target.save(update_fields=["is_template"])
def forget(inst):
"""Remove an instance whose VM its compute no longer lists, or whose
compute is deleted. Its ownership goes to the same UUID on another
compute (an out-of-band move) or is kept in a tombstone in case the VM
comes back."""
elsewhere = Instance.objects.filter(uuid=inst.uuid).exclude(compute_id=inst.compute_id).first()
if elsewhere:
_move_ownership(inst, elsewhere)
else:
_bury(inst)
inst.delete()
def _bury(inst): def _bury(inst):
owners = [ owners = [
{"user": ui.user_id, "is_change": ui.is_change, "is_delete": ui.is_delete, "is_vnc": ui.is_vnc} {"user": ui.user_id, "is_change": ui.is_change, "is_delete": ui.is_delete, "is_vnc": ui.is_vnc}
@ -165,17 +178,10 @@ def refresh_instance_database(compute):
inst.name = new_name inst.name = new_name
inst.save(update_fields=["name"]) inst.save(update_fields=["name"])
# 2. Instances gone from this compute. Their ownership goes to the # 2. Instances gone from this compute
# same UUID on another compute (an out-of-band move) or is kept
# in a tombstone in case the VM comes back.
for inst in db_instances: for inst in db_instances:
if inst.uuid not in host_domains: if inst.uuid not in host_domains:
elsewhere = Instance.objects.filter(uuid=inst.uuid).exclude(compute=compute).first() forget(inst)
if elsewhere:
_move_ownership(inst, elsewhere)
else:
_bury(inst)
inst.delete()
# 3. Instances created out-of-band. A UUID that also exists on another # 3. Instances created out-of-band. A UUID that also exists on another
# compute is a copy and gets no owners; otherwise a tombstone's # compute is a copy and gets no owners; otherwise a tombstone's

View file

@ -1,6 +1,7 @@
import json import json
from admin.decorators import superuser_only from admin.decorators import superuser_only
from django.conf import settings
from django.core import signing from django.core import signing
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -129,8 +130,9 @@ def compute_delete(request, compute_id):
request, request,
"common/confirm_delete.html", "common/confirm_delete.html",
{"object": compute, {"object": compute,
"consequence": _("Its VMs keep running on the host, but the panel forgets them and who owns them; " "consequence": _("Its VMs keep running on the host and leave the panel; their owners are kept for "
"added again, the host's VMs come back without owners.")}, "%(days)s days on the Removed VMs page and come back if the host is added again.")
% {"days": getattr(settings, "INSTANCE_OWNERSHIP_RETENTION_DAYS", 30)}},
) )

View file

@ -303,7 +303,9 @@ class LocalUploadTests(LocalPool, UploadTestCase):
def test_ending_without_the_compute(self): def test_ending_without_the_compute(self):
op = self.send(0, b"aaa") op = self.send(0, b"aaa")
self.compute.delete() with self.assertRaises(claims.Busy): # the upload holds the compute
self.compute.delete()
Compute.objects.filter(pk=self.compute.pk).delete() # the row gone without Compute.delete()
op = jobs.cancel(op.pk, self.admin) op = jobs.cancel(op.pk, self.admin)
self.assertEqual((op.state, op.error), (Operation.FAILED, "The compute was deleted")) self.assertEqual((op.state, op.error), (Operation.FAILED, "The compute was deleted"))

View file

@ -19,7 +19,7 @@ class DeletePageTests(TestCase):
compute = Compute.objects.create(name="del-host", hostname="192.0.2.9", login="u", password="p", type=1) compute = Compute.objects.create(name="del-host", hostname="192.0.2.9", login="u", password="p", type=1)
vm = Instance.objects.create(compute=compute, name="del-vm", uuid="d0000000-0000-4000-8000-000000000001") vm = Instance.objects.create(compute=compute, name="del-vm", uuid="d0000000-0000-4000-8000-000000000001")
pages = { pages = {
reverse("compute_delete", args=[compute.id]): "the panel forgets them and who owns them", reverse("compute_delete", args=[compute.id]): "their owners are kept for 30 days on the Removed VMs page",
reverse("admin:user_delete", args=[user.id]): "the VMs themselves stay", reverse("admin:user_delete", args=[user.id]): "the VMs themselves stay",
reverse("admin:group_delete", args=[Group.objects.create(name="del-group").id]): "lose the permissions", reverse("admin:group_delete", args=[Group.objects.create(name="del-group").id]): "lose the permissions",
reverse("admin:tombstone_delete", args=[InstanceTombstone.objects.create(uuid=vm.uuid, name="gone").id]): reverse("admin:tombstone_delete", args=[InstanceTombstone.objects.create(uuid=vm.uuid, name="gone").id]):