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