feat: update terminal support messaging and enhance guest agent provisioning logic

This commit is contained in:
2026-09-25 21:12:21 +05:30
parent d870c98bfa
commit 6573f483e6
10 changed files with 163 additions and 29 deletions
+7 -2
View File
@@ -14,7 +14,7 @@
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=1" /> <link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=1" />
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script> <script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script> <script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
<script src="/dashboard/static/js/sessions.js?v=1" defer></script> <script src="/dashboard/static/js/sessions.js?v=2" defer></script>
<script src="/dashboard/static/js/dashboard.js?v=4" defer></script> <script src="/dashboard/static/js/dashboard.js?v=4" defer></script>
</head> </head>
<body> <body>
@@ -213,7 +213,12 @@
<footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer> <footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer>
</article> </article>
</div> </div>
<details id="terminal-support" class="terminal-support soft-panel"><summary>Guest terminal setup</summary><p>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.</p><button id="session-install-agent" class="button button-small" type="button">Install / update terminal support</button><span id="session-install-hint" class="muted"></span></details> <details id="terminal-support" class="terminal-support soft-panel">
<summary>Guest terminal support</summary>
<p>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.</p>
<button id="session-install-agent" class="button button-small" type="button">Repair / update terminal support</button>
<span id="session-install-hint" class="muted"></span>
</details>
</section> </section>
</div> </div>
</section> </section>
+2 -2
View File
@@ -66,8 +66,8 @@
const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped' const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
&& !this.vm.operation && !this.vm.pending_resize; && !this.vm.operation && !this.vm.pending_resize;
$('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id); $('#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-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Repair / 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-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(); this.renderStatus();
} }
+1
View File
@@ -46,6 +46,7 @@ class FirecrackerConfigTests(unittest.TestCase):
arguments = config["boot-source"]["boot_args"].split() arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64") self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments) 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) self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
client = RecordingFirecrackerClient() client = RecordingFirecrackerClient()
client.configure_and_start(config) client.configure_and_start(config)
+49 -10
View File
@@ -74,10 +74,14 @@ class GuestAgentImageTests(unittest.TestCase):
def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None: def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None:
root = self.base / "tree" root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
payloads = { payloads = {
**self.retained, "/etc/uvm-image": marker, **self.retained,
"/usr/lib/systemd/systemd": "systemd fixture", "/usr/bin/apt-get": "apt fixture", "/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(): for path, content in payloads.items():
target = root / path.lstrip("/") target = root / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True) 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.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.guest("cat /home/worker/data"), self.retained["/home/worker/data"]) 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:
self.make_disk() for marker in ("uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v3\n", "uvm-ubuntu-jammy-v4\n",
before = self.snapshot() "uvm-ubuntu-jammy-v5\n", "custom-image\n", None):
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) with self.subTest(marker=marker):
self.assertEqual(self.snapshot(), before) self.make_disk(marker)
for version in (4, 5): template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
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-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()
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)
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: def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
self.make_disk() self.make_disk()
@@ -163,12 +202,12 @@ class GuestAgentImageTests(unittest.TestCase):
updated = guest_agent_files() updated = guest_agent_files()
updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n" updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n"
with patch("uvm.images.guest_agent_files", return_value=updated): 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.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") self.replace_guest_file("/usr/lib/uvm/agent.py", "guest customization\n")
before = self.snapshot() before = self.snapshot()
with self.assertRaisesRegex(UvmError, "customized guest agent file"): 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(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk]) self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
+60 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from unittest.mock import Mock from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase 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 from uvm.guest_exec import ExecResult
@@ -22,6 +22,65 @@ class GuestExecLifecycleTests(LifecycleTestCase):
vm = self.create_vm(username="worker") vm = self.create_vm(username="worker")
self.assert_call_order(vm, "images.provision_agent", "process.start") 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: def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None:
vm = self.create_vm(name="worker", ports=()) vm = self.create_vm(name="worker", ports=())
result = ExecResult(7, "output", "problem", False, False) result = ExecResult(7, "output", "problem", False, False)
+1 -1
View File
@@ -128,7 +128,7 @@ class TerminalCompatibilityTests(unittest.TestCase):
thread = threading.Thread(target=old_agent) thread = threading.Thread(target=old_agent)
thread.start() thread.start()
try: 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) terminal.start(24, 80)
finally: finally:
thread.join(3) thread.join(3)
+2 -2
View File
@@ -71,8 +71,8 @@ class GuestSession:
if connection is not None: if connection is not None:
connection.close() connection.close()
raise GuestAgentUnavailableError( raise GuestAgentUnavailableError(
"guest agent is unavailable or not ready; install it with POST /vms/{vm_id}/agent/install " "guest agent is unavailable or not ready; wait for guest startup, or restart the VM "
"while stopped, then start the VM (vsock-capable kernel required)" "to apply automatic vsock guest-agent setup (vsock-capable kernel required)"
) from error ) from error
def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult: def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
+2 -2
View File
@@ -38,8 +38,8 @@ class GuestTerminal(GuestSession):
except (OSError, EOFError, ProtocolError) as error: except (OSError, EOFError, ProtocolError) as error:
self.close() self.close()
raise GuestAgentUnavailableError( raise GuestAgentUnavailableError(
"Interactive terminal support is unavailable. Stop this VM, use Install / update terminal support " "Interactive terminal support is unavailable. Restart this VM to apply automatic guest-agent setup, "
"in its Sessions view, then start it and launch a new session." "then wait for the agent to start and launch a new session."
) from error ) from error
async def _receive_exact(self, size: int) -> bytes: async def _receive_exact(self, size: int) -> bytes:
+39 -10
View File
@@ -304,10 +304,12 @@ class ImageStore:
return profile return profile
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool: 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 The lifecycle lock and confirmed VMM exit are required. A checked sparse
copy preserves the original disk on failure and makes retries atomic. 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) parse_username(username)
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
@@ -319,12 +321,7 @@ class ImageStore:
raise UvmError("private VM disk is unsafe for guest agent installation") raise UvmError("private VM disk is unsafe for guest agent installation")
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name: with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
files = Path(name) files = Path(name)
if not install: if not install and self._guest_agent_current(disk, files, vm_id, username):
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 return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent) descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent)
os.close(descriptor) os.close(descriptor)
@@ -352,16 +349,48 @@ class ImageStore:
except (OSError, UnicodeError) as error: except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest agent: {error}") from 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: 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"): for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True) result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
if re.search(r"Type:\s+regular\b", result.stdout) is None: 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", for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"): "/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
self._agent_directory(disk, directory) self._agent_directory(disk, directory)
payloads = guest_agent_files() payloads = self._agent_payloads(vm_id, username)
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
manifest_path = "/etc/uvm-agent-files.json" manifest_path = "/etc/uvm-agent-files.json"
previous = self._agent_file(disk, manifest_path, files / "manifest") previous = self._agent_file(disk, manifest_path, files / "manifest")
old_hashes = {} old_hashes = {}
+1
View File
@@ -198,6 +198,7 @@ class LifecycleService:
self._cleanup_runtime(vm) self._cleanup_runtime(vm)
self._apply_pending_resize(vm) self._apply_pending_resize(vm)
self._images.configure_guest_network(Path(vm.disk), vm.guest_ip) 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 vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel) self._boot(vm, kernel)
return vm return vm