Dev #6

Merged
kstyagi merged 9 commits from dev into main 2026-09-25 15:47:12 +00:00
5 changed files with 517 additions and 2 deletions
Showing only changes of commit d202f9e2f8 - Show all commits
+54
View File
@@ -0,0 +1,54 @@
.machine-tabs { display: flex; gap: 8px; margin: 0 0 24px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.machine-tab { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 22px; color: var(--muted); font-size: 11px; }
.machine-tab.active { background: var(--white); color: var(--ink); box-shadow: var(--shadow-card); }
.machine-tab .icon { width: 14px; height: 14px; }
.session-section-heading { margin-bottom: 14px; }
.session-workspace { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: 16px; align-items: stretch; }
.session-history { padding: 14px 10px; min-width: 0; display: flex; flex-direction: column; }
.session-history > header { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 10px; }
.session-history h3 { font-size: 11px; }
.session-history > .panel-footnote { margin-top: auto; padding: 15px 8px 0; }
.session-list { display: grid; gap: 6px; max-height: 510px; overflow-y: auto; }
.session-list-empty { padding: 24px 9px; color: var(--muted); font-size: 10px; line-height: 1.8; }
.session-item { display: grid; grid-template-columns: 1fr auto; gap: 7px; padding: 12px 10px; border: 1px solid transparent; border-radius: 12px; color: var(--ink-soft); }
.session-item:hover { background: #fff8; }
.session-item.active { background: var(--white); border-color: #d9d0e4; }
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
.session-item small { grid-column: 1 / -1; color: var(--muted); font-size: 9px; }
.session-state { align-self: center; width: 6px; height: 6px; border-radius: 50%; background: var(--lilac); }
.session-state.active { background: var(--sage); box-shadow: 0 0 0 3px var(--sage-soft); }
.session-state.failed, .session-state.interrupted { background: var(--peach); }
.terminal-panel { min-width: 0; overflow: hidden; border-radius: 17px; background: #25222d; color: #e8e3ef; box-shadow: var(--shadow-card); }
.terminal-toolbar { min-height: 56px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #ffffff12; background: #302c39; }
.terminal-toolbar > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.terminal-toolbar strong { font-size: 11px; }
.terminal-status { color: #b8afc6; font-size: 9px; }
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
.terminal-toolbar .button:hover { background: #ffffff19; }
.terminal-lights { display: inline-flex; gap: 4px; margin-right: 3px; }
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
.terminal-lights i:nth-child(2) { background: var(--yellow); }
.terminal-lights i:nth-child(3) { background: var(--sage); }
.session-terminal { height: 480px; padding: 14px 12px 10px; overflow: hidden; }
.session-terminal .xterm { height: 100%; }
.session-terminal .xterm-viewport { scrollbar-width: thin; scrollbar-color: #766a88 #25222d; }
.terminal-blank { min-height: 480px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 17px; text-align: center; padding: 24px; }
.terminal-blank > .icon { width: 37px; height: 37px; color: #b3a5ca; }
.terminal-blank h3 { font-size: 20px; font-weight: 450; }
.terminal-blank p { color: #b2a9bf; font-size: 11px; line-height: 1.8; }
.terminal-footer { min-height: 43px; padding: 11px 16px; color: #b5abc4; font-size: 9px; border-top: 1px solid #ffffff0d; overflow-wrap: anywhere; }
.terminal-support { margin-top: 18px; padding: 15px 19px; font-size: 10px; color: var(--muted); }
.terminal-support summary { cursor: pointer; color: var(--ink-soft); }
.terminal-support p { margin: 12px 0; max-width: 680px; line-height: 1.8; }
.terminal-support .muted { margin-left: 12px; }
@media (max-width: 1100px) {
.session-workspace { grid-template-columns: minmax(0, 1fr); }
.session-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); max-height: 190px; }
.session-history > .panel-footnote { display: none; }
}
@media (max-width: 700px) {
.session-terminal { height: 400px; }
.terminal-blank { min-height: 330px; }
.session-section-heading { flex-wrap: wrap; }
.terminal-support .muted { display: block; margin: 10px 0 0; }
}
+30 -1
View File
@@ -9,8 +9,13 @@
<meta name="description" content="Nest — a local workspace for your machines, network, and infrastructure." />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 22V10l14 12V10' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
<script src="/dashboard/static/js/dashboard.js?v=3" defer></script>
<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-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/dashboard.js?v=4" defer></script>
</head>
<body>
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
@@ -171,6 +176,11 @@
<div id="detail-content" hidden>
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div>
<nav class="machine-tabs" aria-label="Selected machine views">
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
</nav>
<div id="vm-overview-panel">
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel resources-panel">
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
@@ -186,6 +196,25 @@
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
</div>
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
<p id="sessions-error" class="form-error" role="alert"></p>
<div class="session-workspace">
<aside class="session-history soft-panel" aria-label="Session history">
<header><h3>Session history</h3><button id="sessions-refresh" class="icon-button" type="button" aria-label="Refresh sessions"><svg class="icon"><use href="#i-refresh"/></svg></button></header>
<div id="session-list" class="session-list"></div>
<p class="panel-footnote">Saved for this microVM.</p>
</aside>
<article class="terminal-panel">
<header class="terminal-toolbar"><div><span class="terminal-lights" aria-hidden="true"><i></i><i></i><i></i></span><strong id="session-title">Terminal</strong><span id="session-status" class="terminal-status">Select a session</span></div><div class="button-row"><button id="session-reconnect" class="button button-small" type="button" hidden>Reconnect</button><button id="session-end" class="button button-small" type="button" disabled>End session</button></div></header>
<div id="session-blank" class="terminal-blank"><svg class="icon"><use href="#i-terminal"/></svg><h3>A little room to work.</h3><p>Select a saved session, or launch a fresh terminal<br />inside this microVM.</p><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></div>
<div id="session-terminal" class="session-terminal" aria-label="MicroVM terminal" hidden></div>
<footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer>
</article>
</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>
</section>
</div>
</section>
+1 -1
View File
@@ -26,4 +26,4 @@ packages = ["nest", "nest.dashboard", "uvm", "uvm.firecracker", "uvm.routers"]
include-package-data = false
[tool.setuptools.package-data]
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt", "static/vendor/*.md", "static/vendor/*/LICENSE", "static/vendor/*/lib/*.js", "static/vendor/*/css/*.css"]
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import asyncio
import os
import pwd
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from tests.test_guest_exec import FakeScope
from tests.test_lifecycle import LifecycleTestCase
from uvm import guest_agent as agent
from uvm.errors import ConflictError, GuestAgentUnavailableError, NotFoundError
from uvm.guest_terminal import GuestTerminal
class GuestTerminalTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.scope = FakeScope()
self.addCleanup(self.scope.temporary.cleanup)
self.home = self.enterContext(tempfile.TemporaryDirectory())
username = pwd.getpwuid(os.geteuid()).pw_name
original_popen = subprocess.Popen
# Exercise the actual guest child entry point with a temporary account
# home, so interactive shells never read/write the developer's dotfiles.
script = f"""
import os, runpy
from pathlib import Path
from types import SimpleNamespace
agent = runpy.run_path({agent.__file__!r})
agent['pwd'].getpwnam = lambda _: SimpleNamespace(pw_name={username!r}, pw_uid=os.geteuid(),
pw_gid=os.getegid(), pw_dir={self.home!r}, pw_shell='/bin/sh')
agent['child_main'](Path({str(self.scope.path)!r}), {username!r}, None)
"""
def launch(_args, **kwargs):
return original_popen([sys.executable, "-I", "-c", script], **kwargs)
self.enterContext(patch.object(agent.subprocess, "Popen", side_effect=launch))
left, right = socket.socketpair()
self.terminal = GuestTerminal(left)
self.addCleanup(self.terminal.close)
def runner(connection, account, rows, cols):
return agent.run_terminal(connection, account, rows, cols, scope_factory=lambda: self.scope)
self.thread = threading.Thread(target=agent.serve_connection,
args=(right, {"vm_id": "vm-test", "username": username}),
kwargs={"terminal_runner": runner}, daemon=True)
self.thread.start()
self.addCleanup(self.thread.join, 3)
agent.receive_frame(left, 4096, time.monotonic() + 2)
await asyncio.to_thread(self.terminal.start, 24, 80)
self.prompt = b"# " if os.geteuid() == 0 else b"$ "
await self.until(self.prompt)
async def until(self, wanted):
data = bytearray()
try:
async with asyncio.timeout(5):
while wanted not in data:
message = await self.terminal.read()
self.assertEqual(message["type"], "output", message)
data.extend(message["data"])
except TimeoutError:
self.fail(f"Missing {wanted!r} in terminal output: {bytes(data)!r}")
return bytes(data)
async def asyncTearDown(self):
self.terminal.close()
await asyncio.to_thread(self.thread.join, 3)
self.assertFalse(self.thread.is_alive())
async def test_terminal_has_tty_persistent_shell_state_and_exit_code(self):
await self.terminal.write(b"test -t 0 && printf 'REAL-TTY\\n'\r")
await self.until(b"\r\nREAL-TTY\r\n")
await self.terminal.write(b"export NEST_TEST=hello; cd /\r")
await self.terminal.write(b"printf 'STATE:%s:%s\\n' \"$NEST_TEST\" \"$PWD\"\r")
await self.until(b"STATE:hello:/\r\n")
await self.terminal.write(b"exit 7\r")
async with asyncio.timeout(5):
while True:
message = await self.terminal.read()
if message["type"] == "exit":
self.assertEqual(message["exit_code"], 7)
break
async def test_resize_and_control_c_reach_the_guest_foreground_job(self):
await self.terminal.resize(40, 120)
await self.terminal.write(b"stty size\r")
await self.until(b"40 120\r\n")
await self.terminal.write(b"sleep 20\r")
await asyncio.sleep(0.1)
await self.terminal.write(b"\x03")
await self.until(self.prompt)
await self.terminal.write(b"printf 'INTERRUPTED\\n'\r")
await self.until(b"\r\nINTERRUPTED\r\n")
async def test_explicit_end_terminates_the_shell(self):
await self.terminal.write(b"printf 'READY\\n'\r")
await self.until(b"\r\nREADY\r\n")
await self.terminal.end()
async with asyncio.timeout(5):
while (message := await self.terminal.read())["type"] != "exit":
pass
self.assertIsInstance(message["exit_code"], int)
self.assertGreater(self.scope.kills, 0)
class TerminalCompatibilityTests(unittest.TestCase):
def test_old_agent_rejection_explains_how_to_upgrade(self):
left, right = socket.socketpair()
terminal = GuestTerminal(left)
self.addCleanup(terminal.close)
def old_agent():
with right:
agent.receive_frame(right, 4096, time.monotonic() + 2)
agent.send_frame(right, {"error": "invalid execution request fields", "outcome_unknown": False},
4096, time.monotonic() + 2)
thread = threading.Thread(target=old_agent)
thread.start()
try:
with self.assertRaisesRegex(GuestAgentUnavailableError, "Install / update terminal support"):
terminal.start(24, 80)
finally:
thread.join(3)
class TerminalLifecycleTests(LifecycleTestCase):
def test_terminal_connects_under_lock_and_opens_without_holding_the_lock(self):
vm = self.create_vm(name="worker", ports=())
terminal = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return terminal
def start(rows, cols):
self.assertFalse(self.store.operation_active)
self.assertEqual((rows, cols), (40, 120))
self.service._terminal_connector = Mock(side_effect=connect)
terminal.start.side_effect = start
self.assertEqual(self.service.open_terminal(vm.id, rows=40, cols=120), (terminal, vm.username))
terminal.close.assert_not_called()
def test_aliases_and_stopped_vms_cannot_create_a_terminal(self):
vm = self.create_vm(name="worker")
self.service._terminal_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.open_terminal(alias)
self.service.stop(vm.id)
with self.assertRaises(ConflictError):
self.service.open_terminal(vm.id)
self.service._terminal_connector.assert_not_called()
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
import asyncio
import base64
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from nest.server import create_app
from nest.session_store import SessionStore
from nest.sessions import SessionManager
from tests.test_server import FASTAPI_AVAILABLE, fake_application
from uvm.errors import ConflictError, GuestAgentUnavailableError, NotFoundError
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
class FakeTerminal:
def __init__(self) -> None:
self.events = asyncio.Queue()
self.events.put_nowait({"type": "output", "data": b"guest$ "})
self.input = []
self.sizes = []
self.closed = False
async def read(self):
return await self.events.get()
async def write(self, data):
self.input.append(data)
await self.events.put({"type": "output", "data": data + b"\r\nguest$ "})
async def resize(self, rows, cols):
self.sizes.append((rows, cols))
async def end(self):
await self.events.put({"type": "exit", "exit_code": 0})
def close(self):
self.closed = True
def session_application(base):
application = fake_application(api_token="test-token")
application.settings.base = Path(base)
application.terminals = []
application.vm_ids = {"vm-test", "vm-other"}
def get(vm_id):
if vm_id not in application.vm_ids:
raise NotFoundError("VM not found")
return SimpleNamespace(id=vm_id)
def open_terminal(vm_id, **_size):
get(vm_id)
terminal = FakeTerminal()
application.terminals.append(terminal)
return terminal, "worker"
application.state_store = SimpleNamespace(get=get)
application.lifecycle = SimpleNamespace(open_terminal=open_terminal)
return application
class SessionStoreTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.path = Path(temporary.name) / "nest" / "sessions.sqlite3"
self.store = SessionStore(self.path, max_output=8)
self.addCleanup(self.store.close)
def test_history_is_scoped_by_exact_vm_id_and_survives_reopening(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
self.store.append(record["id"], b"hello")
self.store.finish(record["id"], "closed", 7)
self.assertEqual(self.store.list("vm-b"), [])
with self.assertRaises(NotFoundError):
self.store.get("vm-b", record["id"])
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
saved, start, output = reopened.read("vm-a", record["id"], 0)
self.assertEqual((saved["status"], saved["exit_code"], saved["username"], start, output), ("closed", 7, "worker", 0, b"hello"))
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700)
def test_restarted_server_marks_only_live_sessions_interrupted(self):
live = self.store.create("vm-a", 24, 80)
ended = self.store.create("vm-a", 24, 80)
self.store.activate(live["id"], "worker")
self.store.finish(ended["id"], "closed", 0)
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.get("vm-a", live["id"])["status"], "interrupted")
self.assertIsNotNone(reopened.get("vm-a", live["id"])["ended_at"])
self.assertEqual(reopened.get("vm-a", ended["id"])["status"], "closed")
def test_transcript_is_bounded_and_offsets_support_exact_replay(self):
record = self.store.create("vm-a", 24, 80)
self.store.append(record["id"], b"abcdefghij")
self.store.append(record["id"], b"kl")
saved, start, output = self.store.read("vm-a", record["id"], 0)
self.assertEqual((start, output, saved["output_end"], saved["truncated"]), (4, b"efghijkl", 12, True))
self.assertEqual(self.store.read("vm-a", record["id"], 9)[1:], (9, b"jkl"))
self.assertEqual(self.store.read("vm-a", record["id"], 12)[2], b"")
def test_second_server_cannot_reconcile_another_servers_live_sessions(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
with self.assertRaises(ConflictError):
SessionStore(self.path)
self.assertEqual(self.store.get("vm-a", record["id"])["status"], "active")
def test_symlinked_history_file_is_not_opened_or_rewritten(self):
self.store.close()
self.path.unlink()
target = self.path.parent / "unrelated"
target.write_bytes(b"keep this")
self.path.symlink_to(target)
with self.assertRaises(OSError):
SessionStore(self.path)
self.assertEqual(target.read_bytes(), b"keep this")
class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.application = session_application(self.temporary.name)
self.manager = SessionManager(self.application)
self.addAsyncCleanup(self.manager.shutdown)
async def test_failed_launch_is_saved_in_history(self):
def unavailable(*_args, **_kwargs):
raise GuestAgentUnavailableError("update the guest agent")
self.application.lifecycle.open_terminal = unavailable
with self.assertRaises(GuestAgentUnavailableError):
await self.manager.create("vm-test", 24, 80)
history = await self.manager.list("vm-test")
self.assertEqual(history[0]["status"], "failed")
self.assertEqual(history[0]["error"], "update the guest agent")
async def test_shutdown_closes_live_channels_and_saves_interrupted_history(self):
record = await self.manager.create("vm-test", 24, 80)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
store = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3")
self.addCleanup(store.close)
self.assertEqual(store.get("vm-test", record["id"])["status"], "interrupted")
async def test_cancelling_the_create_request_does_not_orphan_the_shell(self):
import threading
entered, release = threading.Event(), threading.Event()
connect = self.application.lifecycle.open_terminal
def slow_connect(*args, **kwargs):
entered.set()
release.wait(2)
return connect(*args, **kwargs)
self.application.lifecycle.open_terminal = slow_connect
request = asyncio.create_task(self.manager.create("vm-test", 24, 80))
await asyncio.to_thread(entered.wait, 2)
request.cancel()
release.set()
await asyncio.gather(request, return_exceptions=True)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
class SessionApiTests(unittest.TestCase):
def setUp(self):
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
self.application = session_application(self.temporary)
self.api = create_app(self.application, host="127.0.0.1")
self.client = self.enterContext(TestClient(self.api))
self.headers = {"X-UVM-Token": "test-token"}
self.base = "/vms/vm-test/sessions"
def create(self):
response = self.client.post(self.base, headers=self.headers, json={})
self.assertEqual(response.status_code, 201, response.text)
return response.json()
def connect(self, record, *, vm_id="vm-test", offset=0):
ws = self.enterContext(self.client.websocket_connect(f"/vms/{vm_id}/sessions/{record['id']}/terminal"))
ws.send_json({"type": "auth", "token": "test-token", "offset": offset})
return ws
def output(self, ws):
while True:
message = ws.receive_json()
if message["type"] == "output":
data = base64.b64decode(message["data"])
end = message["offset"] + len(data)
ws.send_json({"type": "ack", "offset": end})
return data, end
self.assertEqual(message["type"], "session", message)
def test_all_http_operations_and_websocket_output_require_authentication(self):
for method, path in (("GET", self.base), ("POST", self.base),
("GET", self.base + "/session-unknown"), ("POST", self.base + "/session-unknown/close")):
self.assertEqual(self.client.request(method, path, json={}).status_code, 401)
for token in ("wrong", "", "\u00e9"):
with self.client.websocket_connect(self.base + "/session-unknown/terminal") as ws:
ws.send_json({"type": "auth", "token": token})
with self.assertRaises(WebSocketDisconnect) as raised:
ws.receive_json()
self.assertEqual(raised.exception.code, 4401)
self.assertEqual(self.application.terminals, [])
self.assertFalse((Path(self.temporary) / "nest").exists())
def test_foreign_browser_origin_is_rejected(self):
with self.assertRaises(WebSocketDisconnect):
with self.client.websocket_connect(self.base + "/unknown/terminal", headers={"Origin": "https://foreign.example"}):
self.fail("foreign origin accepted")
def test_create_rejects_unknown_vm_aliases_and_invalid_sizes(self):
for body in ({"rows": 0}, {"cols": True}, {"cols": 401}, {"command": "id"}):
self.assertEqual(self.client.post(self.base, headers=self.headers, json=body).status_code, 422)
self.assertEqual(self.client.post("/vms/name-alias/sessions", headers=self.headers, json={}).status_code, 404)
self.assertEqual(self.application.terminals, [])
def test_sessions_are_nested_under_the_selected_vm(self):
record = self.create()
self.assertEqual(record["username"], "worker")
other = "/vms/vm-other/sessions"
self.assertEqual(self.client.get(other, headers=self.headers).json(), [])
self.assertEqual(self.client.get(other + "/" + record["id"], headers=self.headers).status_code, 404)
self.assertEqual(self.client.post(other + "/" + record["id"] + "/close", headers=self.headers).status_code, 404)
ws = self.connect(record, vm_id="vm-other")
self.assertEqual(ws.receive_json()["type"], "error")
self.assertFalse(self.application.terminals[0].closed)
def test_disconnect_reconnect_input_resize_and_closed_history(self):
record = self.create()
ws = self.connect(record)
output, offset = self.output(ws)
self.assertEqual(output, b"guest$ ")
ws.send_json({"type": "resize", "rows": 35, "cols": 120})
ws.send_json({"type": "input", "data": base64.b64encode(b"pwd\r").decode()})
output, offset = self.output(ws)
self.assertEqual(output, b"pwd\r\r\nguest$ ")
self.assertEqual(self.application.terminals[0].sizes, [(35, 120)])
ws.close()
self.assertFalse(self.application.terminals[0].closed)
reopened = self.connect(record, offset=offset)
self.assertEqual(reopened.receive_json()["session"]["status"], "active")
self.assertEqual(len(self.application.terminals), 1)
closed = self.client.post(self.base + "/" + record["id"] + "/close", headers=self.headers)
self.assertEqual(closed.json()["status"], "closed")
self.assertEqual(closed.json()["exit_code"], 0)
replay = self.connect(record)
transcript, _end = self.output(replay)
self.assertEqual(transcript, b"guest$ pwd\r\r\nguest$ ")
self.assertEqual(self.client.get(self.base, headers=self.headers).json()[0]["status"], "closed")
self.assertTrue(self.application.terminals[0].closed)