diff --git a/README.md b/README.md
index ce54cc61..9dcae29f 100644
--- a/README.md
+++ b/README.md
@@ -531,6 +531,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
- 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.
- 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.
- The log rotates (10 MB × 5) in `data/webvirtcloud.log`.
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
- Docker no longer publishes port 6080; the console goes through nginx on the panel's port (`WS_PUBLIC_PORT`, default 80).
diff --git a/admin/templates/admin/tombstone_list.html b/admin/templates/admin/tombstone_list.html
new file mode 100644
index 00000000..3d9f788d
--- /dev/null
+++ b/admin/templates/admin/tombstone_list.html
@@ -0,0 +1,63 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% load static %}
+{% load bootstrap_icons %}
+{% block title %}{% trans "Removed VMs" %}{% endblock %}
+{% block content %}
+
+
+ {% include 'search_block.html' %}
+
+
{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}
+
+
+
+ {% if not tombstones %}
+
+
+ {% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
+
+
+ {% else %}
+
+
+
+
+ | {% trans "Name" %} |
+ {% trans "UUID" %} |
+ {% trans "Owners" %} |
+ {% trans "Removed" %} |
+ {% trans "Expires" %} |
+ {% trans "Actions" %} |
+
+
+
+ {% for tombstone in tombstones %}
+
+ |
+ {{ tombstone.name }}
+ {% if tombstone.is_template %}{% trans "Template" %}{% endif %}
+ |
+ {{ tombstone.uuid }} |
+ {{ tombstone.owner_names|join:", " }} |
+ {{ tombstone.removed|date:"Y-m-d H:i" }} |
+ {{ tombstone.expires|date:"Y-m-d H:i" }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+{% endblock content %}
+
+{% block script %}
+
+{% endblock script %}
diff --git a/admin/test_tombstones.py b/admin/test_tombstones.py
new file mode 100644
index 00000000..23d49ad1
--- /dev/null
+++ b/admin/test_tombstones.py
@@ -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())
diff --git a/admin/urls.py b/admin/urls.py
index 0b5ab415..937a1349 100644
--- a/admin/urls.py
+++ b/admin/urls.py
@@ -15,4 +15,6 @@ urlpatterns = [
path("users//block/", views.user_block, name="user_block"),
path("users//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//delete/", views.tombstone_delete, name="tombstone_delete"),
]
diff --git a/admin/views.py b/admin/views.py
index 363e144f..3a6c462f 100644
--- a/admin/views.py
+++ b/admin/views.py
@@ -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
diff --git a/instances/models.py b/instances/models.py
index ea84081a..a9a68818 100644
--- a/instances/models.py
+++ b/instances/models.py
@@ -319,6 +319,9 @@ class InstanceTombstone(models.Model):
owners = models.JSONField(default=list) # [{"user": id, "is_change": .., "is_delete": .., "is_vnc": ..}]
removed = models.DateTimeField(auto_now_add=True)
+ def __str__(self):
+ return "%s (%s)" % (self.name, self.uuid)
+
class PermissionSet(models.Model):
"""
diff --git a/templates/navbar.html b/templates/navbar.html
index e71867ba..8ac5c375 100644
--- a/templates/navbar.html
+++ b/templates/navbar.html
@@ -29,6 +29,7 @@
{% bs_icon 'person-plus' %} {% trans "Users" %}
{% bs_icon 'people-fill' %} {% trans "Groups" %}
{% bs_icon 'card-list' %} {% trans "Logs" %}
+ {% bs_icon 'trash' %} {% trans "Removed VMs" %}
{% bs_icon 'gear-wide-connected' %} {% trans "Settings" %}