From caaea12163a491e78d58c1494774c9a179e65817 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Fri, 25 Sep 2026 20:22:56 +0530 Subject: [PATCH] feat: implement session management and terminal handling with UI updates and backend support --- nest/dashboard/static/js/dashboard.js | 46 +++- nest/dashboard/static/js/sessions.js | 363 ++++++++++++++++++++++++++ nest/session_routes.py | 2 +- nest/session_store.py | 13 +- nest/sessions.py | 15 +- tests/browser_session_server.py | 82 ++++++ tests/browser_sessions.cjs | 101 +++++++ tests/test_guest_agent_images.py | 19 +- tests/test_guest_network.py | 4 +- uvm/images.py | 12 +- uvm/install.py | 9 +- 11 files changed, 648 insertions(+), 18 deletions(-) create mode 100644 nest/dashboard/static/js/sessions.js create mode 100644 tests/browser_session_server.py create mode 100644 tests/browser_sessions.cjs diff --git a/nest/dashboard/static/js/dashboard.js b/nest/dashboard/static/js/dashboard.js index 9e278b5..84b21d4 100644 --- a/nest/dashboard/static/js/dashboard.js +++ b/nest/dashboard/static/js/dashboard.js @@ -26,6 +26,8 @@ page: 'overview', detailId: null, detail: null, + detailTab: 'overview', + terminalId: null, portEditing: false, resizeEditing: false, selected: new Set(), @@ -45,6 +47,13 @@ menuTrigger: null, }; + const sessions = new window.NestSessions({ + api, getToken: () => state.token, + isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id, + onExpired: () => logout('Your token was not accepted. Connect again to continue.'), + esc, formatDate, toast, confirmAction, + }); + function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', @@ -598,9 +607,22 @@ const busy = state.busy.has(vm.id); const running = runtimeStatus(vm) === 'running'; const actionButton = (action, caption, symbol, primary = false) => ``; - html('#detail-actions', running + html('#detail-actions', (running ? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop') - : actionButton('start', 'Start machine', 'play', true)); + : actionButton('start', 'Start machine', 'play', true)) + + ``); + const showingSessions = state.detailTab === 'sessions'; + $('#vm-overview-panel').hidden = showingSessions; + $('#vm-sessions-panel').hidden = !showingSessions; + $('#vm-overview-tab').href = vmHref(vm.id); + $('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`; + [['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => { + $(selector).classList.toggle('active', active); + if (active) $(selector).setAttribute('aria-current', 'page'); + else $(selector).removeAttribute('aria-current'); + }); + text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`); + sessions.sync(vm, state.terminalId, showingSessions); html('#detail-config', detailRows([ ['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))], ['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username], @@ -655,13 +677,14 @@ state.detail = vm; state.detailId = vm.id; renderDetail(); - await loadLogs(); + if (state.detailTab === 'overview') await loadLogs(); } catch (error) { if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return; if (state.detail && error.status !== 404) { text('#detail-error', error.message); $('#detail-error').hidden = false; } else { + sessions.leave(); state.detail = null; $('#detail-content').hidden = true; $('#detail-loading').hidden = false; @@ -1119,8 +1142,17 @@ if (!state.authenticated) return; let value = location.hash.slice(1) || 'overview'; let identifier = null; + let detailTab = 'overview'; + let terminalId = null; if (value.startsWith('vm/')) { - try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; } + const parts = value.split('/'); + try { + identifier = decodeURIComponent(parts[1]); + if (parts[2] === 'sessions' && parts.length <= 4) { + detailTab = 'sessions'; + terminalId = parts[3] ? decodeURIComponent(parts[3]) : null; + } else if (parts.length !== 2) identifier = null; + } catch { identifier = null; } value = identifier ? 'vm-detail' : 'overview'; } if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview'; @@ -1129,6 +1161,9 @@ state.requests.detail += 1; state.requests.logs += 1; state.detailId = identifier; + state.detailTab = detailTab; + state.terminalId = terminalId; + sessions.leave(); state.detail = null; state.portEditing = false; state.resizeEditing = false; @@ -1217,6 +1252,7 @@ } function logout(message = '') { + sessions.leave(); state.session += 1; state.controllers.forEach((controller) => controller.abort()); state.controllers.clear(); @@ -1285,6 +1321,7 @@ if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); } if (action === 'dismiss-results') $('#bulk-results').hidden = true; if (action === 'install') installAssets(); + if (action === 'launch-session' && state.detail) sessions.launch(state.detail); if (action === 'refresh-logs') loadLogs(); if (action === 'copy-ssh') copyText($('#ssh-command').textContent); if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id); @@ -1388,6 +1425,7 @@ if (target && !target.disabled) handleAction(target); }); document.addEventListener('keydown', (event) => { + if (event.target.closest('.xterm')) return; const editing = event.target.closest('input, textarea, select, [contenteditable="true"]'); if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) { if ($('#confirm-dialog').open) return; diff --git a/nest/dashboard/static/js/sessions.js b/nest/dashboard/static/js/sessions.js new file mode 100644 index 0000000..077e187 --- /dev/null +++ b/nest/dashboard/static/js/sessions.js @@ -0,0 +1,363 @@ +(function () { + 'use strict'; + + const $ = (selector) => document.querySelector(selector); + const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`; + const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`; + const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); + const encoded = (data) => btoa(String.fromCharCode(...data)); + + window.NestSessions = class { + constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) { + Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }); + this.vm = null; + this.records = []; + this.current = null; + this.selectedId = null; + this.visible = false; + this.sequence = 0; + this.generation = 0; + this.launching = new Set(); + this.upgrading = new Set(); + this.offset = 0; + this.renderPending = Promise.resolve(); + $('#sessions-refresh').addEventListener('click', () => this.refresh()); + $('#session-end').addEventListener('click', () => this.end()); + $('#session-reconnect').addEventListener('click', () => this.connectSocket()); + $('#session-install-agent').addEventListener('click', () => this.installAgent()); + } + + sync(vm, selectedId, visible) { + if (this.vm?.id !== vm.id) { + this.leave(); + this.vm = vm; + $('#vm-session-count').textContent = '—'; + } + this.vm = vm; + if (this.selectedId !== selectedId || this.visible !== visible) { + this.detachTerminal(); + this.showError(''); + } + this.selectedId = selectedId; + this.visible = visible; + this.updateControls(); + if (this.pendingError?.vmId === vm.id && visible) { + this.showError(this.pendingError.message); + $('#terminal-support').open = this.pendingError.setup; + this.pendingError = null; + } + this.renderList(); + this.openSelected(); + if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh(); + } + + showError(message) { + $('#sessions-error').textContent = message; + } + + updateControls() { + const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running' + && !this.vm.operation && !this.vm.pending_resize; + document.querySelectorAll('[data-action="launch-session"]').forEach((button) => { + button.disabled = !running || this.launching.has(this.vm?.id); + button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session'; + button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session'; + }); + 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.'; + this.renderStatus(); + } + + renderList() { + const markup = this.records.length ? this.records.map((record) => ` + + ${this.esc(record.name)} + ${this.esc(this.formatDate(record.created_at))}
${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}
+
`).join('') : `

${this.loading ? 'Loading sessions…' : 'No sessions yet.
Launch a session to get started.'}

`; + if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup; + } + + async refresh() { + if (!this.vm) return; + const vmId = this.vm.id; + const sequence = ++this.sequence; + this.loading = true; + $('#sessions-refresh').disabled = true; + try { + const records = await this.api(path(vmId)); + if (sequence !== this.sequence || this.vm?.id !== vmId) return; + if (!Array.isArray(records)) throw new Error('The session history response was not recognized.'); + this.records = records; + if (this.current) { + const latest = records.find((record) => record.id === this.current.id); + if (latest && latest.updated_at >= this.current.updated_at) this.current = latest; + else if (latest) this.records[this.records.indexOf(latest)] = this.current; + } + this.lastRefresh = Date.now(); + $('#vm-session-count').textContent = records.length; + this.openSelected(); + this.renderStatus(); + } catch (error) { + if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message); + } finally { + if (sequence === this.sequence) { + this.loading = false; + $('#sessions-refresh').disabled = false; + this.renderList(); + } + } + } + + async launch(vm) { + if (!vm || this.launching.has(vm.id)) return; + const token = this.getToken(); + this.launching.add(vm.id); + this.showError(''); + this.updateControls(); + try { + const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } }); + if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id); + } catch (error) { + if (token === this.getToken() && error.name !== 'AbortError') { + this.toast(error.message, true); + if (this.isSelectedVm(vm.id)) { + this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 }; + location.hash = href(vm.id); + if (this.visible) this.sync(vm, null, true); + } + } + } finally { + this.launching.delete(vm.id); + this.lastRefresh = 0; + this.updateControls(); + } + } + + async installAgent() { + const vm = this.vm; + if (!vm || this.upgrading.has(vm.id)) return; + this.upgrading.add(vm.id); + this.showError(''); + this.updateControls(); + try { + await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' }); + if (this.vm?.id === vm.id) { + this.toast('Terminal support updated. Start this machine, then launch a session.'); + $('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.'; + } + } catch (error) { + if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message); + } finally { + this.upgrading.delete(vm.id); + this.updateControls(); + } + } + + openSelected() { + if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return; + const record = this.records.find((item) => item.id === this.selectedId); + if (!record) { + if (this.lastRefresh) this.showError('This session was not found for the selected machine.'); + return; + } + this.showError(''); + this.detachTerminal(); + this.current = record; + $('#session-blank').hidden = true; + $('#session-terminal').hidden = false; + this.term = new window.Terminal({ + cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13, + fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace', + scrollback: 10000, disableStdin: true, + theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' }, + }); + this.fit = new window.FitAddon.FitAddon(); + this.term.loadAddon(this.fit); + this.term.open($('#session-terminal')); + this.term.onData((text) => this.input(new TextEncoder().encode(text))); + this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0)))); + this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols)); + this.observer = new ResizeObserver(() => this.fitTerminal()); + this.observer.observe($('#session-terminal')); + this.fitTerminal(); + this.connectSocket(); + } + + fitTerminal() { + if (this.term && this.visible && $('#session-terminal').clientWidth > 0) { + this.fit.fit(); + } + } + + input(data) { + if (!this.canType()) return; + if (data.length > 65536 || this.socket.bufferedAmount > 65536) { + this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.'); + return; + } + for (let offset = 0; offset < data.length; offset += 8192) { + this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) })); + } + } + + canType() { + return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp; + } + + sendSize(rows, cols) { + if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) })); + } + + async connectSocket() { + if (!this.current || !this.visible || !this.getToken()) return; + const generation = this.generation; + await this.renderPending; + if (generation !== this.generation || !this.current || !this.visible) return; + window.clearTimeout(this.reconnectTimer); + const previous = this.socket; + this.socket = null; + previous?.close(); + this.caughtUp = false; + this.replayEnd = null; + this.connection = 'Connecting…'; + const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin); + url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(url); + this.socket = socket; + let rejected = false; + const current = () => generation === this.generation && this.socket === socket; + this.renderStatus(); + socket.onopen = () => { + if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset })); + }; + socket.onmessage = (event) => { + if (!current()) return; + try { + const message = JSON.parse(event.data); + if (message.type === 'session') { + const wasReady = this.caughtUp; + this.current = message.session; + if (message.offset > this.offset) { + this.term.reset(); + this.offset = message.offset; + } + if (this.replayEnd == null) this.replayEnd = message.session.output_end; + this.caughtUp = this.offset >= this.replayEnd; + this.connection = this.caughtUp ? 'Connected' : 'Loading history…'; + const index = this.records.findIndex((record) => record.id === message.session.id); + if (index >= 0) this.records[index] = message.session; + this.renderList(); + this.renderStatus(); + if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); } + } else if (message.type === 'output') { + const data = bytes(message.data); + if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.'); + this.renderPending = new Promise((resolve) => { + this.finishRender = resolve; + this.term.write(data, () => { + if (generation === this.generation) { + this.offset += data.length; + const wasReady = this.caughtUp; + this.caughtUp = this.offset >= this.replayEnd; + this.connection = this.caughtUp ? 'Connected' : 'Loading history…'; + if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset })); + this.renderStatus(); + if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); } + this.finishRender = null; + } + resolve(); + }); + }); + } else if (message.type === 'error') { + rejected = true; + this.showError(message.message); + } + } catch (error) { + rejected = true; + this.showError(error.message); + socket.close(); + } + }; + socket.onclose = (event) => { + if (!current()) return; + this.connection = 'Disconnected'; + this.caughtUp = false; + this.renderStatus(); + if (event.code === 4401) { this.onExpired(); return; } + if (!rejected && ['active', 'starting'].includes(this.current?.status)) { + this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000); + } + }; + } + + renderStatus() { + const record = this.current; + const active = record?.status === 'active'; + $('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal'; + $('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session'; + $('#session-end').disabled = !active || this.ending === record?.id; + $('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN; + if (this.term) this.term.options.disableStdin = !this.canType(); + $('#session-meta').textContent = record + ? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}` + : 'Your sessions and terminal output are saved on the host.'; + } + + async end() { + const record = this.current; + if (!record || this.ending) return; + const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session'); + if (!confirmed) return; + this.ending = record.id; + this.renderStatus(); + try { + const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' }); + if (this.current?.id === record.id) this.current = ended; + if (this.vm?.id === record.vm_id) await this.refresh(); + } catch (error) { + if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message); + } finally { + this.ending = null; + this.renderStatus(); + } + } + + detachTerminal() { + this.generation += 1; + window.clearTimeout(this.reconnectTimer); + this.observer?.disconnect(); + this.observer = null; + const socket = this.socket; + this.socket = null; + socket?.close(); + this.finishRender?.(); + this.finishRender = null; + this.renderPending = Promise.resolve(); + this.term?.dispose(); + this.term = null; + this.current = null; + this.offset = 0; + this.caughtUp = false; + $('#session-terminal').replaceChildren(); + $('#session-terminal').hidden = true; + $('#session-blank').hidden = false; + this.renderStatus(); + } + + leave() { + this.sequence += 1; + this.loading = false; + this.lastRefresh = 0; + this.detachTerminal(); + this.visible = false; + this.vm = null; + this.records = []; + this.selectedId = null; + this.showError(''); + $('#terminal-support').open = false; + } + }; +})(); diff --git a/nest/session_routes.py b/nest/session_routes.py index 35f681d..7ba12ef 100644 --- a/nest/session_routes.py +++ b/nest/session_routes.py @@ -122,7 +122,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str): while True: record, start, data = await sessions.read(vm_id, session_id, cursor) if previous != record or start != cursor: - await websocket.send_json({"type": "session", "session": record, "offset": start}) + await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start}) previous = record cursor = start if data: diff --git a/nest/session_store.py b/nest/session_store.py index bd7a1bc..4167dfe 100644 --- a/nest/session_store.py +++ b/nest/session_store.py @@ -63,12 +63,15 @@ class SessionStore: @staticmethod def _private_file(path: Path) -> int: descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise StateError("session history must use private regular files") + os.fchmod(descriptor, 0o600) + return descriptor + except BaseException: os.close(descriptor) - raise StateError("session history must use private regular files") - os.fchmod(descriptor, 0o600) - return descriptor + raise @contextmanager def _db(self): diff --git a/nest/sessions.py b/nest/sessions.py index d9f84c5..2619e7e 100644 --- a/nest/sessions.py +++ b/nest/sessions.py @@ -13,6 +13,17 @@ from uvm.guest_terminal import GuestTerminal, validate_size from .session_store import SessionStore +async def _persist(function, *arguments): + # Cancellation cannot release the history-owner lock while a worker still + # has an outstanding write to that database. + task = asyncio.create_task(asyncio.to_thread(function, *arguments)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + await task + raise + + @dataclass class _LiveSession: vm_id: str @@ -98,7 +109,7 @@ class SessionManager: if message["type"] == "exit": status, exit_code = "closed", message["exit_code"] break - await asyncio.to_thread(history.append, session_id, message["data"]) + await _persist(history.append, session_id, message["data"]) live.changed.set() except asyncio.CancelledError: error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted." @@ -107,7 +118,7 @@ class SessionManager: finally: live.terminal.close() try: - await asyncio.to_thread(history.finish, session_id, status, exit_code, error) + await _persist(history.finish, session_id, status, exit_code, error) finally: live.changed.set() self._live.pop(session_id, None) diff --git a/tests/browser_session_server.py b/tests/browser_session_server.py new file mode 100644 index 0000000..54518bb --- /dev/null +++ b/tests/browser_session_server.py @@ -0,0 +1,82 @@ +"""Isolated browser-test server: all VM and terminal operations are fakes.""" + +from __future__ import annotations + +import argparse +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import uvicorn + +from nest.server import create_app +from tests.test_server import FakeLifecycle, fake_application +from tests.test_sessions import FakeTerminal +from uvm.errors import ConflictError, NotFoundError +from uvm.lifecycle import ListedVm + + +class BrowserTerminal(FakeTerminal): + def __init__(self): + super().__init__() + self.command = bytearray() + + async def write(self, data): + await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")}) + for byte in data: + if byte == 13: + await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "}) + self.command.clear() + elif byte == 3: + self.command.clear() + else: + self.command.append(byte) + + +class BrowserLifecycle(FakeLifecycle): + def __init__(self): + super().__init__() + self.vm.username = "worker" + self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")} + + def get(self, vm_id): + try: + return self.records[vm_id] + except KeyError as error: + raise NotFoundError("VM not found") from error + + def inspect(self, identifier): + for vm in self.records.values(): + if identifier in {vm.id, vm.name, vm.guest_ip}: + return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive") + raise NotFoundError("VM not found") + + def list_vms(self): + return [self.inspect(vm_id) for vm_id in self.records] + + def open_terminal(self, vm_id, **_size): + if self.get(vm_id).status != "running": + raise ConflictError("Start the machine first") + return BrowserTerminal(), "worker" + + def install_guest_agent(self, vm_id): + return self.get(vm_id) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--base", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + application = fake_application(api_token="browser-test-token") + application.settings.base = args.base + application.lifecycle = BrowserLifecycle() + application.state_store = SimpleNamespace(get=application.lifecycle.get) + api = create_app(application, host="127.0.0.1") + server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning")) + api.state.uvicorn_server = server + server.run() + + +if __name__ == "__main__": + main() diff --git a/tests/browser_sessions.cjs b/tests/browser_sessions.cjs new file mode 100644 index 0000000..d121fe3 --- /dev/null +++ b/tests/browser_sessions.cjs @@ -0,0 +1,101 @@ +// Optional integration test: requires Playwright + Chromium and the project's test extra. +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { mkdtemp, rm } = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +(async () => { + const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-')); + const port = process.env.NEST_BROWSER_TEST_PORT || '18765'; + const origin = `http://127.0.0.1:${port}`; + const server = spawn(process.env.NEST_TEST_PYTHON || 'python3', + ['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port], + { cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] }); + let logs = ''; + server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); }); + server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); }); + let browser; + let page; + try { + let ready = false; + for (let attempt = 0; attempt < 100; attempt++) { + if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`); + try { + const response = await fetch(`${origin}/health`); + if (response.ok) { ready = true; break; } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(ready, `Fixture server did not start: ${logs}`); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected'); + const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value); + await page.goto(`${origin}/dashboard#vm/vm-test`); + await page.locator('#token-input').fill('browser-test-token'); + await page.locator('#auth-btn').click(); + await page.locator('#detail-actions [data-action="launch-session"]').click(); + await connected(); + const firstUrl = page.url(); + assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/); + await page.keyboard.type('echo alpha'); + await page.keyboard.press('Enter'); + await output('result:echo alpha'); + + await page.reload(); + await connected(); + await output('result:echo alpha'); + assert.equal(await page.locator('.session-item').count(), 1); + + await page.locator('#detail-actions [data-action="launch-session"]').click(); + await page.waitForURL((url) => url.href !== firstUrl); + await connected(); + assert.equal(await page.locator('.session-item').count(), 2); + assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha')); + await page.keyboard.type('echo beta'); + await page.keyboard.press('Enter'); + await output('result:echo beta'); + await page.locator('.session-item').filter({ hasText: 'Session 1' }).click(); + await connected(); + await output('result:echo alpha'); + assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta')); + + await page.goto(`${origin}/dashboard#vm/vm-other/sessions`); + await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' }); + await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0'); + assert.equal(await page.locator('.session-item').count(), 0); + assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled()); + assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha')); + + await page.goto(firstUrl); + await connected(); + await output('result:echo alpha'); + await page.locator('#session-end').click(); + await page.locator('#confirm-ok').click(); + await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed')); + await page.reload(); + await output('result:echo alpha'); + assert.ok(await page.locator('#session-end').isDisabled()); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(300); + const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth); + assert.ok(fits, 'mobile layout overflows the viewport'); + assert.deepEqual(errors, []); + if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true }); + console.log('Browser sessions passed: launch, terminal input, reload/reconnect, multiple sessions, VM isolation, closed history, and mobile layout.'); + } catch (error) { + if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent()); + console.error(logs); + throw error; + } finally { + await browser?.close(); + server.kill('SIGTERM'); + if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve)); + await rm(temporary, { recursive: true, force: true }); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/test_guest_agent_images.py b/tests/test_guest_agent_images.py index 714bf8f..16741d8 100644 --- a/tests/test_guest_agent_images.py +++ b/tests/test_guest_agent_images.py @@ -139,8 +139,23 @@ class GuestAgentImageTests(unittest.TestCase): before = self.snapshot() self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) self.assertEqual(self.snapshot(), before) - self.replace_guest_file("/etc/uvm-image", "uvm-ubuntu-jammy-v4\n") - self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker")) + 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")) + + def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None: + self.make_disk() + self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True) + self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True) + old_payload = "previous packaged agent\n" + self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload) + before = self.snapshot() + with self.assertRaisesRegex(UvmError, "customized guest agent"): + self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True) + self.assertEqual(self.snapshot(), before) + with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()): + self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)) + self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode()) def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None: self.make_disk() diff --git a/tests/test_guest_network.py b/tests/test_guest_network.py index c669fb9..79dda7f 100644 --- a/tests/test_guest_network.py +++ b/tests/test_guest_network.py @@ -108,7 +108,9 @@ class OfflineGuestNetworkTests(unittest.TestCase): def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None: for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE), ("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE), - ("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)): + ("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE), + ("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE), + ("uvm-ubuntu-jammy-v5\n", GUEST_NETWORK_TEMPLATE)): with self.subTest(marker=marker): self.make_disk(marker=marker, profile=profile) original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest() diff --git a/uvm/images.py b/uvm/images.py index 6eee8bf..7b5d4c1 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -89,6 +89,11 @@ check_python """ +# The original v4 template shipped this exact agent without an ownership +# manifest. Recognize that payload when creating from a cached v4 template. +_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948" + + def guest_agent_files() -> dict[str, bytes]: """The same standalone payload is used by the builder and offline upgrades.""" return { @@ -283,7 +288,7 @@ class ImageStore: ) if marker is None or marker.path.read_text(encoding="utf-8") not in { "uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n", - "uvm-ubuntu-jammy-v4\n", + "uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n", }: return None profile = self._read_guest_file( @@ -374,7 +379,10 @@ class ImageStore: existing = self._agent_file(disk, path, files / f"existing-{index}") if existing is not None: digest = hashlib.sha256(existing.path.read_bytes()).hexdigest() - if digest not in {hashes[path], old_hashes.get(path)}: + allowed = {hashes[path], old_hashes.get(path)} + if not old_hashes and path == "/usr/lib/uvm/agent.py": + allowed.add(_V4_TEMPLATE_AGENT_SHA256) + if digest not in allowed: raise UvmError(f"refusing to overwrite a customized guest agent file: {path}") source = files / f"payload-{index}" source.write_bytes(content) diff --git a/uvm/install.py b/uvm/install.py index d2a28d0..beb1cd9 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -2,6 +2,8 @@ from __future__ import annotations +import hashlib +import json import os import shlex import shutil @@ -397,12 +399,17 @@ class Installer: machine_id.symlink_to("../../../etc/machine-id") for key in (root / "etc/ssh").glob("ssh_host_*_key*"): key.unlink() - for guest_path, contents in guest_agent_files().items(): + agent_payloads = guest_agent_files() + for guest_path, contents in agent_payloads.items(): path = root / guest_path.lstrip("/") path.parent.mkdir(parents=True, exist_ok=True) path.parent.chmod(0o755) path.write_bytes(contents) path.chmod(0o644) + manifest = root / "etc/uvm-agent-files.json" + manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()}, + sort_keys=True) + "\n", encoding="utf-8") + manifest.chmod(0o600) self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service", "uvm-agent.service"), capture=True) self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)