From 890d82f44a72032068512a67a26ccdb4cb4aca76 Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 17:45:18 +0300 Subject: [PATCH] fix(instances): a timed-out root password or SSH key change says it may still be made When the guest agent does not answer a root password or SSH key change in time, the command was already sent: the agent runs it when it answers again. Measured: with the agent frozen the change failed with "guest agent command timed out", and after it resumed, root's hash was the one sent. The error now adds "The change may still be made when the agent answers again." A failed synchronization sends no command and gets no note. --- instances/tests/test_root_pass.py | 18 ++++++++++++++++++ instances/views.py | 18 ++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/instances/tests/test_root_pass.py b/instances/tests/test_root_pass.py index 05b01197..0c889faf 100644 --- a/instances/tests/test_root_pass.py +++ b/instances/tests/test_root_pass.py @@ -169,6 +169,24 @@ class GuestAgentAccessTestCase(TestCase): self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"}), ["guest agent command failed: user root not found"]) + def test_a_command_that_timed_out_may_still_be_made(self): + # sent to the agent: it runs when the agent answers again; a failed + # synchronization sends nothing + from libvirt import VIR_ERR_AGENT_UNRESPONSIVE + + for code, text, note in ( + (112, "guest agent command timed out", True), # VIR_ERR_AGENT_COMMAND_TIMEOUT + (VIR_ERR_AGENT_UNRESPONSIVE, "Guest agent is not responding", False), + ): + with self.subTest(code=code): + err = libvirtError(text) + err.get_error_code = lambda code=code: code + self.proxy.set_user_password.side_effect = err + self.proxy.add_authorized_key.side_effect = err + expected = text + (" The change may still be made when the agent answers again." if note else "") + self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"})[-1], expected) + self.assertEqual(self.post("add_public_key", {"sshkeyid": self.key.id})[-1], expected) + def test_a_user_who_does_not_see_hosts_gets_no_connection_details(self): from accounts.models import UserInstance from django.contrib.auth import get_user_model diff --git a/instances/views.py b/instances/views.py index 89cec857..9cd5e7af 100755 --- a/instances/views.py +++ b/instances/views.py @@ -869,6 +869,20 @@ def gstfsd_request(hostname, data): AGENT_OR_SHUT_OFF = _("Shut the VM down, or start its QEMU guest agent, and try again") +# libvirt's value; bindings built against older libvirt (Ubuntu 24.04's) lack +# the name, and the code comes from the host's libvirtd +VIR_ERR_AGENT_COMMAND_TIMEOUT = 112 + + +def access_error(request, err): + """The error of a root password or SSH key change. A command that timed + out was sent to the agent, which still runs it when it answers again.""" + text = error_text(request, err) + if isinstance(err, libvirtError) and err.get_error_code() == VIR_ERR_AGENT_COMMAND_TIMEOUT: + text += " " + _("The change may still be made when the agent answers again.") + return text + + @require_POST @serialize_instance_mutation def set_root_pass(request, pk): @@ -890,7 +904,7 @@ def set_root_pass(request, pk): else: instance.proxy.set_user_password("root", passwd_hash) except (util.OperationError, libvirtError) as err: - messages.error(request, error_text(request, err)) + messages.error(request, access_error(request, err)) else: messages.success(request, _("Reset root password")) else: @@ -928,7 +942,7 @@ def add_public_key(request, pk): else: instance.proxy.add_authorized_key("root", publickey.keypublic) except (util.OperationError, libvirtError) as err: - messages.error(request, error_text(request, err)) + messages.error(request, access_error(request, err)) else: messages.success(request, _("Installed new SSH public key %(keyname)s") % { "keyname": publickey.keyname