mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(logs): every VM operation writes an audit row the VM's Logs tab finds by UUID
Operations on a VM now write their rows the way the admin audit does: one
row per operation, done or failed with a safe reason, with the user, IP and
host. Each row names the VM ("VM web-1: delete snapshot snap1") and carries
its UUID in the new Logs.instance_uuid column.
- audit_vm and failed_vm wrap audit() and failed() for a VM. A failure the
view turns into a message is caught outside the block, so its failed row
is written; busy claims, permission and quota refusals and invalid input
write nothing. No password, key or XML goes into a row.
- All VM page operations move from addlogmsg to audit_vm, and addlogmsg
goes. QoS changes and defining a VM from XML are logged now; a gstfsd
error reply (root password, SSH key) is a failed row, not a done one.
- Partial results are rows of their own: the VM delete, then each disk
delete; the disk detach, then the volume delete; the migration, then a
failed autostart restore, NVRAM removal or record on the target.
- Migration rows use the compute name as the host column, as every other
row does.
- VM job rows (create, clone, snapshots) and their resolutions carry the
VM's UUID; snapshot job intents now record it. The owner rows of the VM
page and the accounts pages, and the ownership system rows, carry it too.
- The Logs tab loads vm_logs/<pk>/ and shows the rows with the VM's UUID,
so a rename keeps the history, two VMs with one name are told apart and
a VM on two computes is one VM. It gains a Result column. Rows from
before this change have no UUID and stay on the Logs page only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49cf16ecbe
commit
c1d36053d6
24 changed files with 639 additions and 524 deletions
|
|
@ -61,11 +61,12 @@ class AdminAccountRowTests(TestCase):
|
|||
self.client.post(url, {"is_vnc": "on"}) # no change
|
||||
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
|
||||
self.assertEqual(rows(), [
|
||||
("VM vm owner bob: add", True),
|
||||
("VM vm owner bob: change is_vnc", True),
|
||||
("VM vm owner bob: remove", True),
|
||||
("VM vm: add owner bob", True),
|
||||
("VM vm: change owner bob: is_vnc", True),
|
||||
("VM vm: remove owner bob", True),
|
||||
])
|
||||
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance")), {("audit-admin", "c1", "vm")})
|
||||
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance", "instance_uuid")),
|
||||
{("audit-admin", "c1", "vm", self.vm.uuid)})
|
||||
|
||||
def test_otp_mail_without_an_email(self):
|
||||
from smtplib import SMTPException
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from django.views.decorators.http import require_POST
|
|||
|
||||
from accounts.apps import admin_password_path
|
||||
from instances.models import Instance
|
||||
from logs.audit import audit, changed_fields, failed, snapshot
|
||||
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
|
||||
|
||||
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
|
||||
from accounts.models import User, UserAttributes, UserInstance, UserSSHKey
|
||||
|
|
@ -146,8 +146,7 @@ def user_instance_create(request, user_id):
|
|||
form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
|
||||
if form.is_valid():
|
||||
instance = form.cleaned_data["instance"]
|
||||
subject = f"VM {instance.name} owner {form.cleaned_data['user'].username}"
|
||||
with audit(request, instance.compute, subject, "add", instance=instance.name):
|
||||
with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}"):
|
||||
form.save()
|
||||
return redirect(reverse("accounts:account", args=[user.id]))
|
||||
|
||||
|
|
@ -167,11 +166,10 @@ def user_instance_update(request, pk):
|
|||
instance, before = user_instance.instance, snapshot(user_instance)
|
||||
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
|
||||
if form.is_valid():
|
||||
subject = f"VM {instance.name} owner {user_instance.user.username}"
|
||||
with audit(request, instance.compute, subject, "change", instance=instance.name) as entry:
|
||||
with audit_vm(request, instance, "change owner") as entry:
|
||||
form.save()
|
||||
fields = changed_fields(before, snapshot(user_instance))
|
||||
entry.action = "change " + ", ".join(fields)
|
||||
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
|
||||
entry.skip = not fields
|
||||
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
|
||||
|
||||
|
|
@ -190,8 +188,7 @@ def user_instance_delete(request, pk):
|
|||
user_instance = get_object_or_404(UserInstance, pk=pk)
|
||||
if request.method == "POST":
|
||||
user, instance = user_instance.user, user_instance.instance
|
||||
with audit(request, instance.compute, f"VM {instance.name} owner {user.username}", "remove",
|
||||
instance=instance.name):
|
||||
with audit_vm(request, instance, f"remove owner {user.username}"):
|
||||
user_instance.delete()
|
||||
next_url = request.GET.get("next", None)
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ def lock_ownership():
|
|||
|
||||
|
||||
def _log(inst, what):
|
||||
write("-", inst.compute.name, "", f"VM {inst.name} ({inst.uuid}): {what}", True)
|
||||
write("-", inst.compute.name, inst.name, f"VM {inst.name} ({inst.uuid}): {what}", True, uuid=inst.uuid)
|
||||
|
||||
|
||||
def _move_ownership(source, target):
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def _vm_plan(params):
|
|||
claims.instance_key(instance.uuid): Claim.EXCLUSIVE},
|
||||
resource=claims.instance_key(instance.uuid),
|
||||
label=f"{instance.compute.name}/{instance.name}",
|
||||
intent={"compute": instance.compute_id},
|
||||
intent={"compute": instance.compute_id, "uuid": instance.uuid},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -83,9 +83,12 @@ def _libvirt_busy(dom):
|
|||
return dom.isActive() and dom.jobInfo()[0] != VIR_DOMAIN_JOB_NONE
|
||||
|
||||
|
||||
def _log(op, state, message):
|
||||
compute, _sep, vm = op.resource_label.rpartition("/") # a VM name has no "/"
|
||||
return compute, vm, message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
def _log(op, state, action, vm=None):
|
||||
"""The job's row: "VM <name>: <action>", the end state added unless it succeeded."""
|
||||
compute, _sep, label_vm = op.resource_label.rpartition("/") # a VM name has no "/"
|
||||
vm = vm or label_vm
|
||||
message = f"VM {vm}: {action}"
|
||||
return compute, vm, op.intent["uuid"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
|
||||
|
||||
def _snapshot_free_space():
|
||||
|
|
@ -283,7 +286,7 @@ class DeleteExternalSnapshot(Kind):
|
|||
"changed; check the VM before deleting the snapshot again or reverting to it.")
|
||||
|
||||
def log_entry(self, op, state):
|
||||
return _log(op, state, f"Delete external snapshot: {op.params['name']}")
|
||||
return _log(op, state, f"delete external snapshot {op.params['name']}")
|
||||
|
||||
|
||||
GONE = "The VM is no longer on the compute the job ran on"
|
||||
|
|
@ -335,7 +338,7 @@ class CreateSnapshot(Kind):
|
|||
|
||||
def log_entry(self, op, state):
|
||||
kind = "external snapshot" if op.params["external"] else "snapshot"
|
||||
return _log(op, state, f"Create {kind}: {op.params['name']}")
|
||||
return _log(op, state, f"take {kind} {op.params['name']}")
|
||||
|
||||
|
||||
def _current(proxy):
|
||||
|
|
@ -388,7 +391,7 @@ class RevertSnapshot(Kind):
|
|||
|
||||
def log_entry(self, op, state):
|
||||
kind = "external snapshot" if op.params["external"] else "snapshot"
|
||||
return _log(op, state, f"Revert {kind}: {op.params['name']}")
|
||||
return _log(op, state, f"revert to {kind} {op.params['name']}")
|
||||
|
||||
|
||||
def _delete_created(proxy, op):
|
||||
|
|
@ -606,9 +609,8 @@ class CloneInstance(Kind):
|
|||
return Outcome(Operation.SUCCEEDED)
|
||||
|
||||
def log_entry(self, op, state):
|
||||
compute, _sep, source = op.resource_label.rpartition("/")
|
||||
message = f"Create a clone of '{source}'"
|
||||
return compute, op.intent["name"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
source = op.resource_label.rpartition("/")[2]
|
||||
return _log(op, state, f"clone from {source}", vm=op.intent["name"])
|
||||
|
||||
|
||||
@register
|
||||
|
|
@ -756,9 +758,7 @@ class CreateInstance(Kind):
|
|||
return vm_outcome(op, owner=False, volumes=names if data["hdd_size"] or data["template"] else [])
|
||||
|
||||
def log_entry(self, op, state):
|
||||
compute, _sep, name = op.resource_label.rpartition("/")
|
||||
message = "Instance is created"
|
||||
return compute, name, message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
return _log(op, state, "create")
|
||||
|
||||
|
||||
MIGRATE_OPTIONS = ("live", "unsafe", "offline", "autoconverge", "compress")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab" aria-controls="logs" aria-selected="false" onclick="update_logs_table(vname);">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#logs" type="button" role="tab" aria-controls="logs" aria-selected="false" onclick="update_logs_table();">
|
||||
{% trans "Logs" %}
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -71,11 +71,12 @@
|
|||
<th scope="col">{% trans "Date" %}</th>
|
||||
<th scope="col">{% trans "User" %}</th>
|
||||
<th scope="col">{% trans "Message" %}</th>
|
||||
<th scope="col">{% trans "Result" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
<tr>
|
||||
<td colspan="3"><i>{% trans 'None' %}...</i></td>
|
||||
<td colspan="4"><i>{% trans 'None' %}...</i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -85,10 +86,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function update_logs_table(vname) {
|
||||
// TODO
|
||||
logurl = "{% url 'vm_logs' 1 %}".replace(1, vname);
|
||||
$.getJSON(logurl, function(data) {
|
||||
function update_logs_table() {
|
||||
$.getJSON("{% url 'vm_logs' instance.id %}", function(data) {
|
||||
// Log messages contain user-entered names (snapshots, disks, clones);
|
||||
// insert them as text, never as HTML.
|
||||
var tbody = $("#logs_table > tbody").empty();
|
||||
|
|
@ -97,6 +96,7 @@
|
|||
.append($("<td>").css("width", "150px").text(row['date']))
|
||||
.append($("<td>").text(row['user']))
|
||||
.append($("<td>").text(row['message']))
|
||||
.append($("<td>").text(row['result']))
|
||||
.appendTo(tbody);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ class VMOwnerRowTests(TestCase):
|
|||
ui = UserInstance.objects.get(user=self.bob)
|
||||
self.client.post(reverse("instances:del_owner", args=[self.vm.pk]), {"userinstance": ui.pk})
|
||||
self.assertEqual(rows(), [
|
||||
("VM vm owner bob: add", True),
|
||||
("VM vm owner carol: add: only one owner is allowed", False),
|
||||
("VM vm owner bob: remove", True),
|
||||
("VM vm: add owner bob", True),
|
||||
("VM vm: add owner carol: only one owner is allowed", False),
|
||||
("VM vm: remove owner bob", True),
|
||||
])
|
||||
shown = [log["message"] for log in self.client.get(reverse("vm_logs", args=["vm"])).json()]
|
||||
shown = [log["message"] for log in self.client.get(reverse("vm_logs", args=[self.vm.pk])).json()]
|
||||
self.assertCountEqual(shown, [m for m, _ in rows()])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,9 @@ class XmlRefusalTests(TestCase):
|
|||
def test_a_taken_name(self):
|
||||
self.assertIn("already exists", self.post("<domain type='test'><name>test</name></domain>"))
|
||||
|
||||
def test_a_definition_without_a_name(self):
|
||||
self.assertIn("no name", self.post("<domain type='kvm'><memory>1024</memory></domain>"))
|
||||
|
||||
def test_malformed_xml(self):
|
||||
self.assertIn("not well-formed", self.post("<domain type='test'><name>new</name>"))
|
||||
|
||||
|
|
|
|||
|
|
@ -44,19 +44,19 @@ class MigrationBookkeepingTests(TestCase):
|
|||
def test_a_failed_autostart_does_not_stop_the_records(self):
|
||||
self.migrate(autostart=True, autostart_error=util.OperationError("no autostart"))
|
||||
self.assertEqual(Instance.objects.get(id=self.vm.id).compute, self.dest)
|
||||
self.assertEqual(
|
||||
Logs.objects.get().message,
|
||||
"Instance is migrated(live) to 198.51.100.9; setting its autostart there failed: no autostart",
|
||||
)
|
||||
self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
|
||||
("VM vm: migrate (live) to 198.51.100.9", True),
|
||||
("VM vm: restore autostart on 198.51.100.9: no autostart", False),
|
||||
])
|
||||
|
||||
def test_a_failed_record_is_logged_as_such(self):
|
||||
with patch.object(Instance, "save", side_effect=DatabaseError("locked")), \
|
||||
self.assertRaises(DatabaseError):
|
||||
self.migrate()
|
||||
self.assertEqual(
|
||||
Logs.objects.get().message,
|
||||
"Instance is migrated(live) to 198.51.100.9, but recording it failed: locked",
|
||||
)
|
||||
self.assertEqual(list(Logs.objects.order_by("id").values_list("message", "success")), [
|
||||
("VM vm: migrate (live) to 198.51.100.9", True),
|
||||
("VM vm: record the move to 198.51.100.9: DatabaseError", False),
|
||||
])
|
||||
|
||||
def test_a_record_the_destination_already_has_keeps_template_and_owners(self):
|
||||
owner = get_user_model().objects.create_user("mb-owner")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""A migration is logged where it runs (migrate_instance, in the job): the
|
||||
mode it used on success, the error on failure."""
|
||||
mode it used on success, the error on failure, and each later step that
|
||||
failed in a row of its own."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -14,6 +15,10 @@ from instances.utils import migrate_instance
|
|||
from vrtManager import util
|
||||
|
||||
|
||||
def rows():
|
||||
return list(Logs.objects.order_by("id").values_list("message", "success"))
|
||||
|
||||
|
||||
class MigrationLogTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = get_user_model().objects.create_superuser("ml-admin", "ml@example.com", "pw")
|
||||
|
|
@ -45,7 +50,7 @@ class MigrationLogTests(TestCase):
|
|||
path, destination = self.proxy.remove_nvram.call_args[0]
|
||||
self.assertEqual(path, "/var/lib/libvirt/qemu/nvram/vm_VARS.fd")
|
||||
self.assertIsNotNone(destination)
|
||||
self.assertEqual(Logs.objects.get().message, "Instance is migrated(live) to 198.51.100.9")
|
||||
self.assertEqual(rows(), [("VM vm: migrate (live) to 198.51.100.9", True)])
|
||||
|
||||
def test_a_vm_without_nvram_removes_nothing(self):
|
||||
self.migrate("live", live=True)
|
||||
|
|
@ -54,11 +59,10 @@ class MigrationLogTests(TestCase):
|
|||
def test_a_failed_nvram_removal_is_logged_but_the_migration_stands(self):
|
||||
self.migrate("live", nvram="/var/lib/libvirt/qemu/nvram/vm_VARS.fd",
|
||||
remove_nvram=libvirtError("cannot delete"), live=True)
|
||||
self.assertEqual(
|
||||
Logs.objects.get().message,
|
||||
"Instance is migrated(live) to 198.51.100.9; removing its NVRAM file "
|
||||
"/var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source failed: cannot delete",
|
||||
)
|
||||
self.assertEqual(rows(), [
|
||||
("VM vm: migrate (live) to 198.51.100.9", True),
|
||||
("VM vm: remove NVRAM file /var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source: cannot delete", False),
|
||||
])
|
||||
self.vm.refresh_from_db()
|
||||
self.assertEqual(self.vm.compute, self.dest)
|
||||
|
||||
|
|
@ -67,7 +71,9 @@ class MigrationLogTests(TestCase):
|
|||
remove_nvram=AttributeError("unexpected"), live=True)
|
||||
self.vm.refresh_from_db()
|
||||
self.assertEqual(self.vm.compute, self.dest)
|
||||
self.assertIn("on the source failed: unexpected", Logs.objects.get().message)
|
||||
# an unknown error gives only its class name
|
||||
self.assertEqual(rows()[-1], ("VM vm: remove NVRAM file /var/lib/libvirt/qemu/nvram/vm_VARS.fd on the source: "
|
||||
"AttributeError", False))
|
||||
|
||||
def test_a_failed_migration_removes_nothing(self):
|
||||
with self.assertRaises(util.OperationError):
|
||||
|
|
@ -77,23 +83,20 @@ class MigrationLogTests(TestCase):
|
|||
def test_success_names_the_mode_used(self):
|
||||
self.migrate("non-live, compressed", live=False, compress=True)
|
||||
log = Logs.objects.get()
|
||||
self.assertEqual(log.message, "Instance is migrated(non-live, compressed) to 198.51.100.9")
|
||||
self.assertEqual((log.user, log.host, log.instance), ("ml-admin", "198.51.100.8", "vm"))
|
||||
self.assertEqual(log.message, "VM vm: migrate (non-live, compressed) to 198.51.100.9")
|
||||
self.assertEqual((log.user, log.host, log.instance, log.instance_uuid), ("ml-admin", "ml-src", "vm", "u-ml"))
|
||||
|
||||
def test_success_names_the_migration_address(self):
|
||||
self.dest.migration_address = "203.0.113.9"
|
||||
self.migrate("live", live=True)
|
||||
self.assertEqual(Logs.objects.get().message, "Instance is migrated(live) to 198.51.100.9 via 203.0.113.9")
|
||||
self.assertEqual(Logs.objects.get().message, "VM vm: migrate (live) to 198.51.100.9 via 203.0.113.9")
|
||||
self.vm.compute = self.source
|
||||
self.vm.save()
|
||||
self.dest.migration_address = "198.51.100.9" # the host name itself
|
||||
self.migrate("live", live=True)
|
||||
self.assertEqual(Logs.objects.latest("id").message, "Instance is migrated(live) to 198.51.100.9")
|
||||
self.assertEqual(Logs.objects.latest("id").message, "VM vm: migrate (live) to 198.51.100.9")
|
||||
|
||||
def test_failure_is_logged_and_raised(self):
|
||||
with self.assertRaisesRegex(util.OperationError, "A shut-off VM cannot be migrated live"):
|
||||
self.migrate(util.OperationError("A shut-off VM cannot be migrated live"), live=True)
|
||||
self.assertEqual(
|
||||
Logs.objects.get().message,
|
||||
"Instance migration to 198.51.100.9 failed: A shut-off VM cannot be migrated live",
|
||||
)
|
||||
self.assertEqual(rows(), [("VM vm: migrate to 198.51.100.9: A shut-off VM cannot be migrated live", False)])
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
|
|||
self.assertEqual(op.intent["before"]["own"], ["/p/a.one"])
|
||||
self.assertEqual(result, {"left_behind": ["/p/a.one"]})
|
||||
jobs.finish(op, Outcome(Operation.SUCCEEDED, result=result))
|
||||
self.assertTrue(Logs.objects.filter(message="Delete external snapshot: one").exists())
|
||||
row = Logs.objects.get(message="VM job-vm: delete external snapshot one")
|
||||
self.assertEqual(row.instance_uuid, self.instance.uuid)
|
||||
|
||||
def logged(self):
|
||||
return list(Logs.objects.values_list("user", "host", "instance", "message"))
|
||||
|
|
@ -110,17 +111,17 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
|
|||
op = self.submitted()
|
||||
jobs.finish(op, Outcome(state, error="x"))
|
||||
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm",
|
||||
f"Delete external snapshot: one ({state})")])
|
||||
f"VM job-vm: delete external snapshot one ({state})")])
|
||||
Logs.objects.all().delete()
|
||||
Operation.objects.all().delete()
|
||||
jobs.cancel(self.submitted().pk, self.admin)
|
||||
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", "Delete external snapshot: one (cancelled)")])
|
||||
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm", "VM job-vm: delete external snapshot one (cancelled)")])
|
||||
|
||||
def test_a_compute_name_with_a_slash_is_logged_whole(self):
|
||||
Compute.objects.filter(pk=self.instance.compute_id).update(name="region/job-compute")
|
||||
op = self.submitted()
|
||||
jobs.finish(op, Outcome(Operation.SUCCEEDED))
|
||||
self.assertEqual(self.logged(), [("job_admin", "region/job-compute", "job-vm", "Delete external snapshot: one")])
|
||||
self.assertEqual(self.logged(), [("job_admin", "region/job-compute", "job-vm", "VM job-vm: delete external snapshot one")])
|
||||
|
||||
def test_a_resolved_job_is_logged_on_its_vm(self):
|
||||
op = self.submitted()
|
||||
|
|
@ -129,6 +130,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
|
|||
jobs.resolve(op.pk, self.admin, Operation.FAILED, "disks checked")
|
||||
self.assertEqual(self.logged(), [("job_admin", "job-compute", "job-vm",
|
||||
f"Job {op.pk} resolved as failed: disks checked")])
|
||||
self.assertEqual(Logs.objects.get().instance_uuid, self.instance.uuid)
|
||||
|
||||
def test_retention_keeps_the_log(self):
|
||||
op = self.submitted()
|
||||
|
|
@ -136,7 +138,7 @@ class SnapshotDeleteJobTestCase(TransactionTestCase):
|
|||
Operation.objects.filter(pk=op.pk).update(finished_at=timezone.now() - timedelta(days=400))
|
||||
housekeeping()
|
||||
self.assertFalse(Operation.objects.filter(pk=op.pk).exists())
|
||||
self.assertEqual([row[3] for row in self.logged()], ["Delete external snapshot: one (failed)"])
|
||||
self.assertEqual([row[3] for row in self.logged()], ["VM job-vm: delete external snapshot one (failed)"])
|
||||
|
||||
def test_precheck_refuses_a_moved_vm_and_an_inactive_user(self):
|
||||
op = self.submitted()
|
||||
|
|
|
|||
161
instances/tests/test_vm_audit_rows.py
Normal file
161
instances/tests/test_vm_audit_rows.py
Normal 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)
|
||||
|
|
@ -96,7 +96,7 @@ class JsonEndpointContentTypeTestCase(TestCase):
|
|||
self.assertJson(self.client.get(url))
|
||||
|
||||
def test_vm_logs_is_json(self):
|
||||
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.name])))
|
||||
self.assertJson(self.client.get(reverse("vm_logs", args=[self.instance.pk])))
|
||||
|
||||
|
||||
class TemplateHtmlConcatenationTestCase(TestCase):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from django.conf import settings
|
|||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from libvirt import libvirtError
|
||||
from logs.views import addlogmsg
|
||||
from logs.audit import reason, write
|
||||
from vrtManager.instance import wvmInstance, wvmInstances
|
||||
|
||||
from .models import Instance
|
||||
|
|
@ -159,8 +159,14 @@ def migrate_instance(
|
|||
compress=False,
|
||||
):
|
||||
"""Migrates instance to new_compute and logs the outcome (the mode
|
||||
used, or the error). Returns the mode."""
|
||||
source = instance.compute
|
||||
used, or the error), then each later step that failed in a row of its
|
||||
own. Returns the mode."""
|
||||
source, target = instance.compute, migration_target(new_compute)
|
||||
|
||||
def log(action, error=None):
|
||||
message = f"VM {instance.name}: {action}" + (f": {reason(error, user)}" if error is not None else "")
|
||||
write(user.username, source.name, instance.name, message, error is None, uuid=instance.uuid)
|
||||
|
||||
conn_migrate = None
|
||||
try:
|
||||
conn_migrate = wvmInstances(
|
||||
|
|
@ -183,11 +189,7 @@ def migrate_instance(
|
|||
uri=new_compute.migration_uri,
|
||||
)
|
||||
except Exception as error:
|
||||
msg = _("Instance migration to %(target)s failed: %(error)s") % {
|
||||
"target": migration_target(new_compute),
|
||||
"error": error,
|
||||
}
|
||||
addlogmsg(user.username, source.hostname, instance.name, msg)
|
||||
log(f"migrate to {target}", error)
|
||||
raise
|
||||
finally:
|
||||
if conn_migrate is not None:
|
||||
|
|
@ -195,10 +197,7 @@ def migrate_instance(
|
|||
|
||||
# The VM runs on new_compute now: what follows must not stop it
|
||||
# from being recorded there.
|
||||
msg = _("Instance is migrated(%(mode)s) to %(target)s") % {
|
||||
"mode": mode,
|
||||
"target": migration_target(new_compute),
|
||||
}
|
||||
log(f"migrate ({mode}) to {target}")
|
||||
if autostart:
|
||||
conn_new = None
|
||||
try:
|
||||
|
|
@ -212,7 +211,7 @@ def migrate_instance(
|
|||
)
|
||||
conn_new.set_autostart(1)
|
||||
except (libvirtError, OSError) as error:
|
||||
msg = _("%(msg)s; setting its autostart there failed: %(error)s") % {"msg": msg, "error": error}
|
||||
log(f"restore autostart on {target}", error)
|
||||
finally:
|
||||
if conn_new is not None:
|
||||
conn_new.close()
|
||||
|
|
@ -227,11 +226,7 @@ def migrate_instance(
|
|||
)
|
||||
instance.proxy.remove_nvram(nvram, conn_new)
|
||||
except Exception as error: # the migration stands and is recorded
|
||||
msg = _("%(msg)s; removing its NVRAM file %(path)s on the source failed: %(error)s") % {
|
||||
"msg": msg,
|
||||
"path": nvram,
|
||||
"error": error,
|
||||
}
|
||||
log(f"remove NVRAM file {nvram} on the source", error)
|
||||
finally:
|
||||
if conn_new is not None:
|
||||
conn_new.close()
|
||||
|
|
@ -239,11 +234,8 @@ def migrate_instance(
|
|||
try:
|
||||
record_migration(instance, new_compute)
|
||||
except Exception as error:
|
||||
failed = _("%(msg)s, but recording it failed: %(error)s") % {"msg": msg, "error": error}
|
||||
addlogmsg(user.username, source.hostname, instance.name, failed)
|
||||
log(f"record the move to {target}", error)
|
||||
raise
|
||||
|
||||
addlogmsg(user.username, source.hostname, instance.name, msg)
|
||||
return mode
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@ from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
|
|||
VIR_DOMAIN_UNDEFINE_NVRAM,
|
||||
VIR_ERR_NO_SUPPORT,
|
||||
libvirtError)
|
||||
from logs.audit import audit, changed_fields, failed
|
||||
from logs.audit import audit, audit_vm, changed_fields, failed_vm
|
||||
from logs.audit import snapshot as audit_snapshot
|
||||
from logs.views import addlogmsg
|
||||
from operations import claims, jobs
|
||||
from operations.utils import busy_message, summary
|
||||
from webvirtcloud.middleware import error_text, same_origin_referer
|
||||
|
|
@ -179,19 +178,6 @@ def _instance_page(request, pk, instance, compute):
|
|||
|
||||
# clone_instance_auto_name = app_settings.CLONE_INSTANCE_AUTO_NAME
|
||||
|
||||
# try:
|
||||
# instance = Instance.objects.get(compute=compute, name=vname)
|
||||
# if instance.uuid != uuid:
|
||||
# instance.uuid = uuid
|
||||
# instance.save()
|
||||
# msg = _(f"Fixing UUID {uuid}")
|
||||
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
# except Instance.DoesNotExist:
|
||||
# instance = Instance(compute=compute, name=vname, uuid=uuid)
|
||||
# instance.save()
|
||||
# msg = _("Instance does not exist: Creating new instance")
|
||||
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
|
||||
# userinstances = UserInstance.objects.filter(instance=instance).order_by('user__username')
|
||||
userinstances = instance.userinstance_set.order_by("user__username")
|
||||
# Host resources
|
||||
|
|
@ -578,10 +564,8 @@ def poweron(request, pk):
|
|||
if instance.is_template:
|
||||
messages.warning(request, _("Templates cannot be started."))
|
||||
else:
|
||||
instance.proxy.start()
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, _("Power On")
|
||||
)
|
||||
with audit_vm(request, instance, "power on"):
|
||||
instance.proxy.start()
|
||||
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
|
|
@ -595,11 +579,9 @@ def powercycle(request, pk):
|
|||
if instance.is_template:
|
||||
messages.warning(request, _("Templates cannot be started."))
|
||||
else:
|
||||
instance.proxy.force_shutdown()
|
||||
instance.proxy.start()
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
|
||||
)
|
||||
with audit_vm(request, instance, "power cycle"):
|
||||
instance.proxy.force_shutdown()
|
||||
instance.proxy.start()
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
)
|
||||
|
|
@ -609,10 +591,8 @@ def powercycle(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def poweroff(request, pk):
|
||||
instance = get_instance(request.user, pk, perm_type="power")
|
||||
instance.proxy.shutdown()
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, _("Power Off")
|
||||
)
|
||||
with audit_vm(request, instance, "power off"):
|
||||
instance.proxy.shutdown()
|
||||
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
|
|
@ -624,8 +604,8 @@ def poweroff(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def suspend(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.suspend()
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend"))
|
||||
with audit_vm(request, instance, "suspend"):
|
||||
instance.proxy.suspend()
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
)
|
||||
|
|
@ -636,8 +616,8 @@ def suspend(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def resume(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.resume()
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume"))
|
||||
with audit_vm(request, instance, "resume"):
|
||||
instance.proxy.resume()
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
)
|
||||
|
|
@ -647,10 +627,8 @@ def resume(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def force_off(request, pk):
|
||||
instance = get_instance(request.user, pk, perm_type="power")
|
||||
instance.proxy.force_shutdown()
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, _("Force Off")
|
||||
)
|
||||
with audit_vm(request, instance, "force off"):
|
||||
instance.proxy.force_shutdown()
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id])
|
||||
)
|
||||
|
|
@ -661,37 +639,37 @@ def destroy(request, pk):
|
|||
if request.method in ["POST", "DELETE"]:
|
||||
instance = get_instance(request.user, pk, perm_type="delete")
|
||||
with libvirt_instance_lock(instance):
|
||||
proxy = instance.proxy
|
||||
# Paused and other active states must stop too, or the VM keeps
|
||||
# running as a transient domain after the undefine.
|
||||
if proxy.instance.isActive():
|
||||
proxy.force_shutdown()
|
||||
with audit_vm(request, instance, "delete"):
|
||||
proxy = instance.proxy
|
||||
# Paused and other active states must stop too, or the VM keeps
|
||||
# running as a transient domain after the undefine.
|
||||
if proxy.instance.isActive():
|
||||
proxy.force_shutdown()
|
||||
|
||||
to_delete, shared = [], []
|
||||
if request.POST.get("delete_disk", ""):
|
||||
to_delete, shared = proxy.split_disk_paths_by_use()
|
||||
to_delete, shared = [], []
|
||||
if request.POST.get("delete_disk", ""):
|
||||
to_delete, shared = proxy.split_disk_paths_by_use()
|
||||
|
||||
# Undefine before deleting any disk: if it fails, nothing is lost.
|
||||
# Snapshot metadata goes with the domain; internal snapshot data
|
||||
# stays in the disk images that are kept.
|
||||
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
|
||||
if request.POST.get("delete_nvram", ""):
|
||||
flags |= VIR_DOMAIN_UNDEFINE_NVRAM
|
||||
else:
|
||||
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
|
||||
proxy.delete(flags)
|
||||
instance.delete()
|
||||
# Undefine before deleting any disk: if it fails, nothing is lost.
|
||||
# Snapshot metadata goes with the domain; internal snapshot data
|
||||
# stays in the disk images that are kept.
|
||||
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
|
||||
if request.POST.get("delete_nvram", ""):
|
||||
flags |= VIR_DOMAIN_UNDEFINE_NVRAM
|
||||
else:
|
||||
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
|
||||
proxy.delete(flags)
|
||||
instance.delete()
|
||||
|
||||
# each disk has its own row: the VM is gone even if one is kept
|
||||
for path in to_delete:
|
||||
try:
|
||||
proxy.get_volume_by_path(path).delete(0)
|
||||
with audit_vm(request, instance, f"delete volume {path}"):
|
||||
proxy.get_volume_by_path(path).delete(0)
|
||||
except libvirtError as err:
|
||||
messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)})
|
||||
for path in shared:
|
||||
messages.warning(request, _("Disk %(path)s is used by another VM and was kept") % {"path": path})
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, _("Destroy")
|
||||
)
|
||||
return redirect(reverse("instances:index"))
|
||||
|
||||
instance = get_instance(request.user, pk, perm_type="delete")
|
||||
|
|
@ -742,20 +720,24 @@ GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GST
|
|||
|
||||
|
||||
def gstfsd_request(hostname, data):
|
||||
"""Send one request to gstfsd and return its JSON reply, or an error reply."""
|
||||
"""Send one request to gstfsd and return its JSON reply; an error reply or
|
||||
a failed exchange raises OperationError with the reason."""
|
||||
try:
|
||||
# the host name may resolve to IPv4 or IPv6
|
||||
s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
|
||||
except OSError as err:
|
||||
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
|
||||
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
|
||||
try:
|
||||
s.sendall(json.dumps(data).encode())
|
||||
# gstfsd sends one reply and closes the connection
|
||||
return json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
|
||||
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
|
||||
except (OSError, ValueError) as err:
|
||||
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
|
||||
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
|
||||
finally:
|
||||
s.close()
|
||||
if reply["return"] != "success":
|
||||
raise util.OperationError(reply["message"])
|
||||
return reply
|
||||
|
||||
|
||||
@require_POST
|
||||
|
|
@ -771,15 +753,13 @@ def set_root_pass(request, pk):
|
|||
data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()}
|
||||
|
||||
if instance.proxy.get_status() == 5:
|
||||
result = gstfsd_request(instance.compute.hostname, data)
|
||||
if result["return"] == "success":
|
||||
msg = _("Reset root password")
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
messages.success(request, msg)
|
||||
try:
|
||||
with audit_vm(request, instance, "reset root password"):
|
||||
gstfsd_request(instance.compute.hostname, data)
|
||||
except util.OperationError as err:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
messages.success(request, _("Reset root password"))
|
||||
else:
|
||||
msg = _("Please shutdown down your instance and then try again")
|
||||
messages.error(request, msg)
|
||||
|
|
@ -808,19 +788,15 @@ def add_public_key(request, pk):
|
|||
}
|
||||
|
||||
if instance.proxy.get_status() == 5:
|
||||
result = gstfsd_request(instance.compute.hostname, data)
|
||||
if result["return"] == "error":
|
||||
msg = result["message"]
|
||||
try:
|
||||
with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
|
||||
gstfsd_request(instance.compute.hostname, data)
|
||||
except util.OperationError as err:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
msg = _("Installed new SSH public key %(keyname)s") % {
|
||||
messages.success(request, _("Installed new SSH public key %(keyname)s") % {
|
||||
"keyname": publickey.keyname
|
||||
}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
|
||||
if result["return"] == "success":
|
||||
messages.success(request, msg)
|
||||
else:
|
||||
messages.error(request, msg)
|
||||
})
|
||||
else:
|
||||
msg = _("Please shutdown down your instance and then try again")
|
||||
messages.error(request, msg)
|
||||
|
|
@ -856,14 +832,12 @@ def resizevm_cpu(request, pk):
|
|||
else:
|
||||
cur_vcpu = new_cur_vcpu
|
||||
vcpu = new_vcpu
|
||||
instance.proxy.resize_cpu(cur_vcpu, vcpu)
|
||||
with audit_vm(request, instance, f"set vCPUs to {cur_vcpu} (maximum {vcpu})"):
|
||||
instance.proxy.resize_cpu(cur_vcpu, vcpu)
|
||||
msg = _("CPU is resized: %(old)s to %(new)s") % {
|
||||
"old": cur_vcpu,
|
||||
"new": vcpu,
|
||||
}
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
messages.success(request, msg)
|
||||
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
|
||||
|
||||
|
|
@ -902,7 +876,8 @@ def resize_memory(request, pk):
|
|||
})
|
||||
messages.error(request, msg)
|
||||
else:
|
||||
instance.proxy.resize_mem(new_cur_memory, new_memory)
|
||||
with audit_vm(request, instance, f"set memory to {new_cur_memory} MiB (maximum {new_memory} MiB)"):
|
||||
instance.proxy.resize_mem(new_cur_memory, new_memory)
|
||||
msg = _(
|
||||
"Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s"
|
||||
) % {
|
||||
|
|
@ -911,9 +886,6 @@ def resize_memory(request, pk):
|
|||
"new_cur": new_cur_memory,
|
||||
"new_max": new_memory,
|
||||
}
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
messages.success(request, msg)
|
||||
|
||||
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
|
||||
|
|
@ -955,18 +927,15 @@ def resize_disk(request, pk):
|
|||
else:
|
||||
for disk in disks_new:
|
||||
try:
|
||||
instance.proxy.resize_disk([disk])
|
||||
with audit_vm(request, instance, f"resize disk {disk['dev']} to {disk['size_new'] >> 30} GiB"):
|
||||
instance.proxy.resize_disk([disk])
|
||||
except libvirtError as err:
|
||||
# an LVM pool, for one, cannot resize its volumes
|
||||
if err.get_error_code() != VIR_ERR_NO_SUPPORT:
|
||||
raise
|
||||
messages.error(request, _("The storage pool of %(dev)s cannot resize its volumes") % {"dev": disk["dev"]})
|
||||
continue
|
||||
msg = _("Disk is resized: %(dev)s") % {"dev": disk["dev"]}
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
messages.success(request, msg)
|
||||
messages.success(request, _("Disk is resized: %(dev)s") % {"dev": disk["dev"]})
|
||||
|
||||
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
|
||||
|
||||
|
|
@ -990,61 +959,57 @@ def add_new_vol(request, pk):
|
|||
if invalid:
|
||||
return reject_disk_options(request, pk, invalid)
|
||||
|
||||
conn_create = wvmCreate(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
)
|
||||
# a name either definition uses is taken: on an active VM a disk can be
|
||||
# pending in the persistent one only
|
||||
target_dev = utils.get_new_disk_dev(
|
||||
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
||||
)
|
||||
with audit_vm(request, instance, f"attach new disk {storage}/{name}"):
|
||||
conn_create = wvmCreate(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
)
|
||||
# a name either definition uses is taken: on an active VM a disk can be
|
||||
# pending in the persistent one only
|
||||
target_dev = utils.get_new_disk_dev(
|
||||
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
||||
)
|
||||
|
||||
source = conn_create.create_volume(
|
||||
storage,
|
||||
name,
|
||||
size,
|
||||
format,
|
||||
meta_prealloc,
|
||||
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
|
||||
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
|
||||
)
|
||||
source = conn_create.create_volume(
|
||||
storage,
|
||||
name,
|
||||
size,
|
||||
format,
|
||||
meta_prealloc,
|
||||
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
|
||||
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
|
||||
)
|
||||
|
||||
conn_pool = wvmStorage(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
storage,
|
||||
)
|
||||
conn_pool = wvmStorage(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
storage,
|
||||
)
|
||||
|
||||
pool_type = conn_pool.get_type()
|
||||
disk_type = conn_pool.get_volume_type(os.path.basename(source))
|
||||
# the pool decides: only a dir pool keeps the asked format, the others create raw
|
||||
format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
|
||||
pool_type = conn_pool.get_type()
|
||||
disk_type = conn_pool.get_volume_type(os.path.basename(source))
|
||||
# the pool decides: only a dir pool keeps the asked format, the others create raw
|
||||
format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
|
||||
|
||||
if pool_type == "rbd":
|
||||
source_info = conn_pool.get_rbd_source()
|
||||
else: # add more disk types to handle different pool and disk types
|
||||
source_info = None
|
||||
if pool_type == "rbd":
|
||||
source_info = conn_pool.get_rbd_source()
|
||||
else: # add more disk types to handle different pool and disk types
|
||||
source_info = None
|
||||
|
||||
instance.proxy.attach_disk(
|
||||
target_dev,
|
||||
source,
|
||||
source_info=source_info,
|
||||
pool_type=pool_type,
|
||||
disk_type=disk_type,
|
||||
target_bus=bus,
|
||||
format_type=format,
|
||||
cache_mode=cache,
|
||||
)
|
||||
msg = _("Attach new disk: %(name)s (%(format)s)") % {
|
||||
"name": name,
|
||||
"format": format,
|
||||
}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
instance.proxy.attach_disk(
|
||||
target_dev,
|
||||
source,
|
||||
source_info=source_info,
|
||||
pool_type=pool_type,
|
||||
disk_type=disk_type,
|
||||
target_bus=bus,
|
||||
format_type=format,
|
||||
cache_mode=cache,
|
||||
)
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
||||
|
|
@ -1091,18 +1056,17 @@ def add_existing_vol(request, pk):
|
|||
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
||||
)
|
||||
|
||||
instance.proxy.attach_disk(
|
||||
target_dev,
|
||||
source,
|
||||
source_info=source_info,
|
||||
pool_type=pool_type,
|
||||
disk_type=disk_type,
|
||||
target_bus=bus,
|
||||
format_type=format_type,
|
||||
cache_mode=cache,
|
||||
)
|
||||
msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"attach disk {storage}/{name} as {target_dev}"):
|
||||
instance.proxy.attach_disk(
|
||||
target_dev,
|
||||
source,
|
||||
source_info=source_info,
|
||||
pool_type=pool_type,
|
||||
disk_type=disk_type,
|
||||
target_bus=bus,
|
||||
format_type=format_type,
|
||||
cache_mode=cache,
|
||||
)
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
||||
|
|
@ -1152,19 +1116,20 @@ def edit_volume(request, pk):
|
|||
if invalid:
|
||||
return reject_disk_options(request, pk, invalid)
|
||||
|
||||
instance.proxy.edit_disk(
|
||||
target_dev,
|
||||
new_path,
|
||||
readonly,
|
||||
shareable,
|
||||
new_bus,
|
||||
serial,
|
||||
format,
|
||||
cache,
|
||||
io,
|
||||
discard,
|
||||
zeroes,
|
||||
)
|
||||
with audit_vm(request, instance, f"edit disk {target_dev}"):
|
||||
instance.proxy.edit_disk(
|
||||
target_dev,
|
||||
new_path,
|
||||
readonly,
|
||||
shareable,
|
||||
new_bus,
|
||||
serial,
|
||||
format,
|
||||
cache,
|
||||
io,
|
||||
discard,
|
||||
zeroes,
|
||||
)
|
||||
|
||||
if not instance.proxy.get_status() == 5:
|
||||
messages.success(
|
||||
|
|
@ -1176,8 +1141,6 @@ def edit_volume(request, pk):
|
|||
)
|
||||
else:
|
||||
messages.success(request, _("Volume is changed successfully."))
|
||||
msg = _("Edit disk: %(target_dev)s") % {"target_dev": target_dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
|
@ -1221,10 +1184,12 @@ def delete_vol(request, pk):
|
|||
)
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
msg = _("Delete disk: %(dev)s") % {"dev": dev}
|
||||
instance.proxy.detach_disk(dev)
|
||||
volume = f"{disk['storage']}/{disk['image']}"
|
||||
with audit_vm(request, instance, f"detach disk {dev}"):
|
||||
instance.proxy.detach_disk(dev)
|
||||
# Never delete a volume the guest may still be writing to.
|
||||
if not instance.proxy.wait_disk_detached(dev):
|
||||
failed_vm(request, instance, f"delete volume {volume}", "the guest has not released the disk")
|
||||
messages.warning(
|
||||
request,
|
||||
_("The guest has not released disk %(dev)s yet; volume %(vol)s was kept. "
|
||||
|
|
@ -1233,14 +1198,13 @@ def delete_vol(request, pk):
|
|||
)
|
||||
return _back(request, pk, "disks")
|
||||
try:
|
||||
conn_delete.del_volume(disk["image"])
|
||||
with audit_vm(request, instance, f"delete volume {volume}"):
|
||||
conn_delete.del_volume(disk["image"])
|
||||
except util.OperationError as err: # a file of the VM's own snapshots
|
||||
messages.warning(
|
||||
request,
|
||||
_("Disk %(dev)s was detached, but its volume was kept: %(err)s") % {"dev": dev, "err": err},
|
||||
)
|
||||
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
||||
|
|
@ -1250,9 +1214,8 @@ def delete_vol(request, pk):
|
|||
def detach_vol(request, pk):
|
||||
instance = get_instance(request.user, pk, perm_type="change")
|
||||
dev = request.POST.get("dev", "")
|
||||
instance.proxy.detach_disk(dev)
|
||||
msg = _("Detach disk: %(dev)s") % {"dev": dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"detach disk {dev}"):
|
||||
instance.proxy.detach_disk(dev)
|
||||
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
|
@ -1270,16 +1233,15 @@ def add_cdrom(request, pk):
|
|||
target = utils.get_new_disk_dev(
|
||||
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
||||
)
|
||||
instance.proxy.attach_disk(
|
||||
target,
|
||||
"",
|
||||
disk_device="cdrom",
|
||||
cache_mode="none",
|
||||
target_bus=bus,
|
||||
readonly=True,
|
||||
)
|
||||
msg = _("Add CD-ROM: %(target)s") % {"target": target}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"add CD-ROM {target}"):
|
||||
instance.proxy.attach_disk(
|
||||
target,
|
||||
"",
|
||||
disk_device="cdrom",
|
||||
cache_mode="none",
|
||||
target_bus=bus,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
|
@ -1289,10 +1251,8 @@ def add_cdrom(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def detach_cdrom(request, pk, dev):
|
||||
instance = get_instance(request.user, pk, perm_type="change")
|
||||
# dev = request.POST.get('detach_cdrom', '')
|
||||
instance.proxy.detach_disk(dev)
|
||||
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"remove CD-ROM {dev}"):
|
||||
instance.proxy.detach_disk(dev)
|
||||
|
||||
return _back(request, pk, "disks")
|
||||
|
||||
|
|
@ -1304,12 +1264,10 @@ def unmount_iso(request, pk):
|
|||
instance = get_instance(request.user, pk, perm_type="change")
|
||||
dev = request.POST.get("umount_iso", "")
|
||||
try:
|
||||
instance.proxy.umount_iso(dev)
|
||||
with audit_vm(request, instance, f"eject media from {dev}"):
|
||||
instance.proxy.umount_iso(dev)
|
||||
except libvirtError as err:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
msg = _("Unmount media: %(dev)s") % {"dev": dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
|
||||
|
||||
|
||||
|
|
@ -1321,12 +1279,10 @@ def mount_iso(request, pk):
|
|||
image = request.POST.get("media", "")
|
||||
dev = request.POST.get("mount_iso", "")
|
||||
try:
|
||||
instance.proxy.mount_iso(dev, image)
|
||||
with audit_vm(request, instance, f"insert media {image} into {dev}"):
|
||||
instance.proxy.mount_iso(dev, image)
|
||||
except libvirtError as err:
|
||||
messages.error(request, err)
|
||||
else:
|
||||
msg = _("Mount media: %(dev)s") % {"dev": dev}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
|
||||
|
||||
|
||||
|
|
@ -1345,12 +1301,10 @@ def delete_snapshot(request, pk):
|
|||
raise PermissionDenied
|
||||
snap_name = request.POST.get("name", "")
|
||||
try:
|
||||
instance.proxy.snapshot_delete(snap_name)
|
||||
with audit_vm(request, instance, f"delete snapshot {snap_name}"):
|
||||
instance.proxy.snapshot_delete(snap_name)
|
||||
except libvirtError as err:
|
||||
messages.error(request, _("Snapshot was not deleted: %(err)s") % {"err": error_text(request, err)})
|
||||
return _back(request, pk, "managesnapshot")
|
||||
msg = _("Delete snapshot: %(snap)s") % {"snap": snap_name}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
return _back(request, pk, "managesnapshot")
|
||||
|
||||
|
||||
|
|
@ -1406,13 +1360,9 @@ def delete_external_snapshot(request, pk):
|
|||
def set_vcpu(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
id = request.POST.get("id", "")
|
||||
enabled = request.POST.get("set_vcpu", "")
|
||||
if enabled == "True":
|
||||
instance.proxy.set_vcpu(id, 1)
|
||||
else:
|
||||
instance.proxy.set_vcpu(id, 0)
|
||||
msg = _("VCPU %(id)s is enabled=%(enabled)s") % {"id": id, "enabled": enabled}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
enabled = request.POST.get("set_vcpu", "") == "True"
|
||||
with audit_vm(request, instance, f"{'enable' if enabled else 'disable'} vCPU {id}"):
|
||||
instance.proxy.set_vcpu(id, 1 if enabled else 0)
|
||||
return _back(request, pk, "resize")
|
||||
|
||||
|
||||
|
|
@ -1422,9 +1372,8 @@ def set_vcpu(request, pk):
|
|||
def set_vcpu_hotplug(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False
|
||||
msg = _("VCPU Hot-plug is enabled=%(status)s") % {"status": status}
|
||||
instance.proxy.set_vcpu_hotplug(status)
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"{'enable' if status else 'disable'} vCPU hot-plug"):
|
||||
instance.proxy.set_vcpu_hotplug(status)
|
||||
return _back(request, pk, "resize")
|
||||
|
||||
|
||||
|
|
@ -1433,9 +1382,8 @@ def set_vcpu_hotplug(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def set_autostart(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.set_autostart(1)
|
||||
msg = _("Set autostart")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "enable autostart"):
|
||||
instance.proxy.set_autostart(1)
|
||||
return _back(request, pk, "boot_opt")
|
||||
|
||||
|
||||
|
|
@ -1444,9 +1392,8 @@ def set_autostart(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def unset_autostart(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.set_autostart(0)
|
||||
msg = _("Unset autostart")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "disable autostart"):
|
||||
instance.proxy.set_autostart(0)
|
||||
return _back(request, pk, "boot_opt")
|
||||
|
||||
|
||||
|
|
@ -1455,9 +1402,8 @@ def unset_autostart(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def set_bootmenu(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.set_bootmenu(1)
|
||||
msg = _("Enable boot menu")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "enable boot menu"):
|
||||
instance.proxy.set_bootmenu(1)
|
||||
return _back(request, pk, "boot_opt")
|
||||
|
||||
|
||||
|
|
@ -1466,9 +1412,8 @@ def set_bootmenu(request, pk):
|
|||
@serialize_instance_mutation
|
||||
def unset_bootmenu(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
instance.proxy.set_bootmenu(0)
|
||||
msg = _("Disable boot menu")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "disable boot menu"):
|
||||
instance.proxy.set_bootmenu(0)
|
||||
return _back(request, pk, "boot_opt")
|
||||
|
||||
|
||||
|
|
@ -1484,11 +1429,11 @@ def set_bootorder(request, pk):
|
|||
dev_type, dev = val.split(":", 1)
|
||||
order_list[idx] = {"type": dev_type, "dev": dev}
|
||||
try:
|
||||
instance.proxy.set_bootorder(order_list)
|
||||
with audit_vm(request, instance, "change boot order"):
|
||||
instance.proxy.set_bootorder(order_list)
|
||||
except util.OperationError as err:
|
||||
messages.error(request, err)
|
||||
return _back(request, pk, "boot_opt")
|
||||
msg = _("Set boot order")
|
||||
|
||||
if not instance.proxy.get_status() == 5:
|
||||
messages.success(
|
||||
|
|
@ -1500,7 +1445,6 @@ def set_bootorder(request, pk):
|
|||
)
|
||||
else:
|
||||
messages.success(request, _("Boot order changed successfully."))
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
return _back(request, pk, "boot_opt")
|
||||
|
||||
|
||||
|
|
@ -1515,9 +1459,8 @@ def change_xml(request, pk):
|
|||
if new_xml and instance.proxy.get_status() != 5:
|
||||
messages.error(request, _("Power off the instance to change its XML"))
|
||||
elif new_xml:
|
||||
instance.proxy._defineXML(new_xml)
|
||||
msg = _("Change instance XML")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "change XML"):
|
||||
instance.proxy._defineXML(new_xml)
|
||||
return _back(request, pk, "xmledit")
|
||||
|
||||
|
||||
|
|
@ -1528,12 +1471,11 @@ def set_guest_agent(request, pk):
|
|||
instance = get_instance(request.user, pk)
|
||||
status = request.POST.get("guest_agent")
|
||||
if status == "True":
|
||||
instance.proxy.add_guest_agent()
|
||||
with audit_vm(request, instance, "add guest agent channel"):
|
||||
instance.proxy.add_guest_agent()
|
||||
if status == "False":
|
||||
instance.proxy.remove_guest_agent()
|
||||
|
||||
msg = _("Set Guest Agent: %(status)s") % {"status": status}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "remove guest agent channel"):
|
||||
instance.proxy.remove_guest_agent()
|
||||
return _back(request, pk, "options")
|
||||
|
||||
|
||||
|
|
@ -1543,9 +1485,8 @@ def set_guest_agent(request, pk):
|
|||
def set_video_model(request, pk):
|
||||
instance = get_instance(request.user, pk)
|
||||
video_model = request.POST.get("video_model", "vga")
|
||||
instance.proxy.set_video_model(video_model)
|
||||
msg = _("Set Video Model: %(model)s") % {"model": video_model}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"set video model {video_model}"):
|
||||
instance.proxy.set_video_model(video_model)
|
||||
return _back(request, pk, "options")
|
||||
|
||||
|
||||
|
|
@ -1571,31 +1512,29 @@ def change_network(request, pk):
|
|||
return redirect(back)
|
||||
|
||||
(source, source_type) = utils.get_network_tuple(request.POST.get(f"net-source-{num}", ""))
|
||||
if source_type == "iface":
|
||||
iface = wvmInterface(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
source,
|
||||
)
|
||||
source_type = iface.get_type()
|
||||
|
||||
try:
|
||||
instance.proxy.change_network(
|
||||
old_mac,
|
||||
mac,
|
||||
source,
|
||||
source_type,
|
||||
request.POST.get(f"net-model-{num}"),
|
||||
request.POST.get(f"net-nwfilter-{num}", ""),
|
||||
)
|
||||
with audit_vm(request, instance, f"change interface {old_mac}"):
|
||||
if source_type == "iface":
|
||||
iface = wvmInterface(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
source,
|
||||
)
|
||||
source_type = iface.get_type()
|
||||
instance.proxy.change_network(
|
||||
old_mac,
|
||||
mac,
|
||||
source,
|
||||
source_type,
|
||||
request.POST.get(f"net-model-{num}"),
|
||||
request.POST.get(f"net-nwfilter-{num}", ""),
|
||||
)
|
||||
except libvirtError as err:
|
||||
messages.error(request, err)
|
||||
return redirect(back)
|
||||
|
||||
msg = _("Change network: %(mac)s") % {"mac": old_mac}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
msg = _("Network Device Config is changed. Please shutdown instance to activate.")
|
||||
if instance.proxy.get_status() != 5:
|
||||
messages.success(request, msg)
|
||||
|
|
@ -1617,19 +1556,17 @@ def add_network(request, pk):
|
|||
(source, source_type) = utils.get_network_tuple(network)
|
||||
model = request.POST.get("add-net-model")
|
||||
|
||||
if source_type == "iface":
|
||||
iface = wvmInterface(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
source,
|
||||
)
|
||||
source_type = iface.get_type()
|
||||
|
||||
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter)
|
||||
msg = _("Add network: %(mac)s") % {"mac": mac}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"add interface on {source}"):
|
||||
if source_type == "iface":
|
||||
iface = wvmInterface(
|
||||
instance.compute.hostname,
|
||||
instance.compute.login,
|
||||
instance.compute.password,
|
||||
instance.compute.type,
|
||||
source,
|
||||
)
|
||||
source_type = iface.get_type()
|
||||
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter)
|
||||
return _back(request, pk, "network")
|
||||
|
||||
|
||||
|
|
@ -1640,9 +1577,8 @@ def delete_network(request, pk):
|
|||
instance = get_instance(request.user, pk)
|
||||
mac_address = request.POST.get("delete_network", "")
|
||||
|
||||
instance.proxy.delete_network(mac_address)
|
||||
msg = _("Delete Network: %(mac)s") % {"mac": mac_address}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"remove interface {mac_address}"):
|
||||
instance.proxy.delete_network(mac_address)
|
||||
return _back(request, pk, "network")
|
||||
|
||||
|
||||
|
|
@ -1655,9 +1591,8 @@ def set_link_state(request, pk):
|
|||
mac_address = request.POST.get("mac", "")
|
||||
state = request.POST.get("set_link_state")
|
||||
state = "down" if state == "up" else "up"
|
||||
instance.proxy.set_link_state(mac_address, state)
|
||||
msg = _("Set Link State: %(state)s") % {"state": state}
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, f"set link {state} on interface {mac_address}"):
|
||||
instance.proxy.set_link_state(mac_address, state)
|
||||
return _back(request, pk, "network")
|
||||
|
||||
|
||||
|
|
@ -1680,7 +1615,8 @@ def set_qos(request, pk):
|
|||
messages.error(request, _("QoS rates must be whole numbers"))
|
||||
return _back(request, pk, "network")
|
||||
|
||||
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
|
||||
with audit_vm(request, instance, f"set {qos_dir} QoS on interface {mac}"):
|
||||
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
|
||||
if instance.proxy.get_status() == 5:
|
||||
messages.success(
|
||||
request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()}
|
||||
|
|
@ -1705,7 +1641,8 @@ def unset_qos(request, pk):
|
|||
if not mac or qos_dir not in ("inbound", "outbound"):
|
||||
messages.error(request, _("Select a network interface and a QoS direction"))
|
||||
return _back(request, pk, "network")
|
||||
instance.proxy.unset_qos(mac, qos_dir)
|
||||
with audit_vm(request, instance, f"remove {qos_dir} QoS from interface {mac}"):
|
||||
instance.proxy.unset_qos(mac, qos_dir)
|
||||
|
||||
if instance.proxy.get_status() == 5:
|
||||
messages.success(
|
||||
|
|
@ -1734,14 +1671,14 @@ def add_owner(request, pk):
|
|||
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
|
||||
check_inst = UserInstance.objects.filter(instance=instance).count()
|
||||
|
||||
subject = f"VM {instance.name} owner {user.username}"
|
||||
action = f"add owner {user.username}"
|
||||
if check_inst > 0:
|
||||
failed(request, instance.compute, subject, "add", "only one owner is allowed", instance=instance.name)
|
||||
failed_vm(request, instance, action, "only one owner is allowed")
|
||||
messages.error(
|
||||
request, _("Only one owner is allowed and the one already added")
|
||||
)
|
||||
else:
|
||||
with audit(request, instance.compute, subject, "add", instance=instance.name) as entry:
|
||||
with audit_vm(request, instance, action) as entry:
|
||||
entry.skip = not UserInstance.objects.get_or_create(instance=instance, user=user)[1]
|
||||
if entry.skip:
|
||||
messages.warning(request, _("%(user)s is already an owner") % {"user": user})
|
||||
|
|
@ -1756,8 +1693,7 @@ def del_owner(request, pk):
|
|||
if not userinstance_id.isdigit():
|
||||
raise Http404
|
||||
userinstance = get_object_or_404(UserInstance, pk=userinstance_id, instance=instance)
|
||||
with audit(request, instance.compute, f"VM {instance.name} owner {userinstance.user.username}", "remove",
|
||||
instance=instance.name):
|
||||
with audit_vm(request, instance, f"remove owner {userinstance.user.username}"):
|
||||
userinstance.delete()
|
||||
return _back(request, pk, "users")
|
||||
|
||||
|
|
@ -1803,8 +1739,8 @@ def update_console(request, pk):
|
|||
raise PermissionDenied
|
||||
form = ConsoleForm(request.POST or None, can_set_listener=request.user.is_superuser)
|
||||
if form.is_valid() and form.cleaned_data["console_type"] not in ("", instance.console_type):
|
||||
instance.proxy.set_console_type(form.cleaned_data["console_type"])
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Set console type"))
|
||||
with audit_vm(request, instance, f"set console type {form.cleaned_data['console_type']}"):
|
||||
instance.proxy.set_console_type(form.cleaned_data["console_type"])
|
||||
# the other fields are VNC settings; a new VNC console gets them too
|
||||
if form.is_valid() and form.cleaned_data["console_type"] != "pty":
|
||||
if (
|
||||
|
|
@ -1831,35 +1767,26 @@ def update_console(request, pk):
|
|||
request,
|
||||
_("The console listens beyond localhost; only an administrator can remove its password."),
|
||||
)
|
||||
elif not instance.proxy.set_console_passwd(password):
|
||||
msg = _(
|
||||
"Error setting console password. "
|
||||
+ "You should check that your instance have an graphic device."
|
||||
)
|
||||
messages.error(request, msg)
|
||||
else:
|
||||
msg = _("Set VNC password")
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
try:
|
||||
with audit_vm(request, instance, "set console password" if password else "remove console password"):
|
||||
if not instance.proxy.set_console_passwd(password):
|
||||
raise util.OperationError("The VM has no graphics device")
|
||||
except util.OperationError:
|
||||
msg = _(
|
||||
"Error setting console password. "
|
||||
+ "You should check that your instance have an graphic device."
|
||||
)
|
||||
messages.error(request, msg)
|
||||
|
||||
if "keymap" in form.changed_data or "clear_keymap" in form.changed_data:
|
||||
if form.cleaned_data["clear_keymap"]:
|
||||
instance.proxy.set_console_keymap("")
|
||||
else:
|
||||
instance.proxy.set_console_keymap(form.cleaned_data["keymap"])
|
||||
|
||||
msg = _("Set VNC keymap")
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
keymap = "" if form.cleaned_data["clear_keymap"] else form.cleaned_data["keymap"]
|
||||
with audit_vm(request, instance, f"set console keymap {keymap}" if keymap else "remove console keymap"):
|
||||
instance.proxy.set_console_keymap(keymap)
|
||||
|
||||
if "listen_on" in form.changed_data:
|
||||
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
|
||||
msg = _("Set VNC listen address")
|
||||
addlogmsg(
|
||||
request.user.username, instance.compute.name, instance.name, msg
|
||||
)
|
||||
with audit_vm(request, instance, f"set console listen address {form.cleaned_data['listen_on']}"):
|
||||
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
|
||||
if not form.is_valid():
|
||||
messages.error(request, _("Console settings were not saved: %(errors)s") % {"errors": form.errors.as_text()})
|
||||
|
||||
|
|
@ -1901,10 +1828,8 @@ def change_options(request, pk):
|
|||
for post in request.POST:
|
||||
if post in ["title", "description"]:
|
||||
options[post] = request.POST.get(post, "")
|
||||
instance.proxy.set_options(options)
|
||||
|
||||
msg = _("Edit options")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
with audit_vm(request, instance, "edit options"):
|
||||
instance.proxy.set_options(options)
|
||||
return get_safe_redirect(
|
||||
request, default=reverse("instances:instance", args=[instance.id]) + "#options"
|
||||
)
|
||||
|
|
@ -1915,8 +1840,6 @@ def getvvfile(request, pk):
|
|||
# The .vv file contains the VNC password: same rule as the console.
|
||||
if not utils.can_open_console(request.user, instance):
|
||||
raise PermissionDenied
|
||||
msg = _("Send console.vv file")
|
||||
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
|
||||
response = HttpResponse(
|
||||
content="",
|
||||
content_type="application/x-virt-viewer",
|
||||
|
|
@ -1925,7 +1848,7 @@ def getvvfile(request, pk):
|
|||
charset="utf-8",
|
||||
)
|
||||
# the VM found by its UUID; the stored name may be stale
|
||||
with instance.proxy.cached_reads() as proxy:
|
||||
with audit_vm(request, instance, "download console file"), instance.proxy.cached_reads() as proxy:
|
||||
listen = proxy.get_console_listener_addr()
|
||||
response.writelines("[virt-viewer]\n")
|
||||
response.writelines(f"type={proxy.get_console_type()}\n")
|
||||
|
|
@ -1982,13 +1905,17 @@ def create_instance_select_type(request, compute_id):
|
|||
xml = request.POST.get("dom_xml", "")
|
||||
try:
|
||||
name = util.get_xml_path(xml, "/domain/name")
|
||||
if name in instances:
|
||||
if not name:
|
||||
xml_error = _("The XML has no name")
|
||||
elif name in instances:
|
||||
xml_error = _("A virtual machine with this name already exists")
|
||||
else:
|
||||
with libvirt_compute_lock(compute):
|
||||
conn._defineXML(xml)
|
||||
utils.refr(compute)
|
||||
instance = compute.instance_set.get(name=name)
|
||||
with audit(request, compute, f"VM {name}", "define from XML", instance=name) as entry:
|
||||
conn._defineXML(xml)
|
||||
utils.refr(compute)
|
||||
instance = compute.instance_set.get(name=name)
|
||||
entry.uuid = instance.uuid
|
||||
return redirect(reverse("instances:instance", args=[instance.id]))
|
||||
except util.etree.Error as err:
|
||||
xml_error = _("The XML is not well-formed: %(error)s") % {"error": err}
|
||||
|
|
|
|||
|
|
@ -12,18 +12,19 @@ from logs.models import Logs
|
|||
logger = logging.getLogger("logs.audit")
|
||||
|
||||
|
||||
def write(user, host, instance, message, success=None, ip=None):
|
||||
def write(user, host, instance, message, success=None, ip=None, uuid=""):
|
||||
"""
|
||||
Save one Logs row. A failure is written to the log file instead and never
|
||||
raised, so an audit write cannot break the action it records.
|
||||
"""
|
||||
user, host, instance = user[:150], host[:255], instance[:120]
|
||||
user, host, instance, uuid = user[:150], host[:255], instance[:120], uuid[:36]
|
||||
try:
|
||||
with transaction.atomic():
|
||||
Logs.objects.create(user=user, host=host, instance=instance, message=message, success=success, ip=ip)
|
||||
Logs.objects.create(user=user, host=host, instance=instance, instance_uuid=uuid, message=message,
|
||||
success=success, ip=ip)
|
||||
except Exception as e:
|
||||
try:
|
||||
line = f"audit row not saved ({e!r}): {user} {host} {instance} success={success} ip={ip} {message}"
|
||||
line = f"audit row not saved ({e!r}): {user} {host} {instance} {uuid} success={success} ip={ip} {message}"
|
||||
logger.warning(line.replace("\r", "\\r").replace("\n", "\\n"))
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -44,6 +45,7 @@ def request_ip(request):
|
|||
class Entry:
|
||||
action: str
|
||||
skip: bool = False
|
||||
uuid: str = ""
|
||||
|
||||
|
||||
def host_of(compute):
|
||||
|
|
@ -69,26 +71,37 @@ def reason(exc, user):
|
|||
|
||||
|
||||
@contextmanager
|
||||
def audit(request, compute, subject, action, instance=""):
|
||||
def audit(request, compute, subject, action, instance="", uuid=""):
|
||||
"""
|
||||
Write one row when the block ends: done, or failed with the reason when it
|
||||
raises (the exception goes on). The block may set entry.action, or
|
||||
entry.skip when nothing was done.
|
||||
raises (the exception goes on). The block may set entry.action, entry.uuid,
|
||||
or entry.skip when nothing was done.
|
||||
"""
|
||||
entry = Entry(action)
|
||||
entry = Entry(action, uuid=uuid)
|
||||
user, host, ip = request.user.get_username(), host_of(compute), request_ip(request)
|
||||
try:
|
||||
yield entry
|
||||
except Exception as exc:
|
||||
write(user, host, instance, f"{subject}: {entry.action}: {reason(exc, request.user)}", False, ip)
|
||||
write(user, host, instance, f"{subject}: {entry.action}: {reason(exc, request.user)}", False, ip, entry.uuid)
|
||||
raise
|
||||
if not entry.skip:
|
||||
write(user, host, instance, f"{subject}: {entry.action}", True, ip)
|
||||
write(user, host, instance, f"{subject}: {entry.action}", True, ip, entry.uuid)
|
||||
|
||||
|
||||
def failed(request, compute, subject, action, why, instance=""):
|
||||
def failed(request, compute, subject, action, why, instance="", uuid=""):
|
||||
"""Write the failure row of an action that was refused before it started."""
|
||||
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False, request_ip(request))
|
||||
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False,
|
||||
request_ip(request), uuid)
|
||||
|
||||
|
||||
def audit_vm(request, instance, action):
|
||||
"""audit() for an operation on a VM: the row names the VM and carries its UUID."""
|
||||
return audit(request, instance.compute, f"VM {instance.name}", action, instance=instance.name, uuid=instance.uuid)
|
||||
|
||||
|
||||
def failed_vm(request, instance, action, why):
|
||||
"""failed() for an operation on a VM, such as one whose error came back as a reply."""
|
||||
failed(request, instance.compute, f"VM {instance.name}", action, why, instance=instance.name, uuid=instance.uuid)
|
||||
|
||||
|
||||
def snapshot(obj):
|
||||
|
|
|
|||
18
logs/migrations/0005_instance_uuid.py
Normal file
18
logs/migrations/0005_instance_uuid.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -5,6 +5,7 @@ class Logs(Model):
|
|||
user = CharField(_("user"), max_length=150)
|
||||
host = CharField(_("host"), max_length=255, default="-")
|
||||
instance = CharField(_("instance"), max_length=120)
|
||||
instance_uuid = CharField(_("instance UUID"), max_length=36, blank=True, default="", db_index=True)
|
||||
message = TextField(_("message"))
|
||||
date = DateTimeField(_("date"), auto_now_add=True, db_index=True)
|
||||
success = BooleanField(_("success"), null=True)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ from django.db import IntegrityError, transaction
|
|||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from computes.models import Compute
|
||||
from logs.audit import audit, changed_fields, failed, request_ip, snapshot, write
|
||||
from instances.models import Instance
|
||||
from logs.audit import audit, audit_vm, changed_fields, failed, failed_vm, request_ip, snapshot, write
|
||||
from logs.models import Logs
|
||||
from logs.views import addlogmsg
|
||||
from operations import claims
|
||||
from vrtManager.util import OperationError
|
||||
|
||||
|
|
@ -62,12 +62,9 @@ class WriteTests(TestCase):
|
|||
self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR="127.0.0.1", HTTP_X_REAL_IP="fe80::1%eth0")))
|
||||
self.assertIsNone(request_ip(rf.get("/", REMOTE_ADDR="")))
|
||||
|
||||
def test_addlogmsg_still_writes(self):
|
||||
addlogmsg("u", "h", "vm", "Destroy")
|
||||
row = Logs.objects.get()
|
||||
self.assertEqual((row.user, row.host, row.instance, row.message), ("u", "h", "vm", "Destroy"))
|
||||
self.assertIsNone(row.success)
|
||||
self.assertIsNone(row.ip)
|
||||
def test_a_row_without_a_vm_has_no_uuid(self):
|
||||
write("u", "h", "", "Network lab: start", True)
|
||||
self.assertEqual(Logs.objects.get().instance_uuid, "")
|
||||
|
||||
|
||||
class AuditTests(TestCase):
|
||||
|
|
@ -135,6 +132,26 @@ class AuditTests(TestCase):
|
|||
self.assertEqual(row.host, "-")
|
||||
self.assertIs(row.success, False)
|
||||
|
||||
def test_vm_rows_name_the_vm_and_carry_its_uuid(self):
|
||||
vm = Instance(compute=self.compute, name="web-1", uuid="11111111-2222-3333-4444-555555555555")
|
||||
with audit_vm(self.request, vm, "power on"):
|
||||
pass
|
||||
with self.assertRaises(OperationError):
|
||||
with audit_vm(self.request, vm, "power off"):
|
||||
raise OperationError("not running")
|
||||
failed_vm(self.request, vm, "reset root password", "gstfsd error: timed out")
|
||||
self.assertEqual(
|
||||
list(Logs.objects.order_by("id").values_list("host", "instance", "instance_uuid", "message", "success")), [
|
||||
("c", "web-1", vm.uuid, "VM web-1: power on", True),
|
||||
("c", "web-1", vm.uuid, "VM web-1: power off: not running", False),
|
||||
("c", "web-1", vm.uuid, "VM web-1: reset root password: gstfsd error: timed out", False),
|
||||
])
|
||||
|
||||
def test_the_block_may_set_the_uuid_it_learns(self):
|
||||
with audit(self.request, self.compute, "VM web-1", "define from XML", instance="web-1") as entry:
|
||||
entry.uuid = "11111111-2222-3333-4444-555555555555"
|
||||
self.assertEqual(self.row().instance_uuid, "11111111-2222-3333-4444-555555555555")
|
||||
|
||||
def test_changed_fields(self):
|
||||
compute = Compute.objects.create(name="c", hostname="192.0.2.5", type=1, password="pw")
|
||||
before = snapshot(compute)
|
||||
|
|
|
|||
|
|
@ -1,61 +1,60 @@
|
|||
import json
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, RequestFactory, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from logs.models import Logs
|
||||
from logs.views import addlogmsg
|
||||
from logs.audit import write
|
||||
|
||||
UUID = "12345678-1234-1234-1234-123456789012"
|
||||
|
||||
|
||||
class LogsTestCase(TestCase):
|
||||
class VMLogsTestCase(TestCase):
|
||||
"""The VM page's Logs tab: the rows of one VM, found by its UUID."""
|
||||
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="admin_logs", email="admin@example.com", password="adminpassword"
|
||||
)
|
||||
self.normal_user = User.objects.create_user(
|
||||
username="normal_logs", email="user@example.com", password="userpassword"
|
||||
)
|
||||
self.compute = Compute.objects.create(
|
||||
name="local_compute",
|
||||
hostname="localhost",
|
||||
type=1,
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="test-vm",
|
||||
uuid="12345678-1234-1234-1234-123456789012",
|
||||
)
|
||||
self.superuser = User.objects.create_superuser("admin_logs", "admin@example.com", "pw")
|
||||
self.normal_user = User.objects.create_user("normal_logs", "user@example.com", "pw")
|
||||
self.compute = Compute.objects.create(name="c1", hostname="192.0.2.1", type=1)
|
||||
self.instance = Instance.objects.create(compute=self.compute, name="test-vm", uuid=UUID)
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
def test_addlogmsg(self):
|
||||
addlogmsg("admin", "localhost", "test-vm", "Started VM")
|
||||
log = Logs.objects.filter(instance="test-vm").first()
|
||||
self.assertIsNotNone(log)
|
||||
self.assertEqual(log.user, "admin")
|
||||
self.assertEqual(log.host, "localhost")
|
||||
self.assertEqual(log.message, "Started VM")
|
||||
|
||||
def test_vm_logs_superuser_access(self):
|
||||
addlogmsg("admin", "localhost", "test-vm", "Created VM")
|
||||
client = Client()
|
||||
client.login(request=RequestFactory().get("/"), username="admin_logs", password="adminpassword")
|
||||
|
||||
response = client.get(reverse("vm_logs", args=["test-vm"]))
|
||||
def shown(self, instance=None):
|
||||
response = self.client.get(reverse("vm_logs", args=[(instance or self.instance).pk]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return [(row["message"], row["result"]) for row in response.json()]
|
||||
|
||||
data = json.loads(response.content.decode("utf-8"))
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["user"], "admin")
|
||||
self.assertEqual(data[0]["message"], "Created VM")
|
||||
self.assertEqual(data[0]["instance"], "test-vm")
|
||||
def test_rows_of_the_vm_with_their_result(self):
|
||||
write("admin", "c1", "test-vm", "VM test-vm: power on", True, uuid=UUID)
|
||||
write("admin", "c1", "test-vm", "VM test-vm: power off: not running", False, uuid=UUID)
|
||||
row = self.client.get(reverse("vm_logs", args=[self.instance.pk])).json()[0]
|
||||
self.assertEqual((row["user"], row["host"], row["instance"]), ("admin", "c1", "test-vm"))
|
||||
self.assertEqual(self.shown(), [("VM test-vm: power off: not running", "Failed"),
|
||||
("VM test-vm: power on", "Done")])
|
||||
|
||||
def test_vm_logs_non_superuser_forbidden(self):
|
||||
client = Client()
|
||||
client.login(request=RequestFactory().get("/"), username="normal_logs", password="userpassword")
|
||||
def test_a_vm_is_found_by_uuid_not_by_name(self):
|
||||
other = Compute.objects.create(name="c2", hostname="192.0.2.2", type=1)
|
||||
namesake = Instance.objects.create(compute=other, name="test-vm", uuid="87654321-4321-4321-4321-210987654321")
|
||||
write("admin", "c1", "old-name", "VM old-name: power on", True, uuid=UUID) # before a rename
|
||||
write("admin", "c2", "test-vm", "VM test-vm: power on", True, uuid=namesake.uuid)
|
||||
write("admin", "c1", "test-vm", "Instance is created") # a row from before the UUID column
|
||||
self.assertEqual(self.shown(), [("VM old-name: power on", "Done")])
|
||||
self.assertEqual(self.shown(namesake), [("VM test-vm: power on", "Done")])
|
||||
|
||||
response = client.get(reverse("vm_logs", args=["test-vm"]))
|
||||
# superuser_only decorator redirects non-superusers to index or login
|
||||
def test_one_vm_on_two_computes_shows_the_rows_of_both(self):
|
||||
other = Compute.objects.create(name="c2", hostname="192.0.2.2", type=1)
|
||||
moved = Instance.objects.create(compute=other, name="test-vm", uuid=UUID)
|
||||
write("admin", "c1", "test-vm", "VM test-vm: migrate (live) to 192.0.2.2", True, uuid=UUID)
|
||||
write("admin", "c2", "test-vm", "VM test-vm: power off", None, uuid=UUID)
|
||||
self.assertEqual(self.shown(moved), [("VM test-vm: power off", "Unknown"),
|
||||
("VM test-vm: migrate (live) to 192.0.2.2", "Done")])
|
||||
self.assertEqual(self.shown(moved), self.shown())
|
||||
|
||||
def test_unknown_vm(self):
|
||||
self.assertEqual(self.client.get(reverse("vm_logs", args=[999999])).status_code, 404)
|
||||
|
||||
def test_non_superuser_forbidden(self):
|
||||
self.client.force_login(self.normal_user)
|
||||
response = self.client.get(reverse("vm_logs", args=[self.instance.pk]))
|
||||
self.assertNotEqual(response.status_code, 200)
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ from django.urls import path
|
|||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("vm_logs/<vname>/", views.vm_logs, name="vm_logs"),
|
||||
path("vm_logs/<int:pk>/", views.vm_logs, name="vm_logs"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,44 +1,25 @@
|
|||
import logging
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from admin.decorators import superuser_only
|
||||
from instances.models import Instance
|
||||
from logs.audit import write
|
||||
from logs.models import Logs
|
||||
|
||||
|
||||
def addlogmsg(user, host, instance, message):
|
||||
"""
|
||||
:param user:
|
||||
:param host:
|
||||
:param instance:
|
||||
:param message:
|
||||
:return:
|
||||
"""
|
||||
write(user, host, instance, str(message))
|
||||
logger = logging.getLogger("")
|
||||
logger.info(f"{user} {host} {instance} {message}")
|
||||
|
||||
|
||||
@superuser_only
|
||||
def vm_logs(request, vname):
|
||||
"""
|
||||
:param request:
|
||||
:param vname:
|
||||
:return:
|
||||
"""
|
||||
|
||||
vm = Instance.objects.get(name=vname)
|
||||
logs_ = Logs.objects.filter(instance=vm.name, date__gte=vm.created).order_by("-date")
|
||||
def vm_logs(request, pk):
|
||||
"""The rows of a VM, found by its UUID: a rename keeps them, and a VM on two computes is one VM."""
|
||||
vm = get_object_or_404(Instance, pk=pk)
|
||||
results = {None: _("Unknown"), True: _("Done"), False: _("Failed")}
|
||||
logs = []
|
||||
for l in logs_:
|
||||
log = dict()
|
||||
log["user"] = l.user
|
||||
log["host"] = l.host
|
||||
log["instance"] = l.instance
|
||||
log["message"] = l.message
|
||||
log["date"] = l.date.strftime("%x %X")
|
||||
logs.append(log)
|
||||
|
||||
for log in Logs.objects.filter(instance_uuid=vm.uuid).order_by("-date", "-id"):
|
||||
logs.append({
|
||||
"user": log.user,
|
||||
"host": log.host,
|
||||
"instance": log.instance,
|
||||
"message": log.message,
|
||||
"result": results[log.success],
|
||||
"date": log.date.strftime("%x %X"),
|
||||
})
|
||||
return JsonResponse(logs, safe=False)
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ class Kind:
|
|||
the kind decides whether the job is idle and ends it if so."""
|
||||
|
||||
def log_entry(self, op, state):
|
||||
"""(host, instance, message) of the Logs row written with each end of
|
||||
the job (in the same transaction); its host and instance also go on
|
||||
the row of a resolution. None: no row."""
|
||||
"""(host, instance, instance UUID, message) of the Logs row written
|
||||
with each end of the job (in the same transaction); its host, instance
|
||||
and UUID also go on the row of a resolution. None: no row."""
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -219,8 +219,8 @@ def _entry(op, state):
|
|||
return None
|
||||
|
||||
|
||||
def _log(user, host, instance, message, success):
|
||||
write(str(user), str(host), str(instance), utils.safe_text(message), success)
|
||||
def _log(user, host, instance, uuid, message, success):
|
||||
write(str(user), str(host), str(instance), utils.safe_text(message), success, uuid=str(uuid))
|
||||
|
||||
|
||||
def finish(op, outcome, *, worker=None):
|
||||
|
|
@ -262,10 +262,10 @@ def resolve(op_id, user, outcome_state, note):
|
|||
Operation.objects.filter(pk=op.pk).update(state=outcome_state, resolution=outcome_state,
|
||||
resolution_note=note, resolved_by=user, resolved_at=timezone.now())
|
||||
Claim.objects.filter(operation=op).delete()
|
||||
# the host and VM columns of the kind's own entries, so the VM's log shows it
|
||||
# the host, VM and UUID columns of the kind's own entries, so the VM's log shows it
|
||||
entry = _entry(op, outcome_state)
|
||||
host, instance = entry[:2] if entry is not None else ("-", op.resource_label)
|
||||
_log(user.username, host, instance, f"Job {op.pk} resolved as {outcome_state}: {note}",
|
||||
host, instance, uuid = entry[:3] if entry is not None else ("-", op.resource_label, "")
|
||||
_log(user.username, host, instance, uuid, f"Job {op.pk} resolved as {outcome_state}: {note}",
|
||||
outcome_state == Operation.SUCCEEDED)
|
||||
op.refresh_from_db()
|
||||
return op
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class NoopKind(Kind):
|
|||
return Outcome(Operation.SUCCEEDED, result={"recovered": True})
|
||||
|
||||
def log_entry(self, op, state):
|
||||
return ("c", "vm", f"No-op {state}")
|
||||
return ("c", "vm", op.params["uuid"], f"No-op {state}")
|
||||
|
||||
|
||||
@register
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
|||
def _log(op, state, action):
|
||||
compute, _sep, pool = op.resource_label.rpartition("/") # a pool name has no "/"
|
||||
message = f"Storage pool {pool}: {action}"
|
||||
return compute, "", message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
return compute, "", "", message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
||||
|
||||
|
||||
def _storage(params):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue