1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(logs): every VM operation writes an audit row the VM's Logs tab finds by UUID

Operations on a VM now write their rows the way the admin audit does: one
row per operation, done or failed with a safe reason, with the user, IP and
host. Each row names the VM ("VM web-1: delete snapshot snap1") and carries
its UUID in the new Logs.instance_uuid column.

- audit_vm and failed_vm wrap audit() and failed() for a VM. A failure the
  view turns into a message is caught outside the block, so its failed row
  is written; busy claims, permission and quota refusals and invalid input
  write nothing. No password, key or XML goes into a row.
- All VM page operations move from addlogmsg to audit_vm, and addlogmsg
  goes. QoS changes and defining a VM from XML are logged now; a gstfsd
  error reply (root password, SSH key) is a failed row, not a done one.
- Partial results are rows of their own: the VM delete, then each disk
  delete; the disk detach, then the volume delete; the migration, then a
  failed autostart restore, NVRAM removal or record on the target.
- Migration rows use the compute name as the host column, as every other
  row does.
- VM job rows (create, clone, snapshots) and their resolutions carry the
  VM's UUID; snapshot job intents now record it. The owner rows of the VM
  page and the accounts pages, and the ownership system rows, carry it too.
- The Logs tab loads vm_logs/<pk>/ and shows the rows with the VM's UUID,
  so a rename keeps the history, two VMs with one name are told apart and
  a VM on two computes is one VM. It gains a Result column. Rows from
  before this change have no UUID and stay on the Logs page only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
catborise 2026-10-24 14:05:11 +03:00
parent 49cf16ecbe
commit c1d36053d6
24 changed files with 639 additions and 524 deletions

View file

@ -61,11 +61,12 @@ class AdminAccountRowTests(TestCase):
self.client.post(url, {"is_vnc": "on"}) # no change
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
self.assertEqual(rows(), [
("VM vm owner bob: add", True),
("VM vm owner bob: change is_vnc", True),
("VM vm owner bob: remove", True),
("VM vm: add owner bob", True),
("VM vm: change owner bob: is_vnc", True),
("VM vm: remove owner bob", True),
])
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance")), {("audit-admin", "c1", "vm")})
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance", "instance_uuid")),
{("audit-admin", "c1", "vm", self.vm.uuid)})
def test_otp_mail_without_an_email(self):
from smtplib import SMTPException

View file

@ -19,7 +19,7 @@ from django.views.decorators.http import require_POST
from accounts.apps import admin_password_path
from instances.models import Instance
from logs.audit import audit, changed_fields, failed, snapshot
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import User, UserAttributes, UserInstance, UserSSHKey
@ -146,8 +146,7 @@ def user_instance_create(request, user_id):
form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
if form.is_valid():
instance = form.cleaned_data["instance"]
subject = f"VM {instance.name} owner {form.cleaned_data['user'].username}"
with audit(request, instance.compute, subject, "add", instance=instance.name):
with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}"):
form.save()
return redirect(reverse("accounts:account", args=[user.id]))
@ -167,11 +166,10 @@ def user_instance_update(request, pk):
instance, before = user_instance.instance, snapshot(user_instance)
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
if form.is_valid():
subject = f"VM {instance.name} owner {user_instance.user.username}"
with audit(request, instance.compute, subject, "change", instance=instance.name) as entry:
with audit_vm(request, instance, "change owner") as entry:
form.save()
fields = changed_fields(before, snapshot(user_instance))
entry.action = "change " + ", ".join(fields)
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
entry.skip = not fields
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
@ -190,8 +188,7 @@ def user_instance_delete(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
if request.method == "POST":
user, instance = user_instance.user, user_instance.instance
with audit(request, instance.compute, f"VM {instance.name} owner {user.username}", "remove",
instance=instance.name):
with audit_vm(request, instance, f"remove owner {user.username}"):
user_instance.delete()
next_url = request.GET.get("next", None)
if next_url and url_has_allowed_host_and_scheme(

View file

@ -60,7 +60,7 @@ def lock_ownership():
def _log(inst, what):
write("-", inst.compute.name, "", f"VM {inst.name} ({inst.uuid}): {what}", True)
write("-", inst.compute.name, inst.name, f"VM {inst.name} ({inst.uuid}): {what}", True, uuid=inst.uuid)
def _move_ownership(source, target):

View file

@ -53,7 +53,7 @@ def _vm_plan(params):
claims.instance_key(instance.uuid): Claim.EXCLUSIVE},
resource=claims.instance_key(instance.uuid),
label=f"{instance.compute.name}/{instance.name}",
intent={"compute": instance.compute_id},
intent={"compute": instance.compute_id, "uuid": instance.uuid},
)
@ -83,9 +83,12 @@ def _libvirt_busy(dom):
return dom.isActive() and dom.jobInfo()[0] != VIR_DOMAIN_JOB_NONE
def _log(op, state, message):
compute, _sep, vm = op.resource_label.rpartition("/") # a VM name has no "/"
return compute, vm, message if state == Operation.SUCCEEDED else f"{message} ({state})"
def _log(op, state, action, vm=None):
"""The job's row: "VM <name>: <action>", the end state added unless it succeeded."""
compute, _sep, label_vm = op.resource_label.rpartition("/") # a VM name has no "/"
vm = vm or label_vm
message = f"VM {vm}: {action}"
return compute, vm, op.intent["uuid"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
def _snapshot_free_space():
@ -283,7 +286,7 @@ class DeleteExternalSnapshot(Kind):
"changed; check the VM before deleting the snapshot again or reverting to it.")
def log_entry(self, op, state):
return _log(op, state, f"Delete external snapshot: {op.params['name']}")
return _log(op, state, f"delete external snapshot {op.params['name']}")
GONE = "The VM is no longer on the compute the job ran on"
@ -335,7 +338,7 @@ class CreateSnapshot(Kind):
def log_entry(self, op, state):
kind = "external snapshot" if op.params["external"] else "snapshot"
return _log(op, state, f"Create {kind}: {op.params['name']}")
return _log(op, state, f"take {kind} {op.params['name']}")
def _current(proxy):
@ -388,7 +391,7 @@ class RevertSnapshot(Kind):
def log_entry(self, op, state):
kind = "external snapshot" if op.params["external"] else "snapshot"
return _log(op, state, f"Revert {kind}: {op.params['name']}")
return _log(op, state, f"revert to {kind} {op.params['name']}")
def _delete_created(proxy, op):
@ -606,9 +609,8 @@ class CloneInstance(Kind):
return Outcome(Operation.SUCCEEDED)
def log_entry(self, op, state):
compute, _sep, source = op.resource_label.rpartition("/")
message = f"Create a clone of '{source}'"
return compute, op.intent["name"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
source = op.resource_label.rpartition("/")[2]
return _log(op, state, f"clone from {source}", vm=op.intent["name"])
@register
@ -756,9 +758,7 @@ class CreateInstance(Kind):
return vm_outcome(op, owner=False, volumes=names if data["hdd_size"] or data["template"] else [])
def log_entry(self, op, state):
compute, _sep, name = op.resource_label.rpartition("/")
message = "Instance is created"
return compute, name, message if state == Operation.SUCCEEDED else f"{message} ({state})"
return _log(op, state, "create")
MIGRATE_OPTIONS = ("live", "unsafe", "offline", "autoconverge", "compress")

View file

@ -9,7 +9,7 @@
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab" aria-controls="logs" aria-selected="false" onclick="update_logs_table(vname);">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab" aria-controls="logs" aria-selected="false" onclick="update_logs_table();">
{% trans "Logs" %}
</button>
</li>
@ -71,11 +71,12 @@
<th scope="col">{% trans "Date" %}</th>
<th scope="col">{% trans "User" %}</th>
<th scope="col">{% trans "Message" %}</th>
<th scope="col">{% trans "Result" %}</th>
</tr>
</thead>
<tbody class="searchable">
<tr>
<td colspan="3"><i>{% trans 'None' %}...</i></td>
<td colspan="4"><i>{% trans 'None' %}...</i></td>
</tr>
</tbody>
</table>
@ -85,10 +86,8 @@
</div>
</div>
<script>
function update_logs_table(vname) {
// TODO
logurl = "{% url 'vm_logs' 1 %}".replace(1, vname);
$.getJSON(logurl, function(data) {
function update_logs_table() {
$.getJSON("{% url 'vm_logs' instance.id %}", function(data) {
// Log messages contain user-entered names (snapshots, disks, clones);
// insert them as text, never as HTML.
var tbody = $("#logs_table > tbody").empty();
@ -97,6 +96,7 @@
.append($("<td>").css("width", "150px").text(row['date']))
.append($("<td>").text(row['user']))
.append($("<td>").text(row['message']))
.append($("<td>").text(row['result']))
.appendTo(tbody);
});
});

View file

@ -33,11 +33,11 @@ class VMOwnerRowTests(TestCase):
ui = UserInstance.objects.get(user=self.bob)
self.client.post(reverse("instances:del_owner", args=[self.vm.pk]), {"userinstance": ui.pk})
self.assertEqual(rows(), [
("VM vm owner bob: add", True),
("VM vm owner carol: add: only one owner is allowed", False),
("VM vm owner bob: remove", True),
("VM vm: add owner bob", True),
("VM vm: add owner carol: only one owner is allowed", False),
("VM vm: remove owner bob", True),
])
shown = [log["message"] for log in self.client.get(reverse("vm_logs", args=["vm"])).json()]
shown = [log["message"] for log in self.client.get(reverse("vm_logs", args=[self.vm.pk])).json()]
self.assertCountEqual(shown, [m for m, _ in rows()])

View file

@ -289,6 +289,9 @@ class XmlRefusalTests(TestCase):
def test_a_taken_name(self):
self.assertIn("already exists", self.post("<domain type='test'><name>test</name></domain>"))
def test_a_definition_without_a_name(self):
self.assertIn("no name", self.post("<domain type='kvm'><memory>1024</memory></domain>"))
def test_malformed_xml(self):
self.assertIn("not well-formed", self.post("<domain type='test'><name>new</name>"))

View file

@ -44,19 +44,19 @@ class MigrationBookkeepingTests(TestCase):
def test_a_failed_autostart_does_not_stop_the_records(self):
self.migrate(autostart=True, autostart_error=util.OperationError("no autostart"))
self.assertEqual(Instance.objects.get(id=self.vm.id).compute, self.dest)
self.assertEqual(
Logs.objects.get().message,
"Instance is migrated(live) to 198.51.100.9; setting its autostart there failed: no autostart",
)
self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
("VM vm: migrate (live) to 198.51.100.9", True),
("VM vm: restore autostart on 198.51.100.9: no autostart", False),
])
def test_a_failed_record_is_logged_as_such(self):
with patch.object(Instance, "save", side_effect=DatabaseError("locked")), \
self.assertRaises(DatabaseError):
self.migrate()
self.assertEqual(
Logs.objects.get().message,
"Instance is migrated(live) to 198.51.100.9, but recording it failed: locked",
)
self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
("VM vm: migrate (live) to 198.51.100.9", True),
("VM vm: record the move to 198.51.100.9: DatabaseError", False),
])
def test_a_record_the_destination_already_has_keeps_template_and_owners(self):
owner = get_user_model().objects.create_user("mb-owner")

View file

@ -1,5 +1,6 @@
"""A migration is logged where it runs (migrate_instance, in the job): the
mode it used on success, the error on failure."""
mode it used on success, the error on failure, and each later step that
failed in a row of its own."""
from unittest.mock import MagicMock, patch
@ -14,6 +15,10 @@ from instances.utils import migrate_instance
from vrtManager import util
def rows():
return list(Logs.objects.order_by("id").values_list("message", "success"))
class MigrationLogTests(TestCase):
def setUp(self):
self.admin = get_user_model().objects.create_superuser("ml-admin", "ml@example.com", "pw")
@ -45,7 +50,7 @@ class MigrationLogTests(TestCase):
path, destination = self.proxy.remove_nvram.call_args[0]
self.assertEqual(path, "/var/lib/libvirt/qemu/nvram/vm_VARS.fd")
self.assertIsNotNone(destination)
self.assertEqual(Logs.objects.get().message, "Instance is migrated(live) to 198.51.100.9")
self.assertEqual(rows(), [("VM vm: migrate (live) to 198.51.100.9", True)])
def test_a_vm_without_nvram_removes_nothing(self):
self.migrate("live", live=True)
@ -54,11 +59,10 @@ class MigrationLogTests(TestCase):
def test_a_failed_nvram_removal_is_logged_but_the_migration_stands(self):
self.migrate("live", nvram="/var/lib/libvirt/qemu/nvram/vm_VARS.fd",
remove_nvram=libvirtError("cannot delete"), live=True)
self.assertEqual(
Logs.objects.get().message,
"Instance is migrated(live) to 198.51.100.9; removing its NVRAM file "
"/var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source failed: cannot delete",
)
self.assertEqual(rows(), [
("VM vm: migrate (live) to 198.51.100.9", True),
("VM vm: remove NVRAM file /var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source: cannot delete", False),
])
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest)
@ -67,7 +71,9 @@ class MigrationLogTests(TestCase):
remove_nvram=AttributeError("unexpected"), live=True)
self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest)
self.assertIn("on the source failed: unexpected", Logs.objects.get().message)
# an unknown error gives only its class name
self.assertEqual(rows()[-1], ("VM vm: remove NVRAM file /var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source: "
"AttributeError", False))
def test_a_failed_migration_removes_nothing(self):
with self.assertRaises(util.OperationError):
@ -77,23 +83,20 @@ class MigrationLogTests(TestCase):
def test_success_names_the_mode_used(self):
self.migrate("non-live, compressed", live=False, compress=True)
log = Logs.objects.get()
self.assertEqual(log.message, "Instance is migrated(non-live, compressed) to 198.51.100.9")
self.assertEqual((log.user, log.host, log.instance), ("ml-admin", "198.51.100.8", "vm"))
self.assertEqual(log.message, "VM vm: migrate (non-live, compressed) to 198.51.100.9")
self.assertEqual((log.user, log.host, log.instance, log.instance_uuid), ("ml-admin", "ml-src", "vm", "u-ml"))
def test_success_names_the_migration_address(self):
self.dest.migration_address = "203.0.113.9"
self.migrate("live", live=True)
self.assertEqual(Logs.objects.get().message, "Instance is migrated(live) to 198.51.100.9 via 203.0.113.9")
self.assertEqual(Logs.objects.get().message, "VM vm: migrate (live) to 198.51.100.9 via 203.0.113.9")
self.vm.compute = self.source
self.vm.save()
self.dest.migration_address = "198.51.100.9" # the host name itself
self.migrate("live", live=True)
self.assertEqual(Logs.objects.latest("id").message, "Instance is migrated(live) to 198.51.100.9")
self.assertEqual(Logs.objects.latest("id").message, "VM vm: migrate (live) to 198.51.100.9")
def test_failure_is_logged_and_raised(self):
with self.assertRaisesRegex(util.OperationError, "A shut-off VM cannot be migrated live"):
self.migrate(util.OperationError("A shut-off VM cannot be migrated live"), live=True)
self.assertEqual(
Logs.objects.get().message,
"Instance migration to 198.51.100.9 failed: A shut-off VM cannot be migrated live",
)
self.assertEqual(rows(), [("VM vm: migrate to 198.51.100.9: A shut-off VM cannot be migrated live", False)])

View file

@ -98,7 +98,8 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
self.assertEqual(op.intent["before"]["own"], ["/p/a.one"])
self.assertEqual(result, {"left_behind": ["/p/a.one"]})
jobs.finish(op, Outcome(Operation.SUCCEEDED, result=result))
self.assertTrue(Logs.objects.filter(message="Delete external snapshot: one").exists())
row = Logs.objects.get(message="VM job-vm: delete external snapshot one")
self.assertEqual(row.instance_uuid, self.instance.uuid)
def logged(self):
return list(Logs.objects.values_list("user", "host", "instance", "message"))
@ -110,17 +111,17 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
op = self.submitted()
jobs.finish(op, Outcome(state, error="x"))
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm",
f"Delete external snapshot: one ({state})")])
f"VM job-vm: delete external snapshot one ({state})")])
Logs.objects.all().delete()
Operation.objects.all().delete()
jobs.cancel(self.submitted().pk, self.admin)
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", "Delete external snapshot: one (cancelled)")])
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", "VM job-vm: delete external snapshot one (cancelled)")])
def test_a_compute_name_with_a_slash_is_logged_whole(self):
Compute.objects.filter(pk=self.instance.compute_id).update(name="region/job-compute")
op = self.submitted()
jobs.finish(op, Outcome(Operation.SUCCEEDED))
self.assertEqual(self.logged(), [("job_admin", "region/job-compute", "job-vm", "Delete external snapshot: one")])
self.assertEqual(self.logged(), [("job_admin", "region/job-compute", "job-vm", "VM job-vm: delete external snapshot one")])
def test_a_resolved_job_is_logged_on_its_vm(self):
op = self.submitted()
@ -129,6 +130,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
jobs.resolve(op.pk, self.admin, Operation.FAILED, "disks checked")
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm",
f"Job {op.pk} resolved as failed: disks checked")])
self.assertEqual(Logs.objects.get().instance_uuid, self.instance.uuid)
def test_retention_keeps_the_log(self):
op = self.submitted()
@ -136,7 +138,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
Operation.objects.filter(pk=op.pk).update(finished_at=timezone.now() - timedelta(days=400))
housekeeping()
self.assertFalse(Operation.objects.filter(pk=op.pk).exists())
self.assertEqual([row[3] for row in self.logged()], ["Delete external snapshot: one (failed)"])
self.assertEqual([row[3] for row in self.logged()], ["VM job-vm: delete external snapshot one (failed)"])
def test_precheck_refuses_a_moved_vm_and_an_inactive_user(self):
op = self.submitted()

View file

@ -0,0 +1,161 @@
"""Operations on a VM write one row each: done, or failed with the reason,
named by the VM and carrying its UUID; refused input writes nothing."""
from unittest.mock import patch
import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from instances.models import Instance
from libvirt import libvirtError
from logs.models import Logs
from vrtManager.util import OperationError
UUID = "11111111-2222-3333-4444-555555555555"
class VMRowTests(TestCase):
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("vm-admin", "vm@example.com", "pw"))
self.client.raise_request_exception = False
compute = Compute.objects.create(name="c1", hostname="192.0.2.1", login="", password="", type=4)
self.vm = Instance.objects.create(compute=compute, name="vm", uuid=UUID)
Logs.objects.all().delete() # the login's row
patcher = patch("instances.models.wvmInstance")
self.proxy = patcher.start().return_value
self.addCleanup(patcher.stop)
self.proxy.instance.isActive.return_value = False
def post(self, view, data=None, args=None):
return self.client.post(reverse(f"instances:{view}", args=args or [self.vm.pk]), data or {},
REMOTE_ADDR="192.0.2.50")
def rows(self):
return list(Logs.objects.order_by("id").values_list("message", "success"))
def test_a_done_row_names_the_vm_and_carries_its_uuid(self):
self.post("poweron")
row = Logs.objects.get()
self.assertEqual((row.message, row.success), ("VM vm: power on", True))
self.assertEqual((row.user, row.host, row.instance, row.instance_uuid, row.ip),
("vm-admin", "c1", "vm", UUID, "192.0.2.50"))
def test_a_raised_failure_writes_a_failed_row(self):
self.proxy.start.side_effect = libvirtError("Requested operation is not valid")
self.post("poweron")
self.assertEqual(self.rows(), [("VM vm: power on: Requested operation is not valid", False)])
def test_a_caught_failure_writes_a_failed_row(self):
self.proxy.snapshot_delete.side_effect = libvirtError("snapshot not found")
self.post("delete_snapshot", {"name": "snap1"})
self.proxy.snapshot_delete.side_effect = None
self.post("delete_snapshot", {"name": "snap1"})
self.assertEqual(self.rows(), [("VM vm: delete snapshot snap1: snapshot not found", False),
("VM vm: delete snapshot snap1", True)])
def test_refused_input_writes_nothing(self):
self.post("resizevm_cpu", {"vcpu": "x", "cur_vcpu": "1"})
self.post("set_qos", {"qos_direction": "sideways", "net-mac-0": "52:54:00:00:00:01"})
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
self.post("poweron")
self.assertEqual(self.rows(), [])
def test_qos_is_logged(self):
self.post("set_qos", {"qos_direction": "inbound", "net-mac-0": "52:54:00:00:00:01", "qos_average": "100"})
self.post("unset_qos", {"qos_direction": "inbound", "net-mac": "52:54:00:00:00:01"})
self.assertEqual(self.rows(), [("VM vm: set inbound QoS on interface 52:54:00:00:00:01", True),
("VM vm: remove inbound QoS from interface 52:54:00:00:00:01", True)])
def test_a_gstfsd_error_reply_is_a_failed_row_without_the_password(self):
self.proxy.get_status.return_value = 5
with patch("instances.views.gstfsd_request", side_effect=OperationError("gstfsd error: timed out")):
self.post("rootpasswd", {"passwd": "S3cret-pass"})
with patch("instances.views.gstfsd_request", return_value={"return": "success"}):
self.post("rootpasswd", {"passwd": "S3cret-pass"})
self.assertEqual(self.rows(), [("VM vm: reset root password: gstfsd error: timed out", False),
("VM vm: reset root password", True)])
def test_console_password_rows_never_hold_the_password(self):
self.proxy.get_console_type.return_value = "vnc"
self.proxy.console_listens_locally.return_value = True
self.proxy.set_console_passwd.return_value = False # no graphics device
form = {"listen_on": "127.0.0.1", "keymap": "auto"}
self.post("update_console", {**form, "password": "Vnc-pass-1"})
self.proxy.set_console_passwd.return_value = True
self.post("update_console", {**form, "password": "Vnc-pass-2"})
self.assertNotIn("Vnc-pass", str(self.rows()))
rows = [row for row in self.rows() if "password" in row[0]]
self.assertEqual(rows, [("VM vm: set console password: The VM has no graphics device", False),
("VM vm: set console password", True)])
def test_a_deleted_vm_and_each_of_its_disks_have_their_own_row(self):
self.proxy.split_disk_paths_by_use.return_value = (["/pool/a.qcow2", "/pool/b.qcow2"], [])
volume = self.proxy.get_volume_by_path.return_value
volume.delete.side_effect = [libvirtError("Permission denied"), None]
self.client.post(reverse("instances:destroy", args=[self.vm.pk]), {"delete_disk": "1"})
self.assertFalse(Instance.objects.filter(pk=self.vm.pk).exists())
self.assertEqual(self.rows(), [("VM vm: delete", True),
("VM vm: delete volume /pool/a.qcow2: Permission denied", False),
("VM vm: delete volume /pool/b.qcow2", True)])
self.assertEqual(set(Logs.objects.values_list("instance_uuid", flat=True)), {UUID})
def test_a_disk_delete_logs_the_detach_and_the_volume(self):
disk = {"dev": "vdb", "path": "/pool/data.qcow2", "storage": "default", "image": "data.qcow2"}
self.proxy.get_disk_devices.return_value = [disk]
self.proxy.paths_used_by_other_domains.return_value = set()
with patch("instances.views.wvmStorage"):
self.proxy.wait_disk_detached.return_value = False
self.post("delete_vol", {"dev": "vdb"})
self.proxy.wait_disk_detached.return_value = True
self.post("delete_vol", {"dev": "vdb"})
self.assertEqual(self.rows(), [
("VM vm: detach disk vdb", True),
("VM vm: delete volume default/data.qcow2: the guest has not released the disk", False),
("VM vm: detach disk vdb", True),
("VM vm: delete volume default/data.qcow2", True),
])
class DefineFromXmlRowTests(TestCase):
def post(self, xml):
conn = libvirt.open("test:///default")
self.addCleanup(conn.close)
compute = Compute.objects.create(name="testdrv", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("xml", "xml@example.com", "pw"))
Logs.objects.all().delete()
with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn):
self.client.post(reverse("instances:create_instance_select_type", args=[compute.id]),
{"create_xml": "", "dom_xml": xml})
return Logs.objects.get()
def test_a_defined_vm_has_its_uuid(self):
row = self.post("<domain type='test'><name>from-xml</name><memory>1024</memory>"
"<os><type>hvm</type></os></domain>")
self.assertEqual((row.message, row.success), ("VM from-xml: define from XML", True))
self.assertEqual(row.instance_uuid, Instance.objects.get(name="from-xml").uuid)
def test_a_refused_definition_is_a_failed_row(self):
row = self.post("<domain type='nope'><name>new</name><memory>1024</memory></domain>")
self.assertFalse(row.success)
self.assertTrue(row.message.startswith("VM new: define from XML: "))
class JobRowTests(TestCase):
def test_vm_jobs_carry_the_uuid_of_their_vm(self):
from operations.jobs import get_kind
from operations.models import Operation
new = {"compute": 1, "uuid": UUID, "name": "web-2"}
cases = [
("instance.create", "c1/web-2", {}, new, ("c1", "web-2", UUID, "VM web-2: create")),
("instance.clone", "c1/web-1", {}, new, ("c1", "web-2", UUID, "VM web-2: clone from web-1")),
("snapshot.delete", "c1/web-1", {"name": "s1"}, {"uuid": UUID},
("c1", "web-1", UUID, "VM web-1: delete external snapshot s1 (failed)")),
]
for kind, label, params, intent, entry in cases:
with self.subTest(kind=kind):
op = Operation(kind=kind, resource_label=label, params=params, intent=intent)
state = Operation.FAILED if kind == "snapshot.delete" else Operation.SUCCEEDED
self.assertEqual(get_kind(kind).log_entry(op, state), entry)

View file

@ -96,7 +96,7 @@ class JsonEndpointContentTypeTestCase(TestCase):
self.assertJson(self.client.get(url))
def test_vm_logs_is_json(self):
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.name])))
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.pk])))
class TemplateHtmlConcatenationTestCase(TestCase):

View file

@ -9,7 +9,7 @@ from django.conf import settings
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from libvirt import libvirtError
from logs.views import addlogmsg
from logs.audit import reason, write
from vrtManager.instance import wvmInstance, wvmInstances
from .models import Instance
@ -159,8 +159,14 @@ def migrate_instance(
compress=False,
):
"""Migrates instance to new_compute and logs the outcome (the mode
used, or the error). Returns the mode."""
source = instance.compute
used, or the error), then each later step that failed in a row of its
own. Returns the mode."""
source, target = instance.compute, migration_target(new_compute)
def log(action, error=None):
message = f"VM {instance.name}: {action}" + (f": {reason(error, user)}" if error is not None else "")
write(user.username, source.name, instance.name, message, error is None, uuid=instance.uuid)
conn_migrate = None
try:
conn_migrate = wvmInstances(
@ -183,11 +189,7 @@ def migrate_instance(
uri=new_compute.migration_uri,
)
except Exception as error:
msg = _("Instance migration to %(target)s failed: %(error)s") % {
"target": migration_target(new_compute),
"error": error,
}
addlogmsg(user.username, source.hostname, instance.name, msg)
log(f"migrate to {target}", error)
raise
finally:
if conn_migrate is not None:
@ -195,10 +197,7 @@ def migrate_instance(
# The VM runs on new_compute now: what follows must not stop it
# from being recorded there.
msg = _("Instance is migrated(%(mode)s) to %(target)s") % {
"mode": mode,
"target": migration_target(new_compute),
}
log(f"migrate ({mode}) to {target}")
if autostart:
conn_new = None
try:
@ -212,7 +211,7 @@ def migrate_instance(
)
conn_new.set_autostart(1)
except (libvirtError, OSError) as error:
msg = _("%(msg)s; setting its autostart there failed: %(error)s") % {"msg": msg, "error": error}
log(f"restore autostart on {target}", error)
finally:
if conn_new is not None:
conn_new.close()
@ -227,11 +226,7 @@ def migrate_instance(
)
instance.proxy.remove_nvram(nvram, conn_new)
except Exception as error: # the migration stands and is recorded
msg = _("%(msg)s; removing its NVRAM file %(path)s on the source failed: %(error)s") % {
"msg": msg,
"path": nvram,
"error": error,
}
log(f"remove NVRAM file {nvram} on the source", error)
finally:
if conn_new is not None:
conn_new.close()
@ -239,11 +234,8 @@ def migrate_instance(
try:
record_migration(instance, new_compute)
except Exception as error:
failed = _("%(msg)s, but recording it failed: %(error)s") % {"msg": msg, "error": error}
addlogmsg(user.username, source.hostname, instance.name, failed)
log(f"record the move to {target}", error)
raise
addlogmsg(user.username, source.hostname, instance.name, msg)
return mode

View file

@ -36,9 +36,8 @@ from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
VIR_DOMAIN_UNDEFINE_NVRAM,
VIR_ERR_NO_SUPPORT,
libvirtError)
from logs.audit import audit, changed_fields, failed
from logs.audit import audit, audit_vm, changed_fields, failed_vm
from logs.audit import snapshot as audit_snapshot
from logs.views import addlogmsg
from operations import claims, jobs
from operations.utils import busy_message, summary
from webvirtcloud.middleware import error_text, same_origin_referer
@ -179,19 +178,6 @@ def _instance_page(request, pk, instance, compute):
# clone_instance_auto_name = app_settings.CLONE_INSTANCE_AUTO_NAME
# try:
# instance = Instance.objects.get(compute=compute, name=vname)
# if instance.uuid != uuid:
# instance.uuid = uuid
# instance.save()
# msg = _(f"Fixing UUID {uuid}")
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
# except Instance.DoesNotExist:
# instance = Instance(compute=compute, name=vname, uuid=uuid)
# instance.save()
# msg = _("Instance does not exist: Creating new instance")
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
# userinstances = UserInstance.objects.filter(instance=instance).order_by('user__username')
userinstances = instance.userinstance_set.order_by("user__username")
# Host resources
@ -578,10 +564,8 @@ def poweron(request, pk):
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power On")
)
with audit_vm(request, instance, "power on"):
instance.proxy.start()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
@ -595,11 +579,9 @@ def powercycle(request, pk):
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
instance.proxy.force_shutdown()
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
with audit_vm(request, instance, "power cycle"):
instance.proxy.force_shutdown()
instance.proxy.start()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@ -609,10 +591,8 @@ def powercycle(request, pk):
@serialize_instance_mutation
def poweroff(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Off")
)
with audit_vm(request, instance, "power off"):
instance.proxy.shutdown()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
@ -624,8 +604,8 @@ def poweroff(request, pk):
@serialize_instance_mutation
def suspend(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.suspend()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend"))
with audit_vm(request, instance, "suspend"):
instance.proxy.suspend()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@ -636,8 +616,8 @@ def suspend(request, pk):
@serialize_instance_mutation
def resume(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.resume()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume"))
with audit_vm(request, instance, "resume"):
instance.proxy.resume()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@ -647,10 +627,8 @@ def resume(request, pk):
@serialize_instance_mutation
def force_off(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Force Off")
)
with audit_vm(request, instance, "force off"):
instance.proxy.force_shutdown()
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@ -661,37 +639,37 @@ def destroy(request, pk):
if request.method in ["POST", "DELETE"]:
instance = get_instance(request.user, pk, perm_type="delete")
with libvirt_instance_lock(instance):
proxy = instance.proxy
# Paused and other active states must stop too, or the VM keeps
# running as a transient domain after the undefine.
if proxy.instance.isActive():
proxy.force_shutdown()
with audit_vm(request, instance, "delete"):
proxy = instance.proxy
# Paused and other active states must stop too, or the VM keeps
# running as a transient domain after the undefine.
if proxy.instance.isActive():
proxy.force_shutdown()
to_delete, shared = [], []
if request.POST.get("delete_disk", ""):
to_delete, shared = proxy.split_disk_paths_by_use()
to_delete, shared = [], []
if request.POST.get("delete_disk", ""):
to_delete, shared = proxy.split_disk_paths_by_use()
# Undefine before deleting any disk: if it fails, nothing is lost.
# Snapshot metadata goes with the domain; internal snapshot data
# stays in the disk images that are kept.
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
if request.POST.get("delete_nvram", ""):
flags |= VIR_DOMAIN_UNDEFINE_NVRAM
else:
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
proxy.delete(flags)
instance.delete()
# Undefine before deleting any disk: if it fails, nothing is lost.
# Snapshot metadata goes with the domain; internal snapshot data
# stays in the disk images that are kept.
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
if request.POST.get("delete_nvram", ""):
flags |= VIR_DOMAIN_UNDEFINE_NVRAM
else:
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
proxy.delete(flags)
instance.delete()
# each disk has its own row: the VM is gone even if one is kept
for path in to_delete:
try:
proxy.get_volume_by_path(path).delete(0)
with audit_vm(request, instance, f"delete volume {path}"):
proxy.get_volume_by_path(path).delete(0)
except libvirtError as err:
messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)})
for path in shared:
messages.warning(request, _("Disk %(path)s is used by another VM and was kept") % {"path": path})
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Destroy")
)
return redirect(reverse("instances:index"))
instance = get_instance(request.user, pk, perm_type="delete")
@ -742,20 +720,24 @@ GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GST
def gstfsd_request(hostname, data):
"""Send one request to gstfsd and return its JSON reply, or an error reply."""
"""Send one request to gstfsd and return its JSON reply; an error reply or
a failed exchange raises OperationError with the reason."""
try:
# the host name may resolve to IPv4 or IPv6
s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
except OSError as err:
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
try:
s.sendall(json.dumps(data).encode())
# gstfsd sends one reply and closes the connection
return json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
except (OSError, ValueError) as err:
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
finally:
s.close()
if reply["return"] != "success":
raise util.OperationError(reply["message"])
return reply
@require_POST
@ -771,15 +753,13 @@ def set_root_pass(request, pk):
data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()}
if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data)
if result["return"] == "success":
msg = _("Reset root password")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
try:
with audit_vm(request, instance, "reset root password"):
gstfsd_request(instance.compute.hostname, data)
except util.OperationError as err:
messages.error(request, err)
else:
messages.error(request, result["message"])
messages.success(request, _("Reset root password"))
else:
msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg)
@ -808,19 +788,15 @@ def add_public_key(request, pk):
}
if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data)
if result["return"] == "error":
msg = result["message"]
try:
with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
gstfsd_request(instance.compute.hostname, data)
except util.OperationError as err:
messages.error(request, err)
else:
msg = _("Installed new SSH public key %(keyname)s") % {
messages.success(request, _("Installed new SSH public key %(keyname)s") % {
"keyname": publickey.keyname
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
if result["return"] == "success":
messages.success(request, msg)
else:
messages.error(request, msg)
})
else:
msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg)
@ -856,14 +832,12 @@ def resizevm_cpu(request, pk):
else:
cur_vcpu = new_cur_vcpu
vcpu = new_vcpu
instance.proxy.resize_cpu(cur_vcpu, vcpu)
with audit_vm(request, instance, f"set vCPUs to {cur_vcpu} (maximum {vcpu})"):
instance.proxy.resize_cpu(cur_vcpu, vcpu)
msg = _("CPU is resized: %(old)s to %(new)s") % {
"old": cur_vcpu,
"new": vcpu,
}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -902,7 +876,8 @@ def resize_memory(request, pk):
})
messages.error(request, msg)
else:
instance.proxy.resize_mem(new_cur_memory, new_memory)
with audit_vm(request, instance, f"set memory to {new_cur_memory} MiB (maximum {new_memory} MiB)"):
instance.proxy.resize_mem(new_cur_memory, new_memory)
msg = _(
"Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s"
) % {
@ -911,9 +886,6 @@ def resize_memory(request, pk):
"new_cur": new_cur_memory,
"new_max": new_memory,
}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -955,18 +927,15 @@ def resize_disk(request, pk):
else:
for disk in disks_new:
try:
instance.proxy.resize_disk([disk])
with audit_vm(request, instance, f"resize disk {disk['dev']} to {disk['size_new'] >> 30} GiB"):
instance.proxy.resize_disk([disk])
except libvirtError as err:
# an LVM pool, for one, cannot resize its volumes
if err.get_error_code() != VIR_ERR_NO_SUPPORT:
raise
messages.error(request, _("The storage pool of %(dev)s cannot resize its volumes") % {"dev": disk["dev"]})
continue
msg = _("Disk is resized: %(dev)s") % {"dev": disk["dev"]}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
messages.success(request, _("Disk is resized: %(dev)s") % {"dev": disk["dev"]})
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -990,61 +959,57 @@ def add_new_vol(request, pk):
if invalid:
return reject_disk_options(request, pk, invalid)
conn_create = wvmCreate(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
)
# a name either definition uses is taken: on an active VM a disk can be
# pending in the persistent one only
target_dev = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
with audit_vm(request, instance, f"attach new disk {storage}/{name}"):
conn_create = wvmCreate(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
)
# a name either definition uses is taken: on an active VM a disk can be
# pending in the persistent one only
target_dev = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
source = conn_create.create_volume(
storage,
name,
size,
format,
meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
)
source = conn_create.create_volume(
storage,
name,
size,
format,
meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
)
conn_pool = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
conn_pool = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source))
# the pool decides: only a dir pool keeps the asked format, the others create raw
format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source))
# the pool decides: only a dir pool keeps the asked format, the others create raw
format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
if pool_type == "rbd":
source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types
source_info = None
if pool_type == "rbd":
source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types
source_info = None
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format,
cache_mode=cache,
)
msg = _("Attach new disk: %(name)s (%(format)s)") % {
"name": name,
"format": format,
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format,
cache_mode=cache,
)
return _back(request, pk, "disks")
@ -1091,18 +1056,17 @@ def add_existing_vol(request, pk):
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format_type,
cache_mode=cache,
)
msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"attach disk {storage}/{name} as {target_dev}"):
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format_type,
cache_mode=cache,
)
return _back(request, pk, "disks")
@ -1152,19 +1116,20 @@ def edit_volume(request, pk):
if invalid:
return reject_disk_options(request, pk, invalid)
instance.proxy.edit_disk(
target_dev,
new_path,
readonly,
shareable,
new_bus,
serial,
format,
cache,
io,
discard,
zeroes,
)
with audit_vm(request, instance, f"edit disk {target_dev}"):
instance.proxy.edit_disk(
target_dev,
new_path,
readonly,
shareable,
new_bus,
serial,
format,
cache,
io,
discard,
zeroes,
)
if not instance.proxy.get_status() == 5:
messages.success(
@ -1176,8 +1141,6 @@ def edit_volume(request, pk):
)
else:
messages.success(request, _("Volume is changed successfully."))
msg = _("Edit disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@ -1221,10 +1184,12 @@ def delete_vol(request, pk):
)
return _back(request, pk, "disks")
msg = _("Delete disk: %(dev)s") % {"dev": dev}
instance.proxy.detach_disk(dev)
volume = f"{disk['storage']}/{disk['image']}"
with audit_vm(request, instance, f"detach disk {dev}"):
instance.proxy.detach_disk(dev)
# Never delete a volume the guest may still be writing to.
if not instance.proxy.wait_disk_detached(dev):
failed_vm(request, instance, f"delete volume {volume}", "the guest has not released the disk")
messages.warning(
request,
_("The guest has not released disk %(dev)s yet; volume %(vol)s was kept. "
@ -1233,14 +1198,13 @@ def delete_vol(request, pk):
)
return _back(request, pk, "disks")
try:
conn_delete.del_volume(disk["image"])
with audit_vm(request, instance, f"delete volume {volume}"):
conn_delete.del_volume(disk["image"])
except util.OperationError as err: # a file of the VM's own snapshots
messages.warning(
request,
_("Disk %(dev)s was detached, but its volume was kept: %(err)s") % {"dev": dev, "err": err},
)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@ -1250,9 +1214,8 @@ def delete_vol(request, pk):
def detach_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("dev", "")
instance.proxy.detach_disk(dev)
msg = _("Detach disk: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"detach disk {dev}"):
instance.proxy.detach_disk(dev)
return _back(request, pk, "disks")
@ -1270,16 +1233,15 @@ def add_cdrom(request, pk):
target = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
instance.proxy.attach_disk(
target,
"",
disk_device="cdrom",
cache_mode="none",
target_bus=bus,
readonly=True,
)
msg = _("Add CD-ROM: %(target)s") % {"target": target}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"add CD-ROM {target}"):
instance.proxy.attach_disk(
target,
"",
disk_device="cdrom",
cache_mode="none",
target_bus=bus,
readonly=True,
)
return _back(request, pk, "disks")
@ -1289,10 +1251,8 @@ def add_cdrom(request, pk):
@serialize_instance_mutation
def detach_cdrom(request, pk, dev):
instance = get_instance(request.user, pk, perm_type="change")
# dev = request.POST.get('detach_cdrom', '')
instance.proxy.detach_disk(dev)
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"remove CD-ROM {dev}"):
instance.proxy.detach_disk(dev)
return _back(request, pk, "disks")
@ -1304,12 +1264,10 @@ def unmount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("umount_iso", "")
try:
instance.proxy.umount_iso(dev)
with audit_vm(request, instance, f"eject media from {dev}"):
instance.proxy.umount_iso(dev)
except libvirtError as err:
messages.error(request, err)
else:
msg = _("Unmount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
@ -1321,12 +1279,10 @@ def mount_iso(request, pk):
image = request.POST.get("media", "")
dev = request.POST.get("mount_iso", "")
try:
instance.proxy.mount_iso(dev, image)
with audit_vm(request, instance, f"insert media {image} into {dev}"):
instance.proxy.mount_iso(dev, image)
except libvirtError as err:
messages.error(request, err)
else:
msg = _("Mount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
@ -1345,12 +1301,10 @@ def delete_snapshot(request, pk):
raise PermissionDenied
snap_name = request.POST.get("name", "")
try:
instance.proxy.snapshot_delete(snap_name)
with audit_vm(request, instance, f"delete snapshot {snap_name}"):
instance.proxy.snapshot_delete(snap_name)
except libvirtError as err:
messages.error(request, _("Snapshot was not deleted: %(err)s") % {"err": error_text(request, err)})
return _back(request, pk, "managesnapshot")
msg = _("Delete snapshot: %(snap)s") % {"snap": snap_name}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "managesnapshot")
@ -1406,13 +1360,9 @@ def delete_external_snapshot(request, pk):
def set_vcpu(request, pk):
instance = get_instance(request.user, pk)
id = request.POST.get("id", "")
enabled = request.POST.get("set_vcpu", "")
if enabled == "True":
instance.proxy.set_vcpu(id, 1)
else:
instance.proxy.set_vcpu(id, 0)
msg = _("VCPU %(id)s is enabled=%(enabled)s") % {"id": id, "enabled": enabled}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
enabled = request.POST.get("set_vcpu", "") == "True"
with audit_vm(request, instance, f"{'enable' if enabled else 'disable'} vCPU {id}"):
instance.proxy.set_vcpu(id, 1 if enabled else 0)
return _back(request, pk, "resize")
@ -1422,9 +1372,8 @@ def set_vcpu(request, pk):
def set_vcpu_hotplug(request, pk):
instance = get_instance(request.user, pk)
status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False
msg = _("VCPU Hot-plug is enabled=%(status)s") % {"status": status}
instance.proxy.set_vcpu_hotplug(status)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"{'enable' if status else 'disable'} vCPU hot-plug"):
instance.proxy.set_vcpu_hotplug(status)
return _back(request, pk, "resize")
@ -1433,9 +1382,8 @@ def set_vcpu_hotplug(request, pk):
@serialize_instance_mutation
def set_autostart(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_autostart(1)
msg = _("Set autostart")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "enable autostart"):
instance.proxy.set_autostart(1)
return _back(request, pk, "boot_opt")
@ -1444,9 +1392,8 @@ def set_autostart(request, pk):
@serialize_instance_mutation
def unset_autostart(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_autostart(0)
msg = _("Unset autostart")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "disable autostart"):
instance.proxy.set_autostart(0)
return _back(request, pk, "boot_opt")
@ -1455,9 +1402,8 @@ def unset_autostart(request, pk):
@serialize_instance_mutation
def set_bootmenu(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(1)
msg = _("Enable boot menu")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "enable boot menu"):
instance.proxy.set_bootmenu(1)
return _back(request, pk, "boot_opt")
@ -1466,9 +1412,8 @@ def set_bootmenu(request, pk):
@serialize_instance_mutation
def unset_bootmenu(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(0)
msg = _("Disable boot menu")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "disable boot menu"):
instance.proxy.set_bootmenu(0)
return _back(request, pk, "boot_opt")
@ -1484,11 +1429,11 @@ def set_bootorder(request, pk):
dev_type, dev = val.split(":", 1)
order_list[idx] = {"type": dev_type, "dev": dev}
try:
instance.proxy.set_bootorder(order_list)
with audit_vm(request, instance, "change boot order"):
instance.proxy.set_bootorder(order_list)
except util.OperationError as err:
messages.error(request, err)
return _back(request, pk, "boot_opt")
msg = _("Set boot order")
if not instance.proxy.get_status() == 5:
messages.success(
@ -1500,7 +1445,6 @@ def set_bootorder(request, pk):
)
else:
messages.success(request, _("Boot order changed successfully."))
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@ -1515,9 +1459,8 @@ def change_xml(request, pk):
if new_xml and instance.proxy.get_status() != 5:
messages.error(request, _("Power off the instance to change its XML"))
elif new_xml:
instance.proxy._defineXML(new_xml)
msg = _("Change instance XML")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "change XML"):
instance.proxy._defineXML(new_xml)
return _back(request, pk, "xmledit")
@ -1528,12 +1471,11 @@ def set_guest_agent(request, pk):
instance = get_instance(request.user, pk)
status = request.POST.get("guest_agent")
if status == "True":
instance.proxy.add_guest_agent()
with audit_vm(request, instance, "add guest agent channel"):
instance.proxy.add_guest_agent()
if status == "False":
instance.proxy.remove_guest_agent()
msg = _("Set Guest Agent: %(status)s") % {"status": status}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "remove guest agent channel"):
instance.proxy.remove_guest_agent()
return _back(request, pk, "options")
@ -1543,9 +1485,8 @@ def set_guest_agent(request, pk):
def set_video_model(request, pk):
instance = get_instance(request.user, pk)
video_model = request.POST.get("video_model", "vga")
instance.proxy.set_video_model(video_model)
msg = _("Set Video Model: %(model)s") % {"model": video_model}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"set video model {video_model}"):
instance.proxy.set_video_model(video_model)
return _back(request, pk, "options")
@ -1571,31 +1512,29 @@ def change_network(request, pk):
return redirect(back)
(source, source_type) = utils.get_network_tuple(request.POST.get(f"net-source-{num}", ""))
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
try:
instance.proxy.change_network(
old_mac,
mac,
source,
source_type,
request.POST.get(f"net-model-{num}"),
request.POST.get(f"net-nwfilter-{num}", ""),
)
with audit_vm(request, instance, f"change interface {old_mac}"):
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
instance.proxy.change_network(
old_mac,
mac,
source,
source_type,
request.POST.get(f"net-model-{num}"),
request.POST.get(f"net-nwfilter-{num}", ""),
)
except libvirtError as err:
messages.error(request, err)
return redirect(back)
msg = _("Change network: %(mac)s") % {"mac": old_mac}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
msg = _("Network Device Config is changed. Please shutdown instance to activate.")
if instance.proxy.get_status() != 5:
messages.success(request, msg)
@ -1617,19 +1556,17 @@ def add_network(request, pk):
(source, source_type) = utils.get_network_tuple(network)
model = request.POST.get("add-net-model")
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter)
msg = _("Add network: %(mac)s") % {"mac": mac}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"add interface on {source}"):
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter)
return _back(request, pk, "network")
@ -1640,9 +1577,8 @@ def delete_network(request, pk):
instance = get_instance(request.user, pk)
mac_address = request.POST.get("delete_network", "")
instance.proxy.delete_network(mac_address)
msg = _("Delete Network: %(mac)s") % {"mac": mac_address}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"remove interface {mac_address}"):
instance.proxy.delete_network(mac_address)
return _back(request, pk, "network")
@ -1655,9 +1591,8 @@ def set_link_state(request, pk):
mac_address = request.POST.get("mac", "")
state = request.POST.get("set_link_state")
state = "down" if state == "up" else "up"
instance.proxy.set_link_state(mac_address, state)
msg = _("Set Link State: %(state)s") % {"state": state}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, f"set link {state} on interface {mac_address}"):
instance.proxy.set_link_state(mac_address, state)
return _back(request, pk, "network")
@ -1680,7 +1615,8 @@ def set_qos(request, pk):
messages.error(request, _("QoS rates must be whole numbers"))
return _back(request, pk, "network")
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
with audit_vm(request, instance, f"set {qos_dir} QoS on interface {mac}"):
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
if instance.proxy.get_status() == 5:
messages.success(
request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()}
@ -1705,7 +1641,8 @@ def unset_qos(request, pk):
if not mac or qos_dir not in ("inbound", "outbound"):
messages.error(request, _("Select a network interface and a QoS direction"))
return _back(request, pk, "network")
instance.proxy.unset_qos(mac, qos_dir)
with audit_vm(request, instance, f"remove {qos_dir} QoS from interface {mac}"):
instance.proxy.unset_qos(mac, qos_dir)
if instance.proxy.get_status() == 5:
messages.success(
@ -1734,14 +1671,14 @@ def add_owner(request, pk):
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
check_inst = UserInstance.objects.filter(instance=instance).count()
subject = f"VM {instance.name} owner {user.username}"
action = f"add owner {user.username}"
if check_inst > 0:
failed(request, instance.compute, subject, "add", "only one owner is allowed", instance=instance.name)
failed_vm(request, instance, action, "only one owner is allowed")
messages.error(
request, _("Only one owner is allowed and the one already added")
)
else:
with audit(request, instance.compute, subject, "add", instance=instance.name) as entry:
with audit_vm(request, instance, action) as entry:
entry.skip = not UserInstance.objects.get_or_create(instance=instance, user=user)[1]
if entry.skip:
messages.warning(request, _("%(user)s is already an owner") % {"user": user})
@ -1756,8 +1693,7 @@ def del_owner(request, pk):
if not userinstance_id.isdigit():
raise Http404
userinstance = get_object_or_404(UserInstance, pk=userinstance_id, instance=instance)
with audit(request, instance.compute, f"VM {instance.name} owner {userinstance.user.username}", "remove",
instance=instance.name):
with audit_vm(request, instance, f"remove owner {userinstance.user.username}"):
userinstance.delete()
return _back(request, pk, "users")
@ -1803,8 +1739,8 @@ def update_console(request, pk):
raise PermissionDenied
form = ConsoleForm(request.POST or None, can_set_listener=request.user.is_superuser)
if form.is_valid() and form.cleaned_data["console_type"] not in ("", instance.console_type):
instance.proxy.set_console_type(form.cleaned_data["console_type"])
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Set console type"))
with audit_vm(request, instance, f"set console type {form.cleaned_data['console_type']}"):
instance.proxy.set_console_type(form.cleaned_data["console_type"])
# the other fields are VNC settings; a new VNC console gets them too
if form.is_valid() and form.cleaned_data["console_type"] != "pty":
if (
@ -1831,35 +1767,26 @@ def update_console(request, pk):
request,
_("The console listens beyond localhost; only an administrator can remove its password."),
)
elif not instance.proxy.set_console_passwd(password):
msg = _(
"Error setting console password. "
+ "You should check that your instance have an graphic device."
)
messages.error(request, msg)
else:
msg = _("Set VNC password")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
try:
with audit_vm(request, instance, "set console password" if password else "remove console password"):
if not instance.proxy.set_console_passwd(password):
raise util.OperationError("The VM has no graphics device")
except util.OperationError:
msg = _(
"Error setting console password. "
+ "You should check that your instance have an graphic device."
)
messages.error(request, msg)
if "keymap" in form.changed_data or "clear_keymap" in form.changed_data:
if form.cleaned_data["clear_keymap"]:
instance.proxy.set_console_keymap("")
else:
instance.proxy.set_console_keymap(form.cleaned_data["keymap"])
msg = _("Set VNC keymap")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
keymap = "" if form.cleaned_data["clear_keymap"] else form.cleaned_data["keymap"]
with audit_vm(request, instance, f"set console keymap {keymap}" if keymap else "remove console keymap"):
instance.proxy.set_console_keymap(keymap)
if "listen_on" in form.changed_data:
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
msg = _("Set VNC listen address")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
with audit_vm(request, instance, f"set console listen address {form.cleaned_data['listen_on']}"):
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
if not form.is_valid():
messages.error(request, _("Console settings were not saved: %(errors)s") % {"errors": form.errors.as_text()})
@ -1901,10 +1828,8 @@ def change_options(request, pk):
for post in request.POST:
if post in ["title", "description"]:
options[post] = request.POST.get(post, "")
instance.proxy.set_options(options)
msg = _("Edit options")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
with audit_vm(request, instance, "edit options"):
instance.proxy.set_options(options)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#options"
)
@ -1915,8 +1840,6 @@ def getvvfile(request, pk):
# The .vv file contains the VNC password: same rule as the console.
if not utils.can_open_console(request.user, instance):
raise PermissionDenied
msg = _("Send console.vv file")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
response = HttpResponse(
content="",
content_type="application/x-virt-viewer",
@ -1925,7 +1848,7 @@ def getvvfile(request, pk):
charset="utf-8",
)
# the VM found by its UUID; the stored name may be stale
with instance.proxy.cached_reads() as proxy:
with audit_vm(request, instance, "download console file"), instance.proxy.cached_reads() as proxy:
listen = proxy.get_console_listener_addr()
response.writelines("[virt-viewer]\n")
response.writelines(f"type={proxy.get_console_type()}\n")
@ -1982,13 +1905,17 @@ def create_instance_select_type(request, compute_id):
xml = request.POST.get("dom_xml", "")
try:
name = util.get_xml_path(xml, "/domain/name")
if name in instances:
if not name:
xml_error = _("The XML has no name")
elif name in instances:
xml_error = _("A virtual machine with this name already exists")
else:
with libvirt_compute_lock(compute):
conn._defineXML(xml)
utils.refr(compute)
instance = compute.instance_set.get(name=name)
with audit(request, compute, f"VM {name}", "define from XML", instance=name) as entry:
conn._defineXML(xml)
utils.refr(compute)
instance = compute.instance_set.get(name=name)
entry.uuid = instance.uuid
return redirect(reverse("instances:instance", args=[instance.id]))
except util.etree.Error as err:
xml_error = _("The XML is not well-formed: %(error)s") % {"error": err}

View file

@ -12,18 +12,19 @@ from logs.models import Logs
logger = logging.getLogger("logs.audit")
def write(user, host, instance, message, success=None, ip=None):
def write(user, host, instance, message, success=None, ip=None, uuid=""):
"""
Save one Logs row. A failure is written to the log file instead and never
raised, so an audit write cannot break the action it records.
"""
user, host, instance = user[:150], host[:255], instance[:120]
user, host, instance, uuid = user[:150], host[:255], instance[:120], uuid[:36]
try:
with transaction.atomic():
Logs.objects.create(user=user, host=host, instance=instance, message=message, success=success, ip=ip)
Logs.objects.create(user=user, host=host, instance=instance, instance_uuid=uuid, message=message,
success=success, ip=ip)
except Exception as e:
try:
line = f"audit row not saved ({e!r}): {user} {host} {instance} success={success} ip={ip} {message}"
line = f"audit row not saved ({e!r}): {user} {host} {instance} {uuid} success={success} ip={ip} {message}"
logger.warning(line.replace("\r", "\\r").replace("\n", "\\n"))
except Exception:
pass
@ -44,6 +45,7 @@ def request_ip(request):
class Entry:
action: str
skip: bool = False
uuid: str = ""
def host_of(compute):
@ -69,26 +71,37 @@ def reason(exc, user):
@contextmanager
def audit(request, compute, subject, action, instance=""):
def audit(request, compute, subject, action, instance="", uuid=""):
"""
Write one row when the block ends: done, or failed with the reason when it
raises (the exception goes on). The block may set entry.action, or
entry.skip when nothing was done.
raises (the exception goes on). The block may set entry.action, entry.uuid,
or entry.skip when nothing was done.
"""
entry = Entry(action)
entry = Entry(action, uuid=uuid)
user, host, ip = request.user.get_username(), host_of(compute), request_ip(request)
try:
yield entry
except Exception as exc:
write(user, host, instance, f"{subject}: {entry.action}: {reason(exc, request.user)}", False, ip)
write(user, host, instance, f"{subject}: {entry.action}: {reason(exc, request.user)}", False, ip, entry.uuid)
raise
if not entry.skip:
write(user, host, instance, f"{subject}: {entry.action}", True, ip)
write(user, host, instance, f"{subject}: {entry.action}", True, ip, entry.uuid)
def failed(request, compute, subject, action, why, instance=""):
def failed(request, compute, subject, action, why, instance="", uuid=""):
"""Write the failure row of an action that was refused before it started."""
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False, request_ip(request))
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False,
request_ip(request), uuid)
def audit_vm(request, instance, action):
"""audit() for an operation on a VM: the row names the VM and carries its UUID."""
return audit(request, instance.compute, f"VM {instance.name}", action, instance=instance.name, uuid=instance.uuid)
def failed_vm(request, instance, action, why):
"""failed() for an operation on a VM, such as one whose error came back as a reply."""
failed(request, instance.compute, f"VM {instance.name}", action, why, instance=instance.name, uuid=instance.uuid)
def snapshot(obj):

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.18 on 2026-10-24 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('logs', '0004_audit_fields'),
]
operations = [
migrations.AddField(
model_name='logs',
name='instance_uuid',
field=models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='instance UUID'),
),
]

View file

@ -5,6 +5,7 @@ class Logs(Model):
user = CharField(_("user"), max_length=150)
host = CharField(_("host"), max_length=255, default="-")
instance = CharField(_("instance"), max_length=120)
instance_uuid = CharField(_("instance UUID"), max_length=36, blank=True, default="", db_index=True)
message = TextField(_("message"))
date = DateTimeField(_("date"), auto_now_add=True, db_index=True)
success = BooleanField(_("success"), null=True)

View file

@ -8,9 +8,9 @@ from django.db import IntegrityError, transaction
from django.test import RequestFactory, TestCase
from computes.models import Compute
from logs.audit import audit, changed_fields, failed, request_ip, snapshot, write
from instances.models import Instance
from logs.audit import audit, audit_vm, changed_fields, failed, failed_vm, request_ip, snapshot, write
from logs.models import Logs
from logs.views import addlogmsg
from operations import claims
from vrtManager.util import OperationError
@ -62,12 +62,9 @@ class WriteTests(TestCase):
self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR="127.0.0.1", HTTP_X_REAL_IP="fe80::1%eth0")))
self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR="")))
def test_addlogmsg_still_writes(self):
addlogmsg("u", "h", "vm", "Destroy")
row = Logs.objects.get()
self.assertEqual((row.user, row.host, row.instance, row.message), ("u", "h", "vm", "Destroy"))
self.assertIsNone(row.success)
self.assertIsNone(row.ip)
def test_a_row_without_a_vm_has_no_uuid(self):
write("u", "h", "", "Network lab: start", True)
self.assertEqual(Logs.objects.get().instance_uuid, "")
class AuditTests(TestCase):
@ -135,6 +132,26 @@ class AuditTests(TestCase):
self.assertEqual(row.host, "-")
self.assertIs(row.success, False)
def test_vm_rows_name_the_vm_and_carry_its_uuid(self):
vm = Instance(compute=self.compute, name="web-1", uuid="11111111-2222-3333-4444-555555555555")
with audit_vm(self.request, vm, "power on"):
pass
with self.assertRaises(OperationError):
with audit_vm(self.request, vm, "power off"):
raise OperationError("not running")
failed_vm(self.request, vm, "reset root password", "gstfsd error: timed out")
self.assertEqual(
list(Logs.objects.order_by("id").values_list("host", "instance", "instance_uuid", "message", "success")), [
("c", "web-1", vm.uuid, "VM web-1: power on", True),
("c", "web-1", vm.uuid, "VM web-1: power off: not running", False),
("c", "web-1", vm.uuid, "VM web-1: reset root password: gstfsd error: timed out", False),
])
def test_the_block_may_set_the_uuid_it_learns(self):
with audit(self.request, self.compute, "VM web-1", "define from XML", instance="web-1") as entry:
entry.uuid = "11111111-2222-3333-4444-555555555555"
self.assertEqual(self.row().instance_uuid, "11111111-2222-3333-4444-555555555555")
def test_changed_fields(self):
compute = Compute.objects.create(name="c", hostname="192.0.2.5", type=1, password="pw")
before = snapshot(compute)

View file

@ -1,61 +1,60 @@
import json
from django.contrib.auth import get_user_model
from django.test import Client, RequestFactory, TestCase
from django.test import TestCase
from django.urls import reverse
from computes.models import Compute
from instances.models import Instance
from logs.models import Logs
from logs.views import addlogmsg
from logs.audit import write
UUID = "12345678-1234-1234-1234-123456789012"
class LogsTestCase(TestCase):
class VMLogsTestCase(TestCase):
"""The VM page's Logs tab: the rows of one VM, found by its UUID."""
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="admin_logs", email="admin@example.com", password="adminpassword"
)
self.normal_user = User.objects.create_user(
username="normal_logs", email="user@example.com", password="userpassword"
)
self.compute = Compute.objects.create(
name="local_compute",
hostname="localhost",
type=1,
)
self.instance = Instance.objects.create(
compute=self.compute,
name="test-vm",
uuid="12345678-1234-1234-1234-123456789012",
)
self.superuser = User.objects.create_superuser("admin_logs", "admin@example.com", "pw")
self.normal_user = User.objects.create_user("normal_logs", "user@example.com", "pw")
self.compute = Compute.objects.create(name="c1", hostname="192.0.2.1", type=1)
self.instance = Instance.objects.create(compute=self.compute, name="test-vm", uuid=UUID)
self.client.force_login(self.superuser)
def test_addlogmsg(self):
addlogmsg("admin", "localhost", "test-vm", "Started VM")
log = Logs.objects.filter(instance="test-vm").first()
self.assertIsNotNone(log)
self.assertEqual(log.user, "admin")
self.assertEqual(log.host, "localhost")
self.assertEqual(log.message, "Started VM")
def test_vm_logs_superuser_access(self):
addlogmsg("admin", "localhost", "test-vm", "Created VM")
client = Client()
client.login(request=RequestFactory().get("/"), username="admin_logs", password="adminpassword")
response = client.get(reverse("vm_logs", args=["test-vm"]))
def shown(self, instance=None):
response = self.client.get(reverse("vm_logs", args=[(instance or self.instance).pk]))
self.assertEqual(response.status_code, 200)
return [(row["message"], row["result"]) for row in response.json()]
data = json.loads(response.content.decode("utf-8"))
self.assertIsInstance(data, list)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["user"], "admin")
self.assertEqual(data[0]["message"], "Created VM")
self.assertEqual(data[0]["instance"], "test-vm")
def test_rows_of_the_vm_with_their_result(self):
write("admin", "c1", "test-vm", "VM test-vm: power on", True, uuid=UUID)
write("admin", "c1", "test-vm", "VM test-vm: power off: not running", False, uuid=UUID)
row = self.client.get(reverse("vm_logs", args=[self.instance.pk])).json()[0]
self.assertEqual((row["user"], row["host"], row["instance"]), ("admin", "c1", "test-vm"))
self.assertEqual(self.shown(), [("VM test-vm: power off: not running", "Failed"),
("VM test-vm: power on", "Done")])
def test_vm_logs_non_superuser_forbidden(self):
client = Client()
client.login(request=RequestFactory().get("/"), username="normal_logs", password="userpassword")
def test_a_vm_is_found_by_uuid_not_by_name(self):
other = Compute.objects.create(name="c2", hostname="192.0.2.2", type=1)
namesake = Instance.objects.create(compute=other, name="test-vm", uuid="87654321-4321-4321-4321-210987654321")
write("admin", "c1", "old-name", "VM old-name: power on", True, uuid=UUID) # before a rename
write("admin", "c2", "test-vm", "VM test-vm: power on", True, uuid=namesake.uuid)
write("admin", "c1", "test-vm", "Instance is created") # a row from before the UUID column
self.assertEqual(self.shown(), [("VM old-name: power on", "Done")])
self.assertEqual(self.shown(namesake), [("VM test-vm: power on", "Done")])
response = client.get(reverse("vm_logs", args=["test-vm"]))
# superuser_only decorator redirects non-superusers to index or login
def test_one_vm_on_two_computes_shows_the_rows_of_both(self):
other = Compute.objects.create(name="c2", hostname="192.0.2.2", type=1)
moved = Instance.objects.create(compute=other, name="test-vm", uuid=UUID)
write("admin", "c1", "test-vm", "VM test-vm: migrate (live) to 192.0.2.2", True, uuid=UUID)
write("admin", "c2", "test-vm", "VM test-vm: power off", None, uuid=UUID)
self.assertEqual(self.shown(moved), [("VM test-vm: power off", "Unknown"),
("VM test-vm: migrate (live) to 192.0.2.2", "Done")])
self.assertEqual(self.shown(moved), self.shown())
def test_unknown_vm(self):
self.assertEqual(self.client.get(reverse("vm_logs", args=[999999])).status_code, 404)
def test_non_superuser_forbidden(self):
self.client.force_login(self.normal_user)
response = self.client.get(reverse("vm_logs", args=[self.instance.pk]))
self.assertNotEqual(response.status_code, 200)

View file

@ -3,5 +3,5 @@ from django.urls import path
from . import views
urlpatterns = [
path("vm_logs/<vname>/", views.vm_logs, name="vm_logs"),
path("vm_logs/<int:pk>/", views.vm_logs, name="vm_logs"),
]

View file

@ -1,44 +1,25 @@
import logging
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from admin.decorators import superuser_only
from instances.models import Instance
from logs.audit import write
from logs.models import Logs
def addlogmsg(user, host, instance, message):
"""
:param user:
:param host:
:param instance:
:param message:
:return:
"""
write(user, host, instance, str(message))
logger = logging.getLogger("")
logger.info(f"{user} {host} {instance} {message}")
@superuser_only
def vm_logs(request, vname):
"""
:param request:
:param vname:
:return:
"""
vm = Instance.objects.get(name=vname)
logs_ = Logs.objects.filter(instance=vm.name, date__gte=vm.created).order_by("-date")
def vm_logs(request, pk):
"""The rows of a VM, found by its UUID: a rename keeps them, and a VM on two computes is one VM."""
vm = get_object_or_404(Instance, pk=pk)
results = {None: _("Unknown"), True: _("Done"), False: _("Failed")}
logs = []
for l in logs_:
log = dict()
log["user"] = l.user
log["host"] = l.host
log["instance"] = l.instance
log["message"] = l.message
log["date"] = l.date.strftime("%x %X")
logs.append(log)
for log in Logs.objects.filter(instance_uuid=vm.uuid).order_by("-date", "-id"):
logs.append({
"user": log.user,
"host": log.host,
"instance": log.instance,
"message": log.message,
"result": results[log.success],
"date": log.date.strftime("%x %X"),
})
return JsonResponse(logs, safe=False)

View file

@ -106,9 +106,9 @@ class Kind:
the kind decides whether the job is idle and ends it if so."""
def log_entry(self, op, state):
"""(host, instance, message) of the Logs row written with each end of
the job (in the same transaction); its host and instance also go on
the row of a resolution. None: no row."""
"""(host, instance, instance UUID, message) of the Logs row written
with each end of the job (in the same transaction); its host, instance
and UUID also go on the row of a resolution. None: no row."""
return None
@ -219,8 +219,8 @@ def _entry(op, state):
return None
def _log(user, host, instance, message, success):
write(str(user), str(host), str(instance), utils.safe_text(message), success)
def _log(user, host, instance, uuid, message, success):
write(str(user), str(host), str(instance), utils.safe_text(message), success, uuid=str(uuid))
def finish(op, outcome, *, worker=None):
@ -262,10 +262,10 @@ def resolve(op_id, user, outcome_state, note):
Operation.objects.filter(pk=op.pk).update(state=outcome_state, resolution=outcome_state,
resolution_note=note, resolved_by=user, resolved_at=timezone.now())
Claim.objects.filter(operation=op).delete()
# the host and VM columns of the kind's own entries, so the VM's log shows it
# the host, VM and UUID columns of the kind's own entries, so the VM's log shows it
entry = _entry(op, outcome_state)
host, instance = entry[:2] if entry is not None else ("-", op.resource_label)
_log(user.username, host, instance, f"Job {op.pk} resolved as {outcome_state}: {note}",
host, instance, uuid = entry[:3] if entry is not None else ("-", op.resource_label, "")
_log(user.username, host, instance, uuid, f"Job {op.pk} resolved as {outcome_state}: {note}",
outcome_state == Operation.SUCCEEDED)
op.refresh_from_db()
return op

View file

@ -68,7 +68,7 @@ class NoopKind(Kind):
return Outcome(Operation.SUCCEEDED, result={"recovered": True})
def log_entry(self, op, state):
return ("c", "vm", f"No-op {state}")
return ("c", "vm", op.params["uuid"], f"No-op {state}")
@register

View file

@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
def _log(op, state, action):
compute, _sep, pool = op.resource_label.rpartition("/") # a pool name has no "/"
message = f"Storage pool {pool}: {action}"
return compute, "", message if state == Operation.SUCCEEDED else f"{message} ({state})"
return compute, "", "", message if state == Operation.SUCCEEDED else f"{message} ({state})"
def _storage(params):