From 6573f483e62ce044e22ab4ad59f8e04bb2e79d98 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Fri, 25 Sep 2026 21:12:21 +0530 Subject: [PATCH] feat: update terminal support messaging and enhance guest agent provisioning logic --- nest/dashboard/static/index.html | 9 +++- nest/dashboard/static/js/sessions.js | 4 +- tests/test_firecracker_config.py | 1 + tests/test_guest_agent_images.py | 55 +++++++++++++++++++++---- tests/test_guest_exec_lifecycle.py | 61 +++++++++++++++++++++++++++- tests/test_guest_terminal.py | 2 +- uvm/guest_exec.py | 4 +- uvm/guest_terminal.py | 4 +- uvm/images.py | 51 ++++++++++++++++++----- uvm/lifecycle.py | 1 + 10 files changed, 163 insertions(+), 29 deletions(-) diff --git a/nest/dashboard/static/index.html b/nest/dashboard/static/index.html index 63c6c32..f4ca18a 100644 --- a/nest/dashboard/static/index.html +++ b/nest/dashboard/static/index.html @@ -14,7 +14,7 @@ - + @@ -213,7 +213,12 @@ -
Guest terminal setup

Interactive sessions use the VM’s guest agent. For an older VM, stop the machine, install or update terminal support here, then start it again.

+
+ Guest terminal support +

Vsock and the guest agent are set up automatically when a microVM is created or started. Restart an older VM once to apply the setup. Manual repair is available here while the machine is stopped.

+ + +
diff --git a/nest/dashboard/static/js/sessions.js b/nest/dashboard/static/js/sessions.js index 077e187..c069c9c 100644 --- a/nest/dashboard/static/js/sessions.js +++ b/nest/dashboard/static/js/sessions.js @@ -66,8 +66,8 @@ const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped' && !this.vm.operation && !this.vm.pending_resize; $('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id); - $('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Install / update terminal support'; - $('#session-install-hint').textContent = stopped ? 'Ready to update on this stopped machine.' : 'Stop the machine before updating its guest agent.'; + $('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Repair / update terminal support'; + $('#session-install-hint').textContent = stopped ? 'Setup runs automatically at startup. Manual repair is available now.' : 'Vsock is set up automatically on boot. Restart to apply updates.'; this.renderStatus(); } diff --git a/tests/test_firecracker_config.py b/tests/test_firecracker_config.py index 65704f0..ca00b54 100644 --- a/tests/test_firecracker_config.py +++ b/tests/test_firecracker_config.py @@ -46,6 +46,7 @@ class FirecrackerConfigTests(unittest.TestCase): arguments = config["boot-source"]["boot_args"].split() self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64") self.assertIn("console=ttyS0", arguments) + self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"}) self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments) client = RecordingFirecrackerClient() client.configure_and_start(config) diff --git a/tests/test_guest_agent_images.py b/tests/test_guest_agent_images.py index 16741d8..0cfe7a6 100644 --- a/tests/test_guest_agent_images.py +++ b/tests/test_guest_agent_images.py @@ -74,10 +74,14 @@ class GuestAgentImageTests(unittest.TestCase): def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None: root = self.base / "tree" + if root.exists(): + shutil.rmtree(root) payloads = { - **self.retained, "/etc/uvm-image": marker, + **self.retained, "/usr/lib/systemd/systemd": "systemd fixture", "/usr/bin/apt-get": "apt fixture", } + if marker is not None: + payloads["/etc/uvm-image"] = marker for path, content in payloads.items(): target = root / path.lstrip("/") target.parent.mkdir(parents=True, exist_ok=True) @@ -134,14 +138,49 @@ class GuestAgentImageTests(unittest.TestCase): self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True) self.assertEqual(self.guest("cat /home/worker/data"), self.retained["/home/worker/data"]) - def test_current_templates_are_configured_and_older_templates_require_explicit_install(self) -> None: + def test_all_compatible_images_get_vsock_agent_setup_without_opt_in(self) -> None: + for marker in ("uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v3\n", "uvm-ubuntu-jammy-v4\n", + "uvm-ubuntu-jammy-v5\n", "custom-image\n", None): + with self.subTest(marker=marker): + self.make_disk(marker) + template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest() + self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"}) + self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"', + self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service")) + self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode()) + for path, content in self.retained.items(): + self.assertEqual(self.guest(f"cat {path}"), content) + self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template) + + def test_current_agent_setup_is_read_only_on_later_boots_and_tracks_vm_identity(self) -> None: self.make_disk() + self.images.provision_guest_agent(self.disk, "vm-test", "worker") before = self.snapshot() - self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + with patch.object(self.images, "_copy_sparse") as copy: + self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + copy.assert_not_called() self.assertEqual(self.snapshot(), before) - for version in (4, 5): - self.replace_guest_file("/etc/uvm-image", f"uvm-ubuntu-jammy-v{version}\n") - self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-recreated", "worker")) + self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-recreated", "username": "worker"}) + + def test_default_setup_reenables_an_existing_agent_service(self) -> None: + self.make_disk() + self.images.provision_guest_agent(self.disk, "vm-test", "worker") + self.runner.run(("debugfs", "-w", "-R", "rm /etc/systemd/system/multi-user.target.wants/uvm-agent.service", self.disk), capture=True) + self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"', + self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service")) + + def test_incompatible_images_fail_default_setup_without_changing_the_disk(self) -> None: + for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"): + with self.subTest(binary=binary): + self.make_disk(marker=None) + self.runner.run(("debugfs", "-w", "-R", f"rm {binary}", self.disk), capture=True) + before = self.snapshot() + with self.assertRaisesRegex(UvmError, "automatic vsock guest-agent setup requires a systemd/apt image"): + self.images.provision_guest_agent(self.disk, "vm-test", "worker") + self.assertEqual(self.snapshot(), before) def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None: self.make_disk() @@ -163,12 +202,12 @@ class GuestAgentImageTests(unittest.TestCase): updated = guest_agent_files() updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n" with patch("uvm.images.guest_agent_files", return_value=updated): - self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True) + self.images.provision_guest_agent(self.disk, "vm-test", "worker") self.assertTrue(self.guest("cat /usr/lib/uvm/agent.py").endswith("# next packaged revision\n")) self.replace_guest_file("/usr/lib/uvm/agent.py", "guest customization\n") before = self.snapshot() with self.assertRaisesRegex(UvmError, "customized guest agent file"): - self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True) + self.images.provision_guest_agent(self.disk, "vm-test", "worker") self.assertEqual(self.snapshot(), before) self.assertEqual(list(self.disk.parent.iterdir()), [self.disk]) diff --git a/tests/test_guest_exec_lifecycle.py b/tests/test_guest_exec_lifecycle.py index 4401244..3df582f 100644 --- a/tests/test_guest_exec_lifecycle.py +++ b/tests/test_guest_exec_lifecycle.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import Mock from tests.test_lifecycle import LifecycleTestCase -from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError +from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError from uvm.guest_exec import ExecResult @@ -22,6 +22,65 @@ class GuestExecLifecycleTests(LifecycleTestCase): vm = self.create_vm(username="worker") self.assert_call_order(vm, "images.provision_agent", "process.start") + def test_stopped_boots_automatically_provision_agent_before_starting_the_vmm(self) -> None: + vm = self.create_vm(username="worker", password="retained-password") + identity = (vm.id, vm.guest_ip, vm.mac, vm.username, vm.password) + + def provision(disk, vm_id, username): + self.host.call("images.provision_agent", vm_id) + record = self.store.get(vm_id) + self.assertTrue(self.store.operation_active) + self.assertEqual(self.process.observe(record), "gone") + self.assertEqual((record.id, record.guest_ip, record.mac, record.username, record.password), identity) + self.assertEqual((disk, username), (Path(vm.disk), "worker")) + + self.provision_agent.side_effect = provision + for action in ("start", "restart", "resize"): + with self.subTest(action=action): + if action == "start": + self.service.stop(vm.id) + self.host.events.clear() + self.provision_agent.reset_mock() + self.provision.reset_mock() + if action == "resize": + started = self.service.resize(vm.id, cpu=2) + else: + started = getattr(self.service, action)(vm.id) + self.assertEqual(started.status, "running") + self.provision_agent.assert_called_once_with(Path(vm.disk), vm.id, "worker") + self.provision.assert_not_called() + self.assert_call_order(vm, "images.provision_agent", "process.start") + + def test_live_or_ambiguous_vms_never_run_offline_agent_provisioning(self) -> None: + vm = self.create_vm() + self.provision_agent.reset_mock() + self.service.start(vm.id) + self.process.unknown.add(vm.id) + with self.assertRaises(ConflictError): + self.service.start(vm.id) + self.provision_agent.assert_not_called() + + def test_default_agent_setup_failure_blocks_boot_and_keeps_the_retained_disk(self) -> None: + vm = self.create_vm() + self.service.stop(vm.id) + before = Path(vm.disk).read_bytes() + count = len(self.process.started) + self.provision_agent.side_effect = UvmError("agent setup failed") + with self.assertRaisesRegex(UvmError, "agent setup failed"): + self.service.start(vm.id) + self.assertEqual(len(self.process.started), count) + self.assertEqual(Path(vm.disk).read_bytes(), before) + self.assertEqual(self.store.get(vm.id).status, "failed") + self.provision_agent.side_effect = None + self.assertEqual(self.service.start(vm.id).status, "running") + + def test_default_agent_setup_failure_rolls_back_a_new_vm_before_boot(self) -> None: + self.provision_agent.side_effect = UvmError("unsupported guest image") + with self.assertRaisesRegex(UvmError, "unsupported guest image"): + self.create_vm() + self.assertEqual(self.store.load().vms, {}) + self.assertEqual(self.process.started, []) + def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None: vm = self.create_vm(name="worker", ports=()) result = ExecResult(7, "output", "problem", False, False) diff --git a/tests/test_guest_terminal.py b/tests/test_guest_terminal.py index f7bf0fc..dedcb74 100644 --- a/tests/test_guest_terminal.py +++ b/tests/test_guest_terminal.py @@ -128,7 +128,7 @@ class TerminalCompatibilityTests(unittest.TestCase): thread = threading.Thread(target=old_agent) thread.start() try: - with self.assertRaisesRegex(GuestAgentUnavailableError, "Install / update terminal support"): + with self.assertRaisesRegex(GuestAgentUnavailableError, "Restart this VM to apply automatic guest-agent setup"): terminal.start(24, 80) finally: thread.join(3) diff --git a/uvm/guest_exec.py b/uvm/guest_exec.py index d31141e..af0dff5 100644 --- a/uvm/guest_exec.py +++ b/uvm/guest_exec.py @@ -71,8 +71,8 @@ class GuestSession: if connection is not None: connection.close() raise GuestAgentUnavailableError( - "guest agent is unavailable or not ready; install it with POST /vms/{vm_id}/agent/install " - "while stopped, then start the VM (vsock-capable kernel required)" + "guest agent is unavailable or not ready; wait for guest startup, or restart the VM " + "to apply automatic vsock guest-agent setup (vsock-capable kernel required)" ) from error def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult: diff --git a/uvm/guest_terminal.py b/uvm/guest_terminal.py index bfb9822..a531001 100644 --- a/uvm/guest_terminal.py +++ b/uvm/guest_terminal.py @@ -38,8 +38,8 @@ class GuestTerminal(GuestSession): except (OSError, EOFError, ProtocolError) as error: self.close() raise GuestAgentUnavailableError( - "Interactive terminal support is unavailable. Stop this VM, use Install / update terminal support " - "in its Sessions view, then start it and launch a new session." + "Interactive terminal support is unavailable. Restart this VM to apply automatic guest-agent setup, " + "then wait for the agent to start and launch a new session." ) from error async def _receive_exact(self, size: int) -> bytes: diff --git a/uvm/images.py b/uvm/images.py index 7b5d4c1..403b029 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -304,10 +304,12 @@ class ImageStore: return profile def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool: - """Configure new templates, or explicitly install on a proven-offline disk. + """Ensure every guest has its vsock agent before booting its private disk. The lifecycle lock and confirmed VMM exit are required. A checked sparse copy preserves the original disk on failure and makes retries atomic. + Return False only when the managed files and service are already current. + An explicit install request always stages and rechecks the installation. """ parse_username(username) if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None: @@ -319,13 +321,8 @@ class ImageStore: raise UvmError("private VM disk is unsafe for guest agent installation") with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name: files = Path(name) - if not install: - marker = self._read_guest_file(disk, "/etc/uvm-image", files / "marker", - required=False, regular_only=True) - if marker is None or marker.path.read_text(encoding="utf-8") not in { - "uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n", - }: - return False + if not install and self._guest_agent_current(disk, files, vm_id, username): + return False descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent) os.close(descriptor) temporary = Path(candidate) @@ -352,16 +349,48 @@ class ImageStore: except (OSError, UnicodeError) as error: raise UvmError(f"could not provision guest agent: {error}") from error + @staticmethod + def _agent_payloads(vm_id: str, username: str) -> dict[str, bytes]: + payloads = guest_agent_files() + payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8") + return payloads + + def _guest_agent_current(self, disk: Path, files: Path, vm_id: str, username: str) -> bool: + """Read-only fast path; changed installations are checked again after journal replay.""" + payloads = self._agent_payloads(vm_id, username) + for index, (path, content) in enumerate(payloads.items()): + existing = self._agent_file(disk, path, files / f"current-{index}") + mode = 0o600 if path.endswith(".json") else 0o644 + if (existing is None or (existing.mode, existing.gid) != (mode, 0) + or existing.path.read_bytes() != content): + return False + manifest = self._agent_file(disk, "/etc/uvm-agent-files.json", files / "current-manifest") + if manifest is None or (manifest.mode, manifest.gid) != (0o600, 0): + return False + try: + hashes = json.loads(manifest.path.read_bytes()) + except (ValueError, UnicodeError): + return False + if hashes != {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}: + return False + service = self._runner.run(( + "debugfs", "-R", "stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service", disk, + ), capture=True) + return (re.search(r"Type:\s+symlink\b", service.stdout) is not None + and 'Fast link dest: "/etc/systemd/system/uvm-agent.service"' in service.stdout) + def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None: for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"): result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True) if re.search(r"Type:\s+regular\b", result.stdout) is None: - raise UvmError("guest agent installation requires a systemd/apt image (Ubuntu 22.04 or newer)") + raise UvmError( + "automatic vsock guest-agent setup requires a systemd/apt image (Ubuntu 22.04 or newer); " + "use the default Ubuntu rootfs or a compatible custom image" + ) for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd", "/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"): self._agent_directory(disk, directory) - payloads = guest_agent_files() - payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8") + payloads = self._agent_payloads(vm_id, username) manifest_path = "/etc/uvm-agent-files.json" previous = self._agent_file(disk, manifest_path, files / "manifest") old_hashes = {} diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index f507ba6..4fe5319 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -198,6 +198,7 @@ class LifecycleService: self._cleanup_runtime(vm) self._apply_pending_resize(vm) self._images.configure_guest_network(Path(vm.disk), vm.guest_ip) + self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username) vm.network, vm.bridge = str(self._settings.network), self._settings.bridge self._boot(vm, kernel) return vm