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

feat(admin): list and remove the owners kept for vanished VMs

A VM that disappears from its compute keeps its owners in a tombstone
until INSTANCE_OWNERSHIP_RETENTION_DAYS pass, but nothing showed them.
Superusers now see them on the "Removed VMs" admin page (name, UUID,
owners, removed, expires) and can remove one, so a VM deleted on
purpose no longer hands its owners to a later VM with the same UUID.
This commit is contained in:
catborise 2026-10-08 19:07:02 +03:00
parent 71db69b294
commit 4ae3fb2d53
7 changed files with 157 additions and 0 deletions

View file

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap_icons %}
{% block title %}{% trans "Removed VMs" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% include 'search_block.html' %}
<h1 class="page-header">{% trans "Removed VMs" %}</h1>
<p class="text-muted">{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}</p>
</div>
</div>
<div class="row">
{% if not tombstones %}
<div class="col-lg-12">
<div class="alert alert-info shadow-sm">
{% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
</div>
</div>
{% else %}
<div class="col-lg-12">
<table class="table table-striped table-hover">
<thead>
<tr>
<th span="col">{% trans "Name" %}</th>
<th span="col">{% trans "UUID" %}</th>
<th span="col">{% trans "Owners" %}</th>
<th span="col">{% trans "Removed" %}</th>
<th span="col">{% trans "Expires" %}</th>
<th span="col">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="searchable">
{% for tombstone in tombstones %}
<tr>
<td>
<strong>{{ tombstone.name }}</strong>
{% if tombstone.is_template %}<span class="badge bg-secondary">{% trans "Template" %}</span>{% endif %}
</td>
<td>{{ tombstone.uuid }}</td>
<td>{{ tombstone.owner_names|join:", " }}</td>
<td>{{ tombstone.removed|date:"Y-m-d H:i" }}</td>
<td>{{ tombstone.expires|date:"Y-m-d H:i" }}</td>
<td>
<div class="float-end btn-group">
<a class="btn btn-danger" href="{% url 'admin:tombstone_delete' tombstone.id %}" title="{% trans "Delete" %}">
{% bs_icon 'x' %}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'js/filter-table.js' %}"></script>
{% endblock script %}

56
admin/test_tombstones.py Normal file
View file

@ -0,0 +1,56 @@
"""Superusers list the ownership kept for VMs that vanished and can remove it."""
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from instances.models import InstanceTombstone
UUID = "00000000-0000-4000-8000-000000000001"
class TombstoneTests(TestCase):
def setUp(self):
self.client.force_login(User.objects.create_superuser("ts-admin", "ts@example.com", "pw"))
self.owner = User.objects.create_user("ts-owner", password="pw")
self.tombstone = InstanceTombstone.objects.create(
uuid=UUID,
name="ts-vm",
owners=[
{"user": self.owner.id, "is_change": True, "is_delete": False, "is_vnc": True},
{"user": 999999, "is_change": False, "is_delete": False, "is_vnc": False},
],
)
@override_settings(INSTANCE_OWNERSHIP_RETENTION_DAYS=7)
def test_the_list_shows_the_vm_its_owners_and_when_it_expires(self):
removed = timezone.now() - timedelta(days=2)
InstanceTombstone.objects.filter(pk=self.tombstone.pk).update(removed=removed)
response = self.client.get(reverse("admin:tombstone_list"))
self.assertEqual(response.status_code, 200)
row = response.context["tombstones"][0]
self.assertEqual(row.owner_names, ["ts-owner", "deleted user"])
self.assertEqual(row.expires, removed + timedelta(days=7))
self.assertContains(response, "ts-vm")
self.assertContains(response, UUID)
self.assertContains(response, "ts-owner")
def test_only_superusers_see_or_remove_them(self):
self.client.force_login(self.owner)
self.assertEqual(self.client.get(reverse("admin:tombstone_list")).status_code, 403)
url = reverse("admin:tombstone_delete", args=[self.tombstone.pk])
self.assertEqual(self.client.post(url).status_code, 403)
self.assertTrue(InstanceTombstone.objects.exists())
def test_a_get_only_asks_for_confirmation(self):
response = self.client.get(reverse("admin:tombstone_delete", args=[self.tombstone.pk]))
self.assertContains(response, "ts-vm")
self.assertTrue(InstanceTombstone.objects.exists())
def test_a_post_removes_it(self):
response = self.client.post(reverse("admin:tombstone_delete", args=[self.tombstone.pk]))
self.assertRedirects(response, reverse("admin:tombstone_list"))
self.assertFalse(InstanceTombstone.objects.exists())

View file

@ -15,4 +15,6 @@ urlpatterns = [
path("users/<int:pk>/block/", views.user_block, name="user_block"),
path("users/<int:pk>/unblock/", views.user_unblock, name="user_unblock"),
path("logs/", views.logs, name="logs"),
path("removed-vms/", views.tombstone_list, name="tombstone_list"),
path("removed-vms/<int:pk>/delete/", views.tombstone_delete, name="tombstone_delete"),
]

View file

@ -1,3 +1,5 @@
from datetime import timedelta
from accounts.models import Instance, UserAttributes, UserInstance
from appsettings.settings import app_settings
from django.conf import settings
@ -10,6 +12,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from instances.models import InstanceTombstone
from logs.models import Logs
from . import forms
@ -210,6 +213,34 @@ def logs(request):
return render(request, "admin/logs.html", {"logs": logs})
@superuser_only
def tombstone_list(request):
"""The owners kept for VMs that vanished from their compute, in case the
same UUID comes back."""
tombstones = list(InstanceTombstone.objects.order_by("-removed"))
ids = {owner["user"] for tombstone in tombstones for owner in tombstone.owners}
names = dict(User.objects.filter(id__in=ids).values_list("id", "username"))
retention = timedelta(days=getattr(settings, "INSTANCE_OWNERSHIP_RETENTION_DAYS", 30))
for tombstone in tombstones:
tombstone.owner_names = [names.get(owner["user"], _("deleted user")) for owner in tombstone.owners]
tombstone.expires = tombstone.removed + retention
return render(request, "admin/tombstone_list.html", {"tombstones": tombstones})
@superuser_only
def tombstone_delete(request, pk):
tombstone = get_object_or_404(InstanceTombstone, pk=pk)
if request.method == "POST":
tombstone.delete()
return redirect("admin:tombstone_list")
return render(
request,
"common/confirm_delete.html",
{"object": tombstone},
)
def add_default_instances(user):
"""
Adds instances listed in NEW_USER_DEFAULT_INSTANCES to user