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 "Removed VMs" %}

+

{% 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 %} +
+ + + + + + + + + + + + + {% for tombstone in tombstones %} + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "UUID" %}{% trans "Owners" %}{% trans "Removed" %}{% trans "Expires" %}{% trans "Actions" %}
+ {{ 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" }} + +
+
+ {% 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" %}