1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +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(url, {"is_vnc": "on"}) # no change
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk])) self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
self.assertEqual(rows(), [ self.assertEqual(rows(), [
("VM vm owner bob: add", True), ("VM vm: add owner bob", True),
("VM vm owner bob: change is_vnc", True), ("VM vm: change owner bob: is_vnc", True),
("VM vm owner bob: remove", 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): def test_otp_mail_without_an_email(self):
from smtplib import SMTPException 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 accounts.apps import admin_password_path
from instances.models import Instance 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.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import User, UserAttributes, UserInstance, UserSSHKey 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}) form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
if form.is_valid(): if form.is_valid():
instance = form.cleaned_data["instance"] instance = form.cleaned_data["instance"]
subject = f"VM {instance.name} owner {form.cleaned_data['user'].username}" with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}"):
with audit(request, instance.compute, subject, "add", instance=instance.name):
form.save() form.save()
return redirect(reverse("accounts:account", args=[user.id])) 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) instance, before = user_instance.instance, snapshot(user_instance)
form = forms.UserInstanceForm(request.POST or None, instance=user_instance) form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
if form.is_valid(): if form.is_valid():
subject = f"VM {instance.name} owner {user_instance.user.username}" with audit_vm(request, instance, "change owner") as entry:
with audit(request, instance.compute, subject, "change", instance=instance.name) as entry:
form.save() form.save()
fields = changed_fields(before, snapshot(user_instance)) 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 entry.skip = not fields
return redirect(reverse("accounts:account", args=[user_instance.user.id])) 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) user_instance = get_object_or_404(UserInstance, pk=pk)
if request.method == "POST": if request.method == "POST":
user, instance = user_instance.user, user_instance.instance user, instance = user_instance.user, user_instance.instance
with audit(request, instance.compute, f"VM {instance.name} owner {user.username}", "remove", with audit_vm(request, instance, f"remove owner {user.username}"):
instance=instance.name):
user_instance.delete() user_instance.delete()
next_url = request.GET.get("next", None) next_url = request.GET.get("next", None)
if next_url and url_has_allowed_host_and_scheme( if next_url and url_has_allowed_host_and_scheme(

View file

@ -60,7 +60,7 @@ def lock_ownership():
def _log(inst, what): 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): def _move_ownership(source, target):

View file

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

View file

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

View file

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

View file

@ -289,6 +289,9 @@ class XmlRefusalTests(TestCase):
def test_a_taken_name(self): def test_a_taken_name(self):
self.assertIn("already exists", self.post("<domain type='test'><name>test</name></domain>")) 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): def test_malformed_xml(self):
self.assertIn("not well-formed", self.post("<domain type='test'><name>new</name>")) 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): def test_a_failed_autostart_does_not_stop_the_records(self):
self.migrate(autostart=True, autostart_error=util.OperationError("no autostart")) self.migrate(autostart=True, autostart_error=util.OperationError("no autostart"))
self.assertEqual(Instance.objects.get(id=self.vm.id).compute, self.dest) self.assertEqual(Instance.objects.get(id=self.vm.id).compute, self.dest)
self.assertEqual( self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
Logs.objects.get().message, ("VM vm: migrate (live) to 198.51.100.9", True),
"Instance is migrated(live) to 198.51.100.9; setting its autostart there failed: no autostart", ("VM vm: restore autostart on 198.51.100.9: no autostart", False),
) ])
def test_a_failed_record_is_logged_as_such(self): def test_a_failed_record_is_logged_as_such(self):
with patch.object(Instance, "save", side_effect=DatabaseError("locked")), \ with patch.object(Instance, "save", side_effect=DatabaseError("locked")), \
self.assertRaises(DatabaseError): self.assertRaises(DatabaseError):
self.migrate() self.migrate()
self.assertEqual( self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
Logs.objects.get().message, ("VM vm: migrate (live) to 198.51.100.9", True),
"Instance is migrated(live) to 198.51.100.9, but recording it failed: locked", ("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): def test_a_record_the_destination_already_has_keeps_template_and_owners(self):
owner = get_user_model().objects.create_user("mb-owner") 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 """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 from unittest.mock import MagicMock, patch
@ -14,6 +15,10 @@ from instances.utils import migrate_instance
from vrtManager import util from vrtManager import util
def rows():
return list(Logs.objects.order_by("id").values_list("message", "success"))
class MigrationLogTests(TestCase): class MigrationLogTests(TestCase):
def setUp(self): def setUp(self):
self.admin = get_user_model().objects.create_superuser("ml-admin", "ml@example.com", "pw") 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] path, destination = self.proxy.remove_nvram.call_args[0]
self.assertEqual(path, "/var/lib/libvirt/qemu/nvram/vm_VARS.fd") self.assertEqual(path, "/var/lib/libvirt/qemu/nvram/vm_VARS.fd")
self.assertIsNotNone(destination) 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): def test_a_vm_without_nvram_removes_nothing(self):
self.migrate("live", live=True) 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): 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", self.migrate("live", nvram="/var/lib/libvirt/qemu/nvram/vm_VARS.fd",
remove_nvram=libvirtError("cannot delete"), live=True) remove_nvram=libvirtError("cannot delete"), live=True)
self.assertEqual( self.assertEqual(rows(), [
Logs.objects.get().message, ("VM vm: migrate (live) to 198.51.100.9", True),
"Instance is migrated(live) to 198.51.100.9; removing its NVRAM file " ("VM vm: remove NVRAM file /var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source: cannot delete", False),
"/var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source failed: cannot delete", ])
)
self.vm.refresh_from_db() self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest) self.assertEqual(self.vm.compute, self.dest)
@ -67,7 +71,9 @@ class MigrationLogTests(TestCase):
remove_nvram=AttributeError("unexpected"), live=True) remove_nvram=AttributeError("unexpected"), live=True)
self.vm.refresh_from_db() self.vm.refresh_from_db()
self.assertEqual(self.vm.compute, self.dest) 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): def test_a_failed_migration_removes_nothing(self):
with self.assertRaises(util.OperationError): with self.assertRaises(util.OperationError):
@ -77,23 +83,20 @@ class MigrationLogTests(TestCase):
def test_success_names_the_mode_used(self): def test_success_names_the_mode_used(self):
self.migrate("non-live, compressed", live=False, compress=True) self.migrate("non-live, compressed", live=False, compress=True)
log = Logs.objects.get() log = Logs.objects.get()
self.assertEqual(log.message, "Instance is migrated(non-live, compressed) to 198.51.100.9") self.assertEqual(log.message, "VM vm: migrate (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.user, log.host, log.instance, log.instance_uuid), ("ml-admin", "ml-src", "vm", "u-ml"))
def test_success_names_the_migration_address(self): def test_success_names_the_migration_address(self):
self.dest.migration_address = "203.0.113.9" self.dest.migration_address = "203.0.113.9"
self.migrate("live", live=True) 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.compute = self.source
self.vm.save() self.vm.save()
self.dest.migration_address = "198.51.100.9" # the host name itself self.dest.migration_address = "198.51.100.9" # the host name itself
self.migrate("live", live=True) 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): def test_failure_is_logged_and_raised(self):
with self.assertRaisesRegex(util.OperationError, "A shut-off VM cannot be migrated live"): 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.migrate(util.OperationError("A shut-off VM cannot be migrated live"), live=True)
self.assertEqual( self.assertEqual(rows(), [("VM vm: migrate to 198.51.100.9: A shut-off VM cannot be migrated live", False)])
Logs.objects.get().message,
"Instance migration to 198.51.100.9 failed: A shut-off VM cannot be migrated live",
)

View file

@ -98,7 +98,8 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
self.assertEqual(op.intent["before"]["own"], ["/p/a.one"]) self.assertEqual(op.intent["before"]["own"], ["/p/a.one"])
self.assertEqual(result, {"left_behind": ["/p/a.one"]}) self.assertEqual(result, {"left_behind": ["/p/a.one"]})
jobs.finish(op, Outcome(Operation.SUCCEEDED, result=result)) 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): def logged(self):
return list(Logs.objects.values_list("user", "host", "instance", "message")) return list(Logs.objects.values_list("user", "host", "instance", "message"))
@ -110,17 +111,17 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
op = self.submitted() op = self.submitted()
jobs.finish(op, Outcome(state, error="x")) jobs.finish(op, Outcome(state, error="x"))
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", 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() Logs.objects.all().delete()
Operation.objects.all().delete() Operation.objects.all().delete()
jobs.cancel(self.submitted().pk, self.admin) 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): 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") Compute.objects.filter(pk=self.instance.compute_id).update(name="region/job-compute")
op = self.submitted() op = self.submitted()
jobs.finish(op, Outcome(Operation.SUCCEEDED)) 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): def test_a_resolved_job_is_logged_on_its_vm(self):
op = self.submitted() op = self.submitted()
@ -129,6 +130,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
jobs.resolve(op.pk, self.admin, Operation.FAILED, "disks checked") jobs.resolve(op.pk, self.admin, Operation.FAILED, "disks checked")
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm",
f"Job {op.pk} resolved as failed: disks checked")]) 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): def test_retention_keeps_the_log(self):
op = self.submitted() op = self.submitted()
@ -136,7 +138,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
Operation.objects.filter(pk=op.pk).update(finished_at=timezone.now() - timedelta(days=400)) Operation.objects.filter(pk=op.pk).update(finished_at=timezone.now() - timedelta(days=400))
housekeeping() housekeeping()
self.assertFalse(Operation.objects.filter(pk=op.pk).exists()) 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): def test_precheck_refuses_a_moved_vm_and_an_inactive_user(self):
op = self.submitted() 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)) self.assertJson(self.client.get(url))
def test_vm_logs_is_json(self): 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): class TemplateHtmlConcatenationTestCase(TestCase):

View file

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

View file

@ -36,9 +36,8 @@ from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
VIR_DOMAIN_UNDEFINE_NVRAM, VIR_DOMAIN_UNDEFINE_NVRAM,
VIR_ERR_NO_SUPPORT, VIR_ERR_NO_SUPPORT,
libvirtError) 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.audit import snapshot as audit_snapshot
from logs.views import addlogmsg
from operations import claims, jobs from operations import claims, jobs
from operations.utils import busy_message, summary from operations.utils import busy_message, summary
from webvirtcloud.middleware import error_text, same_origin_referer 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 # 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 = UserInstance.objects.filter(instance=instance).order_by('user__username')
userinstances = instance.userinstance_set.order_by("user__username") userinstances = instance.userinstance_set.order_by("user__username")
# Host resources # Host resources
@ -578,10 +564,8 @@ def poweron(request, pk):
if instance.is_template: if instance.is_template:
messages.warning(request, _("Templates cannot be started.")) messages.warning(request, _("Templates cannot be started."))
else: else:
instance.proxy.start() with audit_vm(request, instance, "power on"):
addlogmsg( instance.proxy.start()
request.user.username, instance.compute.name, instance.name, _("Power On")
)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
@ -595,11 +579,9 @@ def powercycle(request, pk):
if instance.is_template: if instance.is_template:
messages.warning(request, _("Templates cannot be started.")) messages.warning(request, _("Templates cannot be started."))
else: else:
instance.proxy.force_shutdown() with audit_vm(request, instance, "power cycle"):
instance.proxy.start() instance.proxy.force_shutdown()
addlogmsg( instance.proxy.start()
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
) )
@ -609,10 +591,8 @@ def powercycle(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def poweroff(request, pk): def poweroff(request, pk):
instance = get_instance(request.user, pk, perm_type="power") instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.shutdown() with audit_vm(request, instance, "power off"):
addlogmsg( instance.proxy.shutdown()
request.user.username, instance.compute.name, instance.name, _("Power Off")
)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
@ -624,8 +604,8 @@ def poweroff(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def suspend(request, pk): def suspend(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.suspend() with audit_vm(request, instance, "suspend"):
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend")) instance.proxy.suspend()
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
) )
@ -636,8 +616,8 @@ def suspend(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def resume(request, pk): def resume(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.resume() with audit_vm(request, instance, "resume"):
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume")) instance.proxy.resume()
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
) )
@ -647,10 +627,8 @@ def resume(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def force_off(request, pk): def force_off(request, pk):
instance = get_instance(request.user, pk, perm_type="power") instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown() with audit_vm(request, instance, "force off"):
addlogmsg( instance.proxy.force_shutdown()
request.user.username, instance.compute.name, instance.name, _("Force Off")
)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) request, default=reverse("instances:instance", args=[instance.id])
) )
@ -661,37 +639,37 @@ def destroy(request, pk):
if request.method in ["POST", "DELETE"]: if request.method in ["POST", "DELETE"]:
instance = get_instance(request.user, pk, perm_type="delete") instance = get_instance(request.user, pk, perm_type="delete")
with libvirt_instance_lock(instance): with libvirt_instance_lock(instance):
proxy = instance.proxy with audit_vm(request, instance, "delete"):
# Paused and other active states must stop too, or the VM keeps proxy = instance.proxy
# running as a transient domain after the undefine. # Paused and other active states must stop too, or the VM keeps
if proxy.instance.isActive(): # running as a transient domain after the undefine.
proxy.force_shutdown() if proxy.instance.isActive():
proxy.force_shutdown()
to_delete, shared = [], [] to_delete, shared = [], []
if request.POST.get("delete_disk", ""): if request.POST.get("delete_disk", ""):
to_delete, shared = proxy.split_disk_paths_by_use() to_delete, shared = proxy.split_disk_paths_by_use()
# Undefine before deleting any disk: if it fails, nothing is lost. # Undefine before deleting any disk: if it fails, nothing is lost.
# Snapshot metadata goes with the domain; internal snapshot data # Snapshot metadata goes with the domain; internal snapshot data
# stays in the disk images that are kept. # stays in the disk images that are kept.
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
if request.POST.get("delete_nvram", ""): if request.POST.get("delete_nvram", ""):
flags |= VIR_DOMAIN_UNDEFINE_NVRAM flags |= VIR_DOMAIN_UNDEFINE_NVRAM
else: else:
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
proxy.delete(flags) proxy.delete(flags)
instance.delete() instance.delete()
# each disk has its own row: the VM is gone even if one is kept
for path in to_delete: for path in to_delete:
try: 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: except libvirtError as err:
messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)}) messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)})
for path in shared: for path in shared:
messages.warning(request, _("Disk %(path)s is used by another VM and was kept") % {"path": path}) 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")) return redirect(reverse("instances:index"))
instance = get_instance(request.user, pk, perm_type="delete") 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): 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: try:
# the host name may resolve to IPv4 or IPv6 # the host name may resolve to IPv4 or IPv6
s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT) s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
except OSError as err: 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: try:
s.sendall(json.dumps(data).encode()) s.sendall(json.dumps(data).encode())
# gstfsd sends one reply and closes the connection # 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: 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: finally:
s.close() s.close()
if reply["return"] != "success":
raise util.OperationError(reply["message"])
return reply
@require_POST @require_POST
@ -771,15 +753,13 @@ def set_root_pass(request, pk):
data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()} data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()}
if instance.proxy.get_status() == 5: if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data) try:
if result["return"] == "success": with audit_vm(request, instance, "reset root password"):
msg = _("Reset root password") gstfsd_request(instance.compute.hostname, data)
addlogmsg( except util.OperationError as err:
request.user.username, instance.compute.name, instance.name, msg messages.error(request, err)
)
messages.success(request, msg)
else: else:
messages.error(request, result["message"]) messages.success(request, _("Reset root password"))
else: else:
msg = _("Please shutdown down your instance and then try again") msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg) messages.error(request, msg)
@ -808,19 +788,15 @@ def add_public_key(request, pk):
} }
if instance.proxy.get_status() == 5: if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data) try:
if result["return"] == "error": with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
msg = result["message"] gstfsd_request(instance.compute.hostname, data)
except util.OperationError as err:
messages.error(request, err)
else: else:
msg = _("Installed new SSH public key %(keyname)s") % { messages.success(request, _("Installed new SSH public key %(keyname)s") % {
"keyname": publickey.keyname "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: else:
msg = _("Please shutdown down your instance and then try again") msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg) messages.error(request, msg)
@ -856,14 +832,12 @@ def resizevm_cpu(request, pk):
else: else:
cur_vcpu = new_cur_vcpu cur_vcpu = new_cur_vcpu
vcpu = new_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") % { msg = _("CPU is resized: %(old)s to %(new)s") % {
"old": cur_vcpu, "old": cur_vcpu,
"new": vcpu, "new": vcpu,
} }
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg) messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -902,7 +876,8 @@ def resize_memory(request, pk):
}) })
messages.error(request, msg) messages.error(request, msg)
else: 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 = _( msg = _(
"Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s" "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_cur": new_cur_memory,
"new_max": new_memory, "new_max": new_memory,
} }
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg) messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -955,18 +927,15 @@ def resize_disk(request, pk):
else: else:
for disk in disks_new: for disk in disks_new:
try: 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: except libvirtError as err:
# an LVM pool, for one, cannot resize its volumes # an LVM pool, for one, cannot resize its volumes
if err.get_error_code() != VIR_ERR_NO_SUPPORT: if err.get_error_code() != VIR_ERR_NO_SUPPORT:
raise raise
messages.error(request, _("The storage pool of %(dev)s cannot resize its volumes") % {"dev": disk["dev"]}) messages.error(request, _("The storage pool of %(dev)s cannot resize its volumes") % {"dev": disk["dev"]})
continue continue
msg = _("Disk is resized: %(dev)s") % {"dev": disk["dev"]} messages.success(request, _("Disk is resized: %(dev)s") % {"dev": disk["dev"]})
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize") return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@ -990,61 +959,57 @@ def add_new_vol(request, pk):
if invalid: if invalid:
return reject_disk_options(request, pk, invalid) return reject_disk_options(request, pk, invalid)
conn_create = wvmCreate( with audit_vm(request, instance, f"attach new disk {storage}/{name}"):
instance.compute.hostname, conn_create = wvmCreate(
instance.compute.login, instance.compute.hostname,
instance.compute.password, instance.compute.login,
instance.compute.type, 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 # a name either definition uses is taken: on an active VM a disk can be
target_dev = utils.get_new_disk_dev( # pending in the persistent one only
instance.media + instance.config_media, instance.disks + instance.config_disks, bus target_dev = utils.get_new_disk_dev(
) instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
source = conn_create.create_volume( source = conn_create.create_volume(
storage, storage,
name, name,
size, size,
format, format,
meta_prealloc, meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID), int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID), int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
) )
conn_pool = wvmStorage( conn_pool = wvmStorage(
instance.compute.hostname, instance.compute.hostname,
instance.compute.login, instance.compute.login,
instance.compute.password, instance.compute.password,
instance.compute.type, instance.compute.type,
storage, storage,
) )
pool_type = conn_pool.get_type() pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source)) 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 # 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) format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
if pool_type == "rbd": if pool_type == "rbd":
source_info = conn_pool.get_rbd_source() source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types else: # add more disk types to handle different pool and disk types
source_info = None source_info = None
instance.proxy.attach_disk( instance.proxy.attach_disk(
target_dev, target_dev,
source, source,
source_info=source_info, source_info=source_info,
pool_type=pool_type, pool_type=pool_type,
disk_type=disk_type, disk_type=disk_type,
target_bus=bus, target_bus=bus,
format_type=format, format_type=format,
cache_mode=cache, 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)
return _back(request, pk, "disks") 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.media + instance.config_media, instance.disks + instance.config_disks, bus
) )
instance.proxy.attach_disk( with audit_vm(request, instance, f"attach disk {storage}/{name} as {target_dev}"):
target_dev, instance.proxy.attach_disk(
source, target_dev,
source_info=source_info, source,
pool_type=pool_type, source_info=source_info,
disk_type=disk_type, pool_type=pool_type,
target_bus=bus, disk_type=disk_type,
format_type=format_type, target_bus=bus,
cache_mode=cache, 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)
return _back(request, pk, "disks") return _back(request, pk, "disks")
@ -1152,19 +1116,20 @@ def edit_volume(request, pk):
if invalid: if invalid:
return reject_disk_options(request, pk, invalid) return reject_disk_options(request, pk, invalid)
instance.proxy.edit_disk( with audit_vm(request, instance, f"edit disk {target_dev}"):
target_dev, instance.proxy.edit_disk(
new_path, target_dev,
readonly, new_path,
shareable, readonly,
new_bus, shareable,
serial, new_bus,
format, serial,
cache, format,
io, cache,
discard, io,
zeroes, discard,
) zeroes,
)
if not instance.proxy.get_status() == 5: if not instance.proxy.get_status() == 5:
messages.success( messages.success(
@ -1176,8 +1141,6 @@ def edit_volume(request, pk):
) )
else: else:
messages.success(request, _("Volume is changed successfully.")) 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") return _back(request, pk, "disks")
@ -1221,10 +1184,12 @@ def delete_vol(request, pk):
) )
return _back(request, pk, "disks") return _back(request, pk, "disks")
msg = _("Delete disk: %(dev)s") % {"dev": dev} volume = f"{disk['storage']}/{disk['image']}"
instance.proxy.detach_disk(dev) 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. # Never delete a volume the guest may still be writing to.
if not instance.proxy.wait_disk_detached(dev): 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( messages.warning(
request, request,
_("The guest has not released disk %(dev)s yet; volume %(vol)s was kept. " _("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") return _back(request, pk, "disks")
try: 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 except util.OperationError as err: # a file of the VM's own snapshots
messages.warning( messages.warning(
request, request,
_("Disk %(dev)s was detached, but its volume was kept: %(err)s") % {"dev": dev, "err": err}, _("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") return _back(request, pk, "disks")
@ -1250,9 +1214,8 @@ def delete_vol(request, pk):
def detach_vol(request, pk): def detach_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change") instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("dev", "") dev = request.POST.get("dev", "")
instance.proxy.detach_disk(dev) with audit_vm(request, instance, f"detach disk {dev}"):
msg = _("Detach disk: %(dev)s") % {"dev": dev} instance.proxy.detach_disk(dev)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks") return _back(request, pk, "disks")
@ -1270,16 +1233,15 @@ def add_cdrom(request, pk):
target = utils.get_new_disk_dev( target = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus instance.media + instance.config_media, instance.disks + instance.config_disks, bus
) )
instance.proxy.attach_disk( with audit_vm(request, instance, f"add CD-ROM {target}"):
target, instance.proxy.attach_disk(
"", target,
disk_device="cdrom", "",
cache_mode="none", disk_device="cdrom",
target_bus=bus, cache_mode="none",
readonly=True, target_bus=bus,
) readonly=True,
msg = _("Add CD-ROM: %(target)s") % {"target": target} )
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks") return _back(request, pk, "disks")
@ -1289,10 +1251,8 @@ def add_cdrom(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def detach_cdrom(request, pk, dev): def detach_cdrom(request, pk, dev):
instance = get_instance(request.user, pk, perm_type="change") instance = get_instance(request.user, pk, perm_type="change")
# dev = request.POST.get('detach_cdrom', '') with audit_vm(request, instance, f"remove CD-ROM {dev}"):
instance.proxy.detach_disk(dev) instance.proxy.detach_disk(dev)
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks") return _back(request, pk, "disks")
@ -1304,12 +1264,10 @@ def unmount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change") instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("umount_iso", "") dev = request.POST.get("umount_iso", "")
try: 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: except libvirtError as err:
messages.error(request, 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") 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", "") image = request.POST.get("media", "")
dev = request.POST.get("mount_iso", "") dev = request.POST.get("mount_iso", "")
try: 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: except libvirtError as err:
messages.error(request, 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") return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
@ -1345,12 +1301,10 @@ def delete_snapshot(request, pk):
raise PermissionDenied raise PermissionDenied
snap_name = request.POST.get("name", "") snap_name = request.POST.get("name", "")
try: 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: except libvirtError as err:
messages.error(request, _("Snapshot was not deleted: %(err)s") % {"err": error_text(request, 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") return _back(request, pk, "managesnapshot")
@ -1406,13 +1360,9 @@ def delete_external_snapshot(request, pk):
def set_vcpu(request, pk): def set_vcpu(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
id = request.POST.get("id", "") id = request.POST.get("id", "")
enabled = request.POST.get("set_vcpu", "") enabled = request.POST.get("set_vcpu", "") == "True"
if enabled == "True": with audit_vm(request, instance, f"{'enable' if enabled else 'disable'} vCPU {id}"):
instance.proxy.set_vcpu(id, 1) instance.proxy.set_vcpu(id, 1 if enabled else 0)
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)
return _back(request, pk, "resize") return _back(request, pk, "resize")
@ -1422,9 +1372,8 @@ def set_vcpu(request, pk):
def set_vcpu_hotplug(request, pk): def set_vcpu_hotplug(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False
msg = _("VCPU Hot-plug is enabled=%(status)s") % {"status": status} with audit_vm(request, instance, f"{'enable' if status else 'disable'} vCPU hot-plug"):
instance.proxy.set_vcpu_hotplug(status) instance.proxy.set_vcpu_hotplug(status)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "resize") return _back(request, pk, "resize")
@ -1433,9 +1382,8 @@ def set_vcpu_hotplug(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def set_autostart(request, pk): def set_autostart(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.set_autostart(1) with audit_vm(request, instance, "enable autostart"):
msg = _("Set autostart") instance.proxy.set_autostart(1)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
@ -1444,9 +1392,8 @@ def set_autostart(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def unset_autostart(request, pk): def unset_autostart(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.set_autostart(0) with audit_vm(request, instance, "disable autostart"):
msg = _("Unset autostart") instance.proxy.set_autostart(0)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
@ -1455,9 +1402,8 @@ def unset_autostart(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def set_bootmenu(request, pk): def set_bootmenu(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(1) with audit_vm(request, instance, "enable boot menu"):
msg = _("Enable boot menu") instance.proxy.set_bootmenu(1)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
@ -1466,9 +1412,8 @@ def set_bootmenu(request, pk):
@serialize_instance_mutation @serialize_instance_mutation
def unset_bootmenu(request, pk): def unset_bootmenu(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(0) with audit_vm(request, instance, "disable boot menu"):
msg = _("Disable boot menu") instance.proxy.set_bootmenu(0)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
@ -1484,11 +1429,11 @@ def set_bootorder(request, pk):
dev_type, dev = val.split(":", 1) dev_type, dev = val.split(":", 1)
order_list[idx] = {"type": dev_type, "dev": dev} order_list[idx] = {"type": dev_type, "dev": dev}
try: 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: except util.OperationError as err:
messages.error(request, err) messages.error(request, err)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
msg = _("Set boot order")
if not instance.proxy.get_status() == 5: if not instance.proxy.get_status() == 5:
messages.success( messages.success(
@ -1500,7 +1445,6 @@ def set_bootorder(request, pk):
) )
else: else:
messages.success(request, _("Boot order changed successfully.")) messages.success(request, _("Boot order changed successfully."))
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt") return _back(request, pk, "boot_opt")
@ -1515,9 +1459,8 @@ def change_xml(request, pk):
if new_xml and instance.proxy.get_status() != 5: if new_xml and instance.proxy.get_status() != 5:
messages.error(request, _("Power off the instance to change its XML")) messages.error(request, _("Power off the instance to change its XML"))
elif new_xml: elif new_xml:
instance.proxy._defineXML(new_xml) with audit_vm(request, instance, "change XML"):
msg = _("Change instance XML") instance.proxy._defineXML(new_xml)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "xmledit") return _back(request, pk, "xmledit")
@ -1528,12 +1471,11 @@ def set_guest_agent(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
status = request.POST.get("guest_agent") status = request.POST.get("guest_agent")
if status == "True": 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": if status == "False":
instance.proxy.remove_guest_agent() with audit_vm(request, instance, "remove guest agent channel"):
instance.proxy.remove_guest_agent()
msg = _("Set Guest Agent: %(status)s") % {"status": status}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "options") return _back(request, pk, "options")
@ -1543,9 +1485,8 @@ def set_guest_agent(request, pk):
def set_video_model(request, pk): def set_video_model(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
video_model = request.POST.get("video_model", "vga") video_model = request.POST.get("video_model", "vga")
instance.proxy.set_video_model(video_model) with audit_vm(request, instance, f"set video model {video_model}"):
msg = _("Set Video Model: %(model)s") % {"model": video_model} instance.proxy.set_video_model(video_model)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "options") return _back(request, pk, "options")
@ -1571,31 +1512,29 @@ def change_network(request, pk):
return redirect(back) return redirect(back)
(source, source_type) = utils.get_network_tuple(request.POST.get(f"net-source-{num}", "")) (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: try:
instance.proxy.change_network( with audit_vm(request, instance, f"change interface {old_mac}"):
old_mac, if source_type == "iface":
mac, iface = wvmInterface(
source, instance.compute.hostname,
source_type, instance.compute.login,
request.POST.get(f"net-model-{num}"), instance.compute.password,
request.POST.get(f"net-nwfilter-{num}", ""), 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: except libvirtError as err:
messages.error(request, err) messages.error(request, err)
return redirect(back) 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.") msg = _("Network Device Config is changed. Please shutdown instance to activate.")
if instance.proxy.get_status() != 5: if instance.proxy.get_status() != 5:
messages.success(request, msg) messages.success(request, msg)
@ -1617,19 +1556,17 @@ def add_network(request, pk):
(source, source_type) = utils.get_network_tuple(network) (source, source_type) = utils.get_network_tuple(network)
model = request.POST.get("add-net-model") model = request.POST.get("add-net-model")
if source_type == "iface": with audit_vm(request, instance, f"add interface on {source}"):
iface = wvmInterface( if source_type == "iface":
instance.compute.hostname, iface = wvmInterface(
instance.compute.login, instance.compute.hostname,
instance.compute.password, instance.compute.login,
instance.compute.type, instance.compute.password,
source, instance.compute.type,
) source,
source_type = iface.get_type() )
source_type = iface.get_type()
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter) 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)
return _back(request, pk, "network") return _back(request, pk, "network")
@ -1640,9 +1577,8 @@ def delete_network(request, pk):
instance = get_instance(request.user, pk) instance = get_instance(request.user, pk)
mac_address = request.POST.get("delete_network", "") mac_address = request.POST.get("delete_network", "")
instance.proxy.delete_network(mac_address) with audit_vm(request, instance, f"remove interface {mac_address}"):
msg = _("Delete Network: %(mac)s") % {"mac": mac_address} instance.proxy.delete_network(mac_address)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "network") return _back(request, pk, "network")
@ -1655,9 +1591,8 @@ def set_link_state(request, pk):
mac_address = request.POST.get("mac", "") mac_address = request.POST.get("mac", "")
state = request.POST.get("set_link_state") state = request.POST.get("set_link_state")
state = "down" if state == "up" else "up" state = "down" if state == "up" else "up"
instance.proxy.set_link_state(mac_address, state) with audit_vm(request, instance, f"set link {state} on interface {mac_address}"):
msg = _("Set Link State: %(state)s") % {"state": state} instance.proxy.set_link_state(mac_address, state)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "network") return _back(request, pk, "network")
@ -1680,7 +1615,8 @@ def set_qos(request, pk):
messages.error(request, _("QoS rates must be whole numbers")) messages.error(request, _("QoS rates must be whole numbers"))
return _back(request, pk, "network") 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: if instance.proxy.get_status() == 5:
messages.success( messages.success(
request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()} 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"): if not mac or qos_dir not in ("inbound", "outbound"):
messages.error(request, _("Select a network interface and a QoS direction")) messages.error(request, _("Select a network interface and a QoS direction"))
return _back(request, pk, "network") 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: if instance.proxy.get_status() == 5:
messages.success( messages.success(
@ -1734,14 +1671,14 @@ def add_owner(request, pk):
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False": if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
check_inst = UserInstance.objects.filter(instance=instance).count() 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: 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( messages.error(
request, _("Only one owner is allowed and the one already added") request, _("Only one owner is allowed and the one already added")
) )
else: 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] entry.skip = not UserInstance.objects.get_or_create(instance=instance, user=user)[1]
if entry.skip: if entry.skip:
messages.warning(request, _("%(user)s is already an owner") % {"user": user}) 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(): if not userinstance_id.isdigit():
raise Http404 raise Http404
userinstance = get_object_or_404(UserInstance, pk=userinstance_id, instance=instance) 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", with audit_vm(request, instance, f"remove owner {userinstance.user.username}"):
instance=instance.name):
userinstance.delete() userinstance.delete()
return _back(request, pk, "users") return _back(request, pk, "users")
@ -1803,8 +1739,8 @@ def update_console(request, pk):
raise PermissionDenied raise PermissionDenied
form = ConsoleForm(request.POST or None, can_set_listener=request.user.is_superuser) 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): 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"]) with audit_vm(request, instance, f"set console type {form.cleaned_data['console_type']}"):
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Set 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 # 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 form.is_valid() and form.cleaned_data["console_type"] != "pty":
if ( if (
@ -1831,35 +1767,26 @@ def update_console(request, pk):
request, request,
_("The console listens beyond localhost; only an administrator can remove its password."), _("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: else:
msg = _("Set VNC password") try:
addlogmsg( with audit_vm(request, instance, "set console password" if password else "remove console password"):
request.user.username, instance.compute.name, instance.name, msg 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 "keymap" in form.changed_data or "clear_keymap" in form.changed_data:
if form.cleaned_data["clear_keymap"]: keymap = "" if form.cleaned_data["clear_keymap"] else form.cleaned_data["keymap"]
instance.proxy.set_console_keymap("") with audit_vm(request, instance, f"set console keymap {keymap}" if keymap else "remove console keymap"):
else: instance.proxy.set_console_keymap(keymap)
instance.proxy.set_console_keymap(form.cleaned_data["keymap"])
msg = _("Set VNC keymap")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
if "listen_on" in form.changed_data: if "listen_on" in form.changed_data:
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"]) with audit_vm(request, instance, f"set console listen address {form.cleaned_data['listen_on']}"):
msg = _("Set VNC listen address") instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
if not form.is_valid(): if not form.is_valid():
messages.error(request, _("Console settings were not saved: %(errors)s") % {"errors": form.errors.as_text()}) 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: for post in request.POST:
if post in ["title", "description"]: if post in ["title", "description"]:
options[post] = request.POST.get(post, "") options[post] = request.POST.get(post, "")
instance.proxy.set_options(options) with audit_vm(request, instance, "edit options"):
instance.proxy.set_options(options)
msg = _("Edit options")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return get_safe_redirect( return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#options" 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. # The .vv file contains the VNC password: same rule as the console.
if not utils.can_open_console(request.user, instance): if not utils.can_open_console(request.user, instance):
raise PermissionDenied raise PermissionDenied
msg = _("Send console.vv file")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
response = HttpResponse( response = HttpResponse(
content="", content="",
content_type="application/x-virt-viewer", content_type="application/x-virt-viewer",
@ -1925,7 +1848,7 @@ def getvvfile(request, pk):
charset="utf-8", charset="utf-8",
) )
# the VM found by its UUID; the stored name may be stale # 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() listen = proxy.get_console_listener_addr()
response.writelines("[virt-viewer]\n") response.writelines("[virt-viewer]\n")
response.writelines(f"type={proxy.get_console_type()}\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", "") xml = request.POST.get("dom_xml", "")
try: try:
name = util.get_xml_path(xml, "/domain/name") 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") xml_error = _("A virtual machine with this name already exists")
else: else:
with libvirt_compute_lock(compute): with libvirt_compute_lock(compute):
conn._defineXML(xml) with audit(request, compute, f"VM {name}", "define from XML", instance=name) as entry:
utils.refr(compute) conn._defineXML(xml)
instance = compute.instance_set.get(name=name) utils.refr(compute)
instance = compute.instance_set.get(name=name)
entry.uuid = instance.uuid
return redirect(reverse("instances:instance", args=[instance.id])) return redirect(reverse("instances:instance", args=[instance.id]))
except util.etree.Error as err: except util.etree.Error as err:
xml_error = _("The XML is not well-formed: %(error)s") % {"error": 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") 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 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. 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: try:
with transaction.atomic(): 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: except Exception as e:
try: 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")) logger.warning(line.replace("\r", "\\r").replace("\n", "\\n"))
except Exception: except Exception:
pass pass
@ -44,6 +45,7 @@ def request_ip(request):
class Entry: class Entry:
action: str action: str
skip: bool = False skip: bool = False
uuid: str = ""
def host_of(compute): def host_of(compute):
@ -69,26 +71,37 @@ def reason(exc, user):
@contextmanager @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 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 raises (the exception goes on). The block may set entry.action, entry.uuid,
entry.skip when nothing was done. 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) user, host, ip = request.user.get_username(), host_of(compute), request_ip(request)
try: try:
yield entry yield entry
except Exception as exc: 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 raise
if not entry.skip: 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 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): 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) user = CharField(_("user"), max_length=150)
host = CharField(_("host"), max_length=255, default="-") host = CharField(_("host"), max_length=255, default="-")
instance = CharField(_("instance"), max_length=120) instance = CharField(_("instance"), max_length=120)
instance_uuid = CharField(_("instance UUID"), max_length=36, blank=True, default="", db_index=True)
message = TextField(_("message")) message = TextField(_("message"))
date = DateTimeField(_("date"), auto_now_add=True, db_index=True) date = DateTimeField(_("date"), auto_now_add=True, db_index=True)
success = BooleanField(_("success"), null=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 django.test import RequestFactory, TestCase
from computes.models import Compute 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.models import Logs
from logs.views import addlogmsg
from operations import claims from operations import claims
from vrtManager.util import OperationError 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="127.0.0.1", HTTP_X_REAL_IP="fe80::1%eth0")))
self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR=""))) self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR="")))
def test_addlogmsg_still_writes(self): def test_a_row_without_a_vm_has_no_uuid(self):
addlogmsg("u", "h", "vm", "Destroy") write("u", "h", "", "Network lab: start", True)
row = Logs.objects.get() self.assertEqual(Logs.objects.get().instance_uuid, "")
self.assertEqual((row.user, row.host, row.instance, row.message), ("u", "h", "vm", "Destroy"))
self.assertIsNone(row.success)
self.assertIsNone(row.ip)
class AuditTests(TestCase): class AuditTests(TestCase):
@ -135,6 +132,26 @@ class AuditTests(TestCase):
self.assertEqual(row.host, "-") self.assertEqual(row.host, "-")
self.assertIs(row.success, False) 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): def test_changed_fields(self):
compute = Compute.objects.create(name="c", hostname="192.0.2.5", type=1, password="pw") compute = Compute.objects.create(name="c", hostname="192.0.2.5", type=1, password="pw")
before = snapshot(compute) before = snapshot(compute)

View file

@ -1,61 +1,60 @@
import json
from django.contrib.auth import get_user_model 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 django.urls import reverse
from computes.models import Compute from computes.models import Compute
from instances.models import Instance from instances.models import Instance
from logs.models import Logs from logs.audit import write
from logs.views import addlogmsg
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): def setUp(self):
User = get_user_model() User = get_user_model()
self.superuser = User.objects.create_superuser( self.superuser = User.objects.create_superuser("admin_logs", "admin@example.com", "pw")
username="admin_logs", email="admin@example.com", password="adminpassword" 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.normal_user = User.objects.create_user( self.instance = Instance.objects.create(compute=self.compute, name="test-vm", uuid=UUID)
username="normal_logs", email="user@example.com", password="userpassword" self.client.force_login(self.superuser)
)
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",
)
def test_addlogmsg(self): def shown(self, instance=None):
addlogmsg("admin", "localhost", "test-vm", "Started VM") response = self.client.get(reverse("vm_logs", args=[(instance or self.instance).pk]))
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"]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
return [(row["message"], row["result"]) for row in response.json()]
data = json.loads(response.content.decode("utf-8")) def test_rows_of_the_vm_with_their_result(self):
self.assertIsInstance(data, list) write("admin", "c1", "test-vm", "VM test-vm: power on", True, uuid=UUID)
self.assertEqual(len(data), 1) write("admin", "c1", "test-vm", "VM test-vm: power off: not running", False, uuid=UUID)
self.assertEqual(data[0]["user"], "admin") row = self.client.get(reverse("vm_logs", args=[self.instance.pk])).json()[0]
self.assertEqual(data[0]["message"], "Created VM") self.assertEqual((row["user"], row["host"], row["instance"]), ("admin", "c1", "test-vm"))
self.assertEqual(data[0]["instance"], "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): def test_a_vm_is_found_by_uuid_not_by_name(self):
client = Client() other = Compute.objects.create(name="c2", hostname="192.0.2.2", type=1)
client.login(request=RequestFactory().get("/"), username="normal_logs", password="userpassword") 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"])) def test_one_vm_on_two_computes_shows_the_rows_of_both(self):
# superuser_only decorator redirects non-superusers to index or login 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) self.assertNotEqual(response.status_code, 200)

View file

@ -3,5 +3,5 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ 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.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 admin.decorators import superuser_only
from instances.models import Instance from instances.models import Instance
from logs.audit import write
from logs.models import Logs 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 @superuser_only
def vm_logs(request, vname): 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."""
:param request: vm = get_object_or_404(Instance, pk=pk)
:param vname: results = {None: _("Unknown"), True: _("Done"), False: _("Failed")}
:return:
"""
vm = Instance.objects.get(name=vname)
logs_ = Logs.objects.filter(instance=vm.name, date__gte=vm.created).order_by("-date")
logs = [] logs = []
for l in logs_: for log in Logs.objects.filter(instance_uuid=vm.uuid).order_by("-date", "-id"):
log = dict() logs.append({
log["user"] = l.user "user": log.user,
log["host"] = l.host "host": log.host,
log["instance"] = l.instance "instance": log.instance,
log["message"] = l.message "message": log.message,
log["date"] = l.date.strftime("%x %X") "result": results[log.success],
logs.append(log) "date": log.date.strftime("%x %X"),
})
return JsonResponse(logs, safe=False) 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.""" the kind decides whether the job is idle and ends it if so."""
def log_entry(self, op, state): def log_entry(self, op, state):
"""(host, instance, message) of the Logs row written with each end of """(host, instance, instance UUID, message) of the Logs row written
the job (in the same transaction); its host and instance also go on with each end of the job (in the same transaction); its host, instance
the row of a resolution. None: no row.""" and UUID also go on the row of a resolution. None: no row."""
return None return None
@ -219,8 +219,8 @@ def _entry(op, state):
return None return None
def _log(user, host, instance, message, success): def _log(user, host, instance, uuid, message, success):
write(str(user), str(host), str(instance), utils.safe_text(message), success) write(str(user), str(host), str(instance), utils.safe_text(message), success, uuid=str(uuid))
def finish(op, outcome, *, worker=None): 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, Operation.objects.filter(pk=op.pk).update(state=outcome_state, resolution=outcome_state,
resolution_note=note, resolved_by=user, resolved_at=timezone.now()) resolution_note=note, resolved_by=user, resolved_at=timezone.now())
Claim.objects.filter(operation=op).delete() 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) entry = _entry(op, outcome_state)
host, instance = entry[:2] if entry is not None else ("-", op.resource_label) host, instance, uuid = entry[:3] if entry is not None else ("-", op.resource_label, "")
_log(user.username, host, instance, f"Job {op.pk} resolved as {outcome_state}: {note}", _log(user.username, host, instance, uuid, f"Job {op.pk} resolved as {outcome_state}: {note}",
outcome_state == Operation.SUCCEEDED) outcome_state == Operation.SUCCEEDED)
op.refresh_from_db() op.refresh_from_db()
return op return op

View file

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

View file

@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
def _log(op, state, action): def _log(op, state, action):
compute, _sep, pool = op.resource_label.rpartition("/") # a pool name has no "/" compute, _sep, pool = op.resource_label.rpartition("/") # a pool name has no "/"
message = f"Storage pool {pool}: {action}" 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): def _storage(params):