Compare commits

..
15 Commits
Author SHA1 Message Date
kstyagi cc2ceb3bcc Merge pull request 'Dev' (#6) from dev into main
Reviewed-on: #6
2026-09-25 15:47:12 +00:00
kstyagi a4e9cefbc3 feat: enhance guest agent setup and terminal support for microVMs 2026-09-25 21:15:53 +05:30
kstyagi 6573f483e6 feat: update terminal support messaging and enhance guest agent provisioning logic 2026-09-25 21:12:21 +05:30
kstyagi d870c98bfa feat: add terminal session management with HTTP/WebSocket routes and session history 2026-09-25 20:31:20 +05:30
kstyagi caaea12163 feat: implement session management and terminal handling with UI updates and backend support 2026-09-25 20:22:56 +05:30
kstyagi d202f9e2f8 feat: add session management and terminal handling with UI updates and tests 2026-09-25 20:06:44 +05:30
kstyagi 6aa70133c5 Refactor code structure for improved readability and maintainability 2026-09-25 19:58:49 +05:30
kstyagi 73d85fefe4 feat: enhance terminal session handling with output acknowledgment and error handling 2026-09-25 19:54:07 +05:30
kstyagi 9541e29b76 feat: implement session management and terminal handling for VM sessions 2026-09-25 19:52:26 +05:30
kstyagi 3b94ac1b48 feat: add terminal support for guest execution agent and enhance lifecycle management 2026-09-25 19:51:35 +05:30
kstyagi 4663fd1923 Merge pull request 'Dev' (#5) from dev into main
Reviewed-on: #5
2026-09-25 14:20:33 +00:00
kstyagi 283eb4ecf7 Merge pull request 'Dev' (#4) from dev into main
Reviewed-on: #4
2026-09-23 02:53:57 +00:00
kstyagi 10a58daa56 Merge pull request 'feat: enhance user account management during VM creation with automatic user creation and metadata preservation' (#3) from dev into main
Reviewed-on: #3
2026-09-21 18:46:55 +00:00
kstyagi 309f28018e Merge pull request 'Dev' (#2) from dev into main
Reviewed-on: #2
2026-09-21 15:50:24 +00:00
kstyagi 99f9ffa57d Merge pull request 'Dev' (#1) from dev into main
Reviewed-on: #1
2026-09-20 03:41:01 +00:00
32 changed files with 2636 additions and 65 deletions
+103 -7
View File
@@ -71,6 +71,9 @@ Packet integration tests must remain inside disposable isolated namespaces.
│ ├── __main__.py # `python -m nest`
│ ├── cli.py # Nest workspace serve command
│ ├── server.py # Nest composition of the dashboard and UVM API
│ ├── sessions.py # Live guest terminals and browser-independent session lifecycle
│ ├── session_store.py # Private SQLite session history and bounded transcripts
│ ├── session_routes.py # VM-scoped session HTTP and WebSocket endpoints
│ └── dashboard/
│ ├── router.py # Nest dashboard document route
│ └── static/ # Packaged HTML, CSS, JavaScript, and fonts
@@ -91,6 +94,7 @@ Packet integration tests must remain inside disposable isolated namespaces.
│ ├── known_hosts.py # Deletion-only cleanup of local SSH host-key caches
│ ├── guest_agent.py # Standalone guest daemon, job scopes, and bounded wire protocol
│ ├── guest_exec.py # Host-side guest sessions and execution results
│ ├── guest_terminal.py # Async PTY transport over a process-pinned guest connection
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
@@ -877,8 +881,8 @@ or secret manager when that matters.
### API Contract
The root redirect and dashboard document are Nest routes. Both server entry
points expose the remaining management routes below.
The root redirect, dashboard document, and terminal-session routes belong to
Nest. Both server entry points expose the UVM management routes below.
| Method | Route | Router | Notes |
|---|---|---|---|
@@ -902,6 +906,11 @@ points expose the remaining management routes below.
| `POST` | `/vms/{vm_id}/exec` | `vms.py` | Exact ID; `command`, strict `sudo`, bounded `timeout_seconds` |
| `POST` | `/vms/{vm_id}/agent/install` | `vms.py` | Exact ID; install/upgrade the agent on a stopped disk |
| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail |
| `GET` | `/vms/{vm_id}/sessions` | `nest/session_routes.py` | Saved session list (Nest only) |
| `POST` | `/vms/{vm_id}/sessions` | `nest/session_routes.py` | Launch a guest PTY; optional rows/cols in a JSON object (Nest only) |
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | `nest/session_routes.py` | VM-scoped session metadata (Nest only) |
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | `nest/session_routes.py` | End a shell; retain history (Nest only) |
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | `nest/session_routes.py` | Authenticated replay/input/resize (Nest only) |
All management routes use `get_authorized_application()` and require:
@@ -969,7 +978,8 @@ The synchronous HTTP response exposes only exit code, stdout/stderr, timeout,
and truncation. Captured text uses UTF-8 replacement. Nonzero process exits are
HTTP 200 results; agent failures are 502, and failures before submission are 503.
Lost/malformed responses after submission carry `outcome_unknown: true`. There
is no command retry, durable job registry, streaming response, or reconnection.
is no command retry, durable job registry, streaming response, or reconnection
for these one-shot exec requests.
The root systemd agent accepts only host-CID connections and limits concurrent
connections/jobs to eight. Each request creates a delegated cgroup v2 job scope.
@@ -977,7 +987,8 @@ A fresh child interpreter joins the scope before initializing the chosen user's
supplementary groups, GID, UID, home, and environment, then execs `/bin/sh -c`.
`sudo` selects guest root directly; otherwise the recorded username is used.
Password authentication is not involved, and a recorded `root` login remains
root for both flag values. Input is closed and there is no PTY.
root for both flag values. One-shot commands have closed stdin; Nest terminal
sessions use the separate PTY channel described below.
Both output pipes are drained concurrently. The guest enforces the deadline,
signals the process group, escalates via `cgroup.kill`, and confirms cleanup.
@@ -986,15 +997,29 @@ PGID reuse before reap. Normal exits clean up remaining background descendants
too. An unconfirmed cleanup is an unknown outcome. Stopping the agent service
also kills its job scopes; dropping an HTTP connection does not cancel a job.
The v4 Ubuntu builder installs Python and the service. Creation provisions the
VM ID/username in root-only `/etc/uvm-agent.json` before marking the disk ready.
Older templates are left for an explicit stopped-disk `agent/install` request.
The v5 Ubuntu builder installs Python, the terminal-capable service, and an
ownership manifest. Agent setup is mandatory on every compatible image:
`create()` provisions the VM ID/username in root-only `/etc/uvm-agent.json` before
marking the disk ready, and `_start_locked()` ensures the agent after confirmed
runtime cleanup and pending resize, before booting. This covers stopped starts,
restarts, and resizing. There is no image-marker allowlist for agent setup.
An observed live or ambiguous VMM never permits offline agent writes.
`_guest_agent_current()` checks the expected payloads, identity, file metadata,
ownership manifest, and enabled service without mutating the disk. Current
installations return immediately. The explicit `agent/install` operation forces
a staged recheck for manual repair. The original manifest-less v4 agent is
recognized by its exact published source digest.
Provisioning and upgrades stage a sparse copy, replay/check ext4, validate guest
path ownership and recorded agent-file hashes, enable the service, verify writes,
and publish atomically. A root-only guest manifest records managed file hashes
so later upgrades preserve guest edits. An older guest missing Python installs
it through apt inside the guest on its next boot; no guest binaries execute on
the host. This bootstrap requires working guest egress until the runtime is ready.
Incompatible custom images or agent setup failures prevent boot with an actionable
error; they are not silently skipped. New-VM failures roll back creation, while
retained-VM failures preserve the private disk for retry.
The guest needs systemd, Python 3.10+, built-in virtio-vsock, and cgroup v2 with
`cgroup.kill` (Linux 5.14+). Host socket paths are derived from Settings, so the
@@ -1003,6 +1028,59 @@ device and uses CID 3. The process adapter retains both sockets while identity
is uncertain and removes them only after confirmed VMM exit. IPv4 port policies
do not govern this host-only management channel.
### Nest Terminal Sessions
Nest owns session HTTP/WebSocket routes and history. UVM owns the process-pinned
connection and guest PTY. `LifecycleService.open_terminal()` validates an exact
running VM ID and connects under the operation lock, then opens the PTY outside
the lock. It never reconnects that socket to a replacement VMM.
The existing version-1 hello and exec request stay compatible. A terminal request
uses `{"version":1,"terminal":{"rows":24,"cols":80}}`. The guest replies with
`{"type":"ready"}` and exchanges length-prefixed JSON output/input/resize/close
messages. Bytes are base64 encoded; raw chunks are at most 8192 bytes, frames
16384 bytes, and dimensions 2–200 rows / 2–400 columns. Partial frames have finite
deadlines while idle shells can remain connected indefinitely. An old agent
rejects the new request; the host reports the stopped-disk upgrade procedure.
The guest opens a real PTY and spawns a fresh interpreter in a new OS session.
The child joins its delegated job cgroup, establishes the controlling terminal,
assumes the recorded account, and execs that account's interactive shell with
`TERM=xterm-256color`. Input, terminal echo, Ctrl+C, foreground job control, and
window-size changes are handled by the guest terminal driver. Normal exit,
explicit close, or host connection loss cleans up the job cgroup and processes.
`SessionManager` owns background output readers independently of WebSocket
attachments. Creating a shell is shielded from an HTTP disconnect; its connection
is either tracked or closed. Browser navigation, reload, and logout detach only
the browser. Nest shutdown closes guest connections and marks live sessions
interrupted. In-flight transcript writes finish before releasing store ownership.
`SessionStore` keeps metadata and raw output in
`$UVM_BASE/nest/sessions.sqlite3`, separate from the VM registry. A private
directory, mode-0600 files, an exclusive owner lock, and SQLite transactions
protect history. On reopening after a server restart, unfinished records become
interrupted. Session lookups always include the exact VM ID. Output is capped at
the newest 8 MiB per session with byte offsets and an explicit truncation marker;
session metadata is retained. Only PTY output is recorded, not a separate input
or password log. Nest permits 32 active sessions; guest terminals share the
agent's eight connection slots with one-shot jobs.
HTTP routes use the existing `X-UVM-Token` dependency. Browser WebSockets check
the Origin and require `{"type":"auth","token":"...","offset":0}` as their
first frame within ten seconds. Tokens never enter query strings. Each output
message has its byte offset and base64 data; the browser acknowledges the next
offset after xterm renders the chunk. Only one chunk is outstanding, providing
backpressure. Reattachment resumes from an acknowledged offset; a truncated
prefix resets the viewer. Closed sessions use the same channel for read-only
replay. WebSocket transport support comes from the `websockets` dependency.
The dashboard's Sessions tab and nested hash routes belong to the selected VM.
`sessions.js` owns attachment teardown, replay, reconnects, and the terminal
widget. Vendored xterm 5.5.0 and fit addon 0.10.0 (with MIT licenses) are served
locally under `static/vendor/`. Terminal keyboard input bypasses dashboard search
shortcuts. The widget has a 10,000-line scrollback and a 64 KiB per-paste limit.
## 14. Configuration Reference
The environment-variable table is in [README.md](README.md#configuration);
@@ -1088,6 +1166,24 @@ FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
are installed. They should be enabled in CI using the `test` extra.
`test_nest_server.py` checks the workspace launcher, relocated frontend assets,
authenticated machine controls, API documentation, and graceful shutdown.
`test_sessions.py` covers scoped persistence, interrupted-session recovery,
authentication, reconnects, output acknowledgements, and cancellation cleanup.
`test_guest_terminal.py` exercises actual PTYs, persistent shell state, resizing,
Ctrl+C, and child cleanup using temporary account homes and fake cgroup scopes.
Optional browser regression checks use Playwright against an isolated fixture
server with fake VMs and guest channels. With the project's test extra installed:
```sh
npm install --prefix /tmp/nest-browser playwright
/tmp/nest-browser/node_modules/.bin/playwright install chromium
NODE_PATH=/tmp/nest-browser/node_modules \
NEST_TEST_PYTHON="$(command -v python)" node tests/browser_sessions.cjs
```
This checks launch/input, reload/reconnect, session switching, per-VM isolation,
closed-session replay, and the mobile layout. It does not boot a real microVM.
When `nft` and `conntrack` are installed, command-parser tests append an invalid
final command/option so parsing is checked without executing any mutation.
These checks supplement fixture-based readback and cleanup tests; they do not
+58 -9
View File
@@ -310,8 +310,9 @@ and `/dashboard` routes belong to the Nest server.
The Nest dashboard includes a fleet overview, searchable machine collection,
private network and port-policy view, host diagnostics, and machine provisioning.
Open a machine to manage its lifecycle, edit or sync its port rules, copy its SSH
command, or read recent logs. Select machines in the collection for bulk operations.
Open a machine to manage its lifecycle, launch terminal sessions, edit or sync
its port rules, copy its SSH command, or read recent logs. Select machines in the
collection for bulk operations.
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
five seconds while the tab is visible; host checks refresh every thirty seconds
and can also be run manually. Charts show observed machine states and allocated
@@ -342,6 +343,11 @@ The server exposes these routes:
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
| `POST` | `/vms/{vm_id}/sessions` | Launch a guest terminal; body: `{}` or `{"rows":24,"cols":80}` (Nest) |
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Session metadata and status (Nest) |
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End the shell and retain its history (Nest) |
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated terminal input, output, resize, and replay (Nest) |
API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`;
`no_default_ports` is a boolean, default `false`. Create also accepts `storage`,
@@ -359,6 +365,47 @@ Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or
for each fixed target and do not prompt for confirmation. Interactive SSH
remains CLI-only. The API and dashboard are not background reconcilers.
### MicroVM Terminal Sessions
Open **Machines → select a microVM → Sessions**, then click **Launch session**.
Each session opens an interactive guest shell in the dashboard, with terminal
colors, keyboard input, Ctrl+C, resizing, and persistent shell state such as the
working directory. Sessions run as the VM's configured guest user; normal guest
`sudo` commands can request that user's password in the terminal.
The history list belongs to the selected VM. Reopen an active session to
continue using the same shell, or select an ended session to view its saved
output. Navigation, browser reloads, and signing out leave the shell running.
**End session** closes the shell and its processes. Stopping/restarting the VM
or the Nest server ends live sessions; their saved history remains available.
Nest stores session metadata and the latest **8 MiB of output per session** in
`$UVM_BASE/nest/sessions.sqlite3` (default `/var/lib/uvm/nest/sessions.sqlite3`).
The viewer indicates truncated output. Raw keyboard input is not logged
separately; the transcript contains what the guest terminal outputs. History
files are private to the host account running Nest. One Nest server owns
sessions for a given VM registry at a time, with up to 32 active sessions; the
guest agent shares its eight connection slots between terminals and exec calls.
**Vsock and the guest agent are configured by default for every microVM.**
Creation installs and enables the agent on every compatible image, including
cached older templates and custom images. Each stopped-disk boot also installs
or updates it automatically; an already-current installation is a read-only
check. Existing running VMs receive the setup on their next restart.
The **Sessions → Guest terminal support → Repair / update terminal support**
control is available for manual repair on a stopped VM. The guest-agent/kernel
requirements described below apply to terminals.
Terminal support adds the Python `websockets` dependency. In your micromamba
environment, install the current project and launch with its interpreter:
```sh
micromamba activate nest
python -m pip install -e '.[test]'
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
"$CONDA_PREFIX/bin/python" -m nest --serve
```
### Execute Commands Without SSH
Use an exact VM ID from create/list:
@@ -378,14 +425,15 @@ uses guest root without a password prompt. A VM configured with username `root`
still runs as root when `sudo` is false.
Execution uses a guest systemd agent over Firecracker vsock. It works with SSH
disabled and with an empty TCP allowlist. Rebuild the default Ubuntu template
with `uvm install --force` using your configured checksums to include the agent
and Python in new VMs. Restart the management server to load these routes.
disabled and with an empty TCP allowlist. Every Firecracker boot attaches the
vsock device, and creation/start/restart/resize ensure the guest service is
installed and enabled before boot. Restart the management server to load this
behavior, then restart older VMs once to apply it automatically.
For an existing VM, call **stop → `POST /vms/{vm_id}/agent/install` → start**.
Installation stages and verifies a private disk copy, preserving guest data and
credentials. If Python is missing, the installed bootstrap service acquires it
inside the guest on the next boot; this one-time step needs guest internet access.
Installation stages and verifies a private disk copy when changes are needed,
preserving guest data and credentials. If Python is missing, the installed
bootstrap service acquires it inside the guest on boot; this one-time step needs
guest internet access. The default Ubuntu template already includes Python.
The guest needs Python 3.10+, systemd with cgroup v2, and a vsock-capable Linux
5.14+ kernel; UVM's default Linux 6.1 kernel provides the required drivers.
@@ -454,6 +502,7 @@ defaults are username `root` and password `root`. A custom image must provide:
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
- `/etc/group` and `/bin/sh` when creating a new user
- A root-owned, setuid `/usr/bin/sudo` and `/etc/sudoers` for custom login users
- systemd and apt for the automatically installed vsock guest agent (Ubuntu 22.04+)
Custom login users receive password-authenticated sudo access. Inside a new VM:
+58 -16
View File
@@ -801,7 +801,8 @@ The installed command is `nest --serve`; `python3 -B -m nest --serve` also
works from the source checkout. Nest uses `UVM_API_TOKEN` and the `UVM_*` host
settings for its machine-management backend. To launch the standalone UVM API,
use `./uvm.py --serve` with the same host/port options and configuration. That
server exposes the management endpoints and `/docs`; Nest serves the dashboard.
server exposes the UVM management endpoints and `/docs`; Nest serves the
dashboard and terminal-session endpoints.
The API token is required even for a loopback server. All VM, diagnostics, and
installation routes require it. The dashboard shell, liveness route, and FastAPI's
@@ -812,6 +813,42 @@ with no whitespace. Use a protected environment file or secret manager rather
than recording a real token in shell history; do not share tokens or guest
passwords in reports.
### Use Sessions Inside a MicroVM
1. Open **Machines** and select the microVM.
2. Start it if it is stopped.
3. Click **Launch session**, or open its **Sessions** tab and launch there.
4. Type directly in the embedded terminal. Each session keeps its own shell,
current directory, environment, and foreground program.
5. Use **Session history** to switch between that VM's sessions. Active sessions
reconnect to their existing shell; ended sessions replay saved output.
6. Use **End session** when finished, or run `exit` inside the shell.
Sessions continue while you navigate elsewhere, reload, or sign out of the
dashboard. VM shutdown/restart or Nest shutdown ends the shell. Session metadata,
start/end times, exit status, and output survive Nest restarts in
`$UVM_BASE/nest/sessions.sqlite3`; files are mode `0600` in a mode-`0700` directory.
Only sessions belonging to the selected exact VM ID are shown. Reusing a VM name
does not attach another VM's history.
The retained transcript is capped at its latest 8 MiB, and the terminal viewer
has a 10,000-line scrollback. A truncation note appears when older output is
discarded. Terminal input supports pastes up to 64 KiB at a time. Guest output
is saved even while no browser is attached.
Vsock and the guest agent are set up automatically for every microVM during
creation and on each stopped-disk boot. This includes compatible older and custom
images. Restart an older VM once to apply the setup; **Sessions → Guest terminal
support → Repair / update terminal support** remains available for manual repair
while stopped. If the agent is still starting or installing Python, wait for it
to become ready before launching a session.
The terminal transport uses the guest agent over vsock. It logs in as the VM's
configured username; `sudo` inside the terminal uses normal guest authentication.
Nest supports 32 active sessions per server; each guest agent shares eight
connection slots with its command-execution endpoint. Run one Nest session
manager per `UVM_BASE` registry. Reverse proxies must forward WebSocket upgrades.
### Start a TLS Server
Non-loopback binds require a valid TLS certificate and key. The files must be
@@ -876,6 +913,11 @@ curl --cacert /path/to/ca.pem \
| `POST` | `/vms/{vm_id}/exec` | Execute a guest command/script over vsock, with optional root privileges |
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the agent on a stopped VM's disk |
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
| `GET` | `/vms/{vm_id}/sessions` | List saved sessions for an exact VM ID (Nest) |
| `POST` | `/vms/{vm_id}/sessions` | Launch a terminal, with `{}` or optional `rows`/`cols` (Nest) |
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Inspect a session (Nest) |
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End a session and retain its output (Nest) |
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated live terminal/replay (Nest) |
Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt
for confirmation. `timeout` is an optional finite, non-negative query parameter
@@ -1138,26 +1180,26 @@ print(result["exit_code"])
#### Prepare New or Existing VMs
The default Ubuntu template recipe v4 includes Python 3 and `uvm-agent.service`.
Rebuild it with `uvm install --force` using the checksum settings from the
installation section, then create new VMs. Cached older templates and custom
images require the explicit agent installation below. Restart a running Nest or
UVM API server to load the new code before making these requests.
The default Ubuntu template includes Python 3 and `uvm-agent.service`. UVM also
installs the service automatically on compatible older/custom images during
creation and before booting a retained private disk. Every VM gets a Firecracker
vsock device by default. Agent files, VM identity configuration, the ownership
manifest, and the enabled service are checked; an already-current installation
does not rewrite the disk.
For an existing VM, run these requests in order, checking each succeeds:
Restart a running Nest or UVM API server to load the new code. Existing VMs pick
up the setup on their next start or restart. For a currently running VM:
```sh
curl --fail-with-body -X POST -H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/$VM_ID/stop"
curl --fail-with-body -X POST -H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/$VM_ID/agent/install"
curl --fail-with-body -X POST -H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/$VM_ID/start"
"$UVM_API_URL/vms/$VM_ID/restart"
```
Installation returns `{"id":"...","status":"installed","restart_required":true}`.
It requires a cleanly stopped, proven-offline private disk and does not start the
VM itself. Updates are checked on a sparse copy before publication. Guest files,
The explicit `POST /vms/{vm_id}/agent/install` endpoint is available for manual
repair while stopped. It returns
`{"id":"...","status":"installed","restart_required":true}` and does not start
the VM itself. All agent installation requires a proven-offline private disk;
updates are checked on a sparse copy before publication. Guest files,
credentials, and SSH keys are retained; customized agent files are preserved
rather than overwritten. Repeating installation also upgrades unedited managed
agent files using their recorded checksums.
@@ -1166,7 +1208,7 @@ The guest image must provide systemd and apt (Ubuntu 22.04 or newer is the
supported baseline). If Python 3.10+ is missing, a bootstrap service installs
`python3` inside the VM on its next boot. This one-time install needs guest DNS,
internet access, and working apt repositories. The service retries failed startup;
execution returns 503 until it can connect. New v4 templates already include
execution returns 503 until it can connect. Current default templates already include
the runtime and do not need this download.
The guest kernel needs built-in `CONFIG_VSOCKETS=y`, `CONFIG_VIRTIO_VSOCKETS=y`,
+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; }
}
+35 -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=2" 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,30 @@
<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 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>
</div>
</section>
+42 -4
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -598,9 +607,22 @@
const busy = state.busy.has(vm.id);
const running = runtimeStatus(vm) === 'running';
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
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))
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
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;
+363
View File
@@ -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…' : '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();
}
renderList() {
const markup = this.records.length ? this.records.map((record) => `
<a class="session-item${record.id === this.selectedId ? ' active' : ''}" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
</a>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
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;
}
};
})();
+9
View File
@@ -0,0 +1,9 @@
# Local terminal assets
Runtime files from the published npm packages:
- `@xterm/xterm` **5.5.0** — `xterm/lib/xterm.js`, `xterm/css/xterm.css`
- `@xterm/addon-fit` **0.10.0** — `xterm-fit/lib/addon-fit.js`
Both use the MIT license; the original `LICENSE` is included in each directory.
Assets are served locally. Running Nest requires no npm install or frontend build.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
@@ -21,12 +22,29 @@ def create_app(application: Application | None = None, *, host: str | None = Non
from fastapi.staticfiles import StaticFiles
from .dashboard.router import router as dashboard_router
from .session_routes import router as session_router
from .sessions import SessionManager
sessions = SessionManager(api.state.uvm_application)
api.state.nest_sessions = sessions
previous_lifespan = api.router.lifespan_context
@asynccontextmanager
async def lifespan(app):
async with previous_lifespan(app):
try:
yield
finally:
await sessions.shutdown()
api.router.lifespan_context = lifespan
api.title = "Nest"
api.description = "Nest workspace API, with local Firecracker machine management powered by UVM."
static_dir = Path(__file__).parent / "dashboard" / "static"
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
api.include_router(dashboard_router, prefix="/dashboard")
api.include_router(session_router)
@api.get("/")
async def root() -> Any:
+171
View File
@@ -0,0 +1,171 @@
"""VM-scoped session history, creation, and authenticated browser terminal streams."""
from __future__ import annotations
import asyncio
import base64
import hmac
import json
from contextlib import suppress
from typing import Literal
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from uvm.app import Application
from uvm.errors import UvmError
from uvm.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data
from uvm.routers.dependencies import get_authorized_application
from .sessions import SessionManager
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
class SessionCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
rows: StrictInt = Field(default=24, ge=2, le=200)
cols: StrictInt = Field(default=80, ge=2, le=400)
class SessionResponse(BaseModel):
id: str
vm_id: str
name: str
username: str
status: Literal["starting", "active", "closed", "interrupted", "failed"]
created_at: float
updated_at: float
ended_at: float | None
rows: int
cols: int
exit_code: int | None
error: str | None
output_start: int
output_end: int
truncated: bool
def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager:
return request.app.state.nest_sessions
@router.get("", response_model=list[SessionResponse])
async def list_sessions(vm_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.list(vm_id)
@router.post("", response_model=SessionResponse, status_code=201)
async def create_session(vm_id: str, body: SessionCreate, sessions: SessionManager = Depends(manager)):
return await sessions.create(vm_id, body.rows, body.cols)
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.get(vm_id, session_id)
@router.post("/{session_id}/close", response_model=SessionResponse)
async def close_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.end(vm_id, session_id)
def _same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin")
if origin is None:
return True # Non-browser API clients must still authenticate.
try:
parsed = urlsplit(origin)
except ValueError:
return False
scheme = "https" if websocket.url.scheme == "wss" else "http"
return parsed.scheme == scheme and parsed.netloc.lower() == websocket.headers.get("host", "").lower() and not parsed.path
async def _message(websocket: WebSocket, *, limit: int = MAX_TERMINAL_FRAME) -> dict:
text = await websocket.receive_text()
if len(text) > limit:
raise ProtocolError("terminal message exceeds its size limit")
message = json.loads(text)
if not isinstance(message, dict):
raise ProtocolError("terminal message must be an object")
return message
@router.websocket("/{session_id}/terminal")
async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
if not _same_origin(websocket):
await websocket.close(code=1008)
return
await websocket.accept()
tasks = []
try:
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
# frame so tokens never appear in URLs, access logs, or session history.
auth = await asyncio.wait_for(_message(websocket, limit=4096), timeout=10)
token = auth.get("token")
expected = websocket.app.state.uvm_application.settings.api_token
if (auth.get("type") != "auth" or not isinstance(token, str) or not token.isascii()
or not expected or not hmac.compare_digest(token, expected)):
await websocket.close(code=4401)
return
sessions = websocket.app.state.nest_sessions
await sessions.get(vm_id, session_id)
offset = auth.get("offset", 0)
acknowledgements = asyncio.Queue(maxsize=1)
async def output():
cursor = offset
previous = None
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": SessionResponse(**record).model_dump(), "offset": start})
previous = record
cursor = start
if data:
await websocket.send_json({"type": "output", "offset": cursor, "data": base64.b64encode(data).decode("ascii")})
cursor += len(data)
# Only one output chunk may be waiting for the terminal renderer.
acknowledged = await asyncio.wait_for(acknowledgements.get(), timeout=30)
if type(acknowledged) is not int or acknowledged != cursor:
raise ProtocolError("invalid terminal output acknowledgement")
continue
if record["status"] not in {"active", "starting"}:
return
await sessions.wait(session_id)
async def controls():
while True:
message = await _message(websocket)
if set(message) == {"type", "data"} and message["type"] == "input":
await sessions.write(vm_id, session_id, terminal_data(message["data"]))
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
await sessions.resize(vm_id, session_id, message["rows"], message["cols"])
elif set(message) == {"type", "offset"} and message["type"] == "ack":
if acknowledgements.full():
raise ProtocolError("unexpected terminal output acknowledgement")
acknowledgements.put_nowait(message["offset"])
else:
raise ProtocolError("invalid terminal control message")
tasks = [asyncio.create_task(output()), asyncio.create_task(controls())]
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
except WebSocketDisconnect:
pass
except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError):
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
except UvmError as error:
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": str(error)})
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.close()
+172
View File
@@ -0,0 +1,172 @@
"""Private, durable session metadata and bounded PTY transcripts, separate from VM state."""
from __future__ import annotations
import fcntl
import os
import sqlite3
import stat
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
class SessionStore:
def __init__(self, path: Path, *, max_output: int = MAX_TRANSCRIPT_BYTES) -> None:
self.path = path
self.max_output = max_output
self._mutex = threading.RLock()
self._owner = None
try:
path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
if path.parent.is_symlink():
raise StateError("session history directory must not be a symlink")
path.parent.chmod(0o700)
self._owner = self._private_file(path.with_suffix(".lock"))
try:
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise ConflictError("Another Nest server is already managing sessions for this VM registry.") from error
descriptor = self._private_file(path)
os.close(descriptor)
with self._db() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, vm_id TEXT NOT NULL, name TEXT NOT NULL,
username TEXT NOT NULL, status TEXT NOT NULL,
created_at REAL NOT NULL, updated_at REAL NOT NULL, ended_at REAL,
rows INTEGER NOT NULL, cols INTEGER NOT NULL, exit_code INTEGER, error TEXT,
output_start INTEGER NOT NULL DEFAULT 0, output_end INTEGER NOT NULL DEFAULT 0,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS sessions_vm ON sessions(vm_id, created_at);
CREATE TABLE IF NOT EXISTS output (
session_id TEXT NOT NULL REFERENCES sessions(id), offset INTEGER NOT NULL,
data BLOB NOT NULL, PRIMARY KEY (session_id, offset)
);
""")
now = time.time()
db.execute("""UPDATE sessions SET status='interrupted', ended_at=?, updated_at=?,
error='Nest restarted; this shell is no longer connected.'
WHERE status IN ('starting', 'active')""", (now, now))
except BaseException:
self.close()
raise
@staticmethod
def _private_file(path: Path) -> int:
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
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
@contextmanager
def _db(self):
with self._mutex:
db = None
try:
db = sqlite3.connect(self.path, timeout=5)
db.row_factory = sqlite3.Row
with db:
yield db
except sqlite3.Error as error:
raise StateError("Could not read or save terminal session history.") from error
finally:
if db is not None:
db.close()
@staticmethod
def _record(row) -> dict:
if row is None:
raise NotFoundError("Session not found for this VM.")
result = dict(row)
result["truncated"] = bool(result["truncated"])
return result
def create(self, vm_id: str, rows: int, cols: int) -> dict:
session_id = "session-" + uuid.uuid4().hex
now = time.time()
with self._db() as db:
number = db.execute("SELECT COUNT(*) FROM sessions WHERE vm_id=?", (vm_id,)).fetchone()[0] + 1
db.execute("""INSERT INTO sessions
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
(session_id, vm_id, f"Session {number}", now, now, rows, cols))
return self.get(vm_id, session_id)
def list(self, vm_id: str) -> list[dict]:
with self._db() as db:
return [self._record(row) for row in db.execute(
"SELECT * FROM sessions WHERE vm_id=? ORDER BY created_at DESC, id DESC", (vm_id,),
)]
def get(self, vm_id: str, session_id: str) -> dict:
with self._db() as db:
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
def activate(self, session_id: str, username: str) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
(username, time.time(), session_id))
def resize(self, session_id: str, rows: int, cols: int) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET rows=?, cols=? WHERE id=?", (rows, cols, session_id))
def finish(self, session_id: str, status: str, exit_code: int | None = None, error: str | None = None) -> None:
now = time.time()
with self._db() as db:
db.execute("""UPDATE sessions SET status=?, ended_at=?, updated_at=?, exit_code=?, error=?
WHERE id=? AND status IN ('starting', 'active')""", (status, now, now, exit_code, error, session_id))
def append(self, session_id: str, data: bytes) -> None:
if not data:
return
with self._db() as db:
row = db.execute("SELECT output_end FROM sessions WHERE id=?", (session_id,)).fetchone()
if row is None:
raise NotFoundError("Session not found.")
offset = row[0]
end = offset + len(data)
start = max(0, end - self.max_output)
db.execute("INSERT INTO output VALUES (?, ?, ?)", (session_id, offset, data))
db.execute("DELETE FROM output WHERE session_id=? AND offset + length(data)<=?", (session_id, start))
first = db.execute("SELECT offset, data FROM output WHERE session_id=? AND offset<?", (session_id, start)).fetchone()
if first is not None:
db.execute("UPDATE output SET offset=?, data=? WHERE session_id=? AND offset=?",
(start, first["data"][start - first["offset"]:], session_id, first["offset"]))
db.execute("UPDATE sessions SET output_start=?, output_end=?, truncated=?, updated_at=? WHERE id=?",
(start, end, int(start > 0), time.time(), session_id))
def read(self, vm_id: str, session_id: str, offset: int, limit: int = 65536) -> tuple[dict, int, bytes]:
with self._db() as db:
session = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
if type(offset) is not int or not 0 <= offset <= session["output_end"]:
raise ValidationError("invalid terminal output offset")
start = max(offset, session["output_start"])
data = bytearray()
for row in db.execute("""SELECT offset, data FROM output
WHERE session_id=? AND offset + length(data)>? ORDER BY offset""", (session_id, start)):
part = row["data"][max(0, start - row["offset"]):]
data.extend(part[:limit - len(data)])
if len(data) >= limit:
break
return session, start, bytes(data)
def close(self) -> None:
if self._owner is not None:
os.close(self._owner)
self._owner = None
+188
View File
@@ -0,0 +1,188 @@
"""Nest-owned live terminal sessions; browser attachments do not own guest shells."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from uvm.app import Application
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
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
terminal: GuestTerminal
changed: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task | None = None
class SessionManager:
def __init__(self, application: Application) -> None:
self.application = application
self._store: SessionStore | None = None
self._live: dict[str, _LiveSession] = {}
self._pending: set[asyncio.Task] = set()
self._create_lock = asyncio.Lock()
self._closing = False
def _history(self) -> SessionStore:
if self._closing:
raise ConflictError("Nest is shutting down.")
if self._store is None:
try:
self._store = SessionStore(Path(self.application.settings.base) / "nest" / "sessions.sqlite3")
except OSError as error:
raise StateError("Could not open private terminal history; run Nest with access to the VM registry.") from error
return self._store
async def _vm(self, vm_id: str) -> None:
# Exact IDs prevent a session from following a reused VM name or IP.
await asyncio.to_thread(self.application.state_store.get, vm_id)
async def list(self, vm_id: str) -> list[dict]:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().list, vm_id)
async def get(self, vm_id: str, session_id: str) -> dict:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().get, vm_id, session_id)
async def create(self, vm_id: str, rows: int, cols: int) -> dict:
validate_size(rows, cols)
await self._vm(vm_id)
history = self._history()
# A dropped HTTP request must not abandon a just-created guest connection.
task = asyncio.create_task(self._create(history, vm_id, rows, cols))
self._pending.add(task)
def completed(future):
self._pending.discard(future)
if not future.cancelled():
future.exception()
task.add_done_callback(completed)
return await asyncio.shield(task)
async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict:
async with self._create_lock:
if self._closing:
raise ConflictError("Nest is shutting down.")
if len(self._live) >= 32:
raise ConflictError("There are already 32 active terminal sessions. End a session before launching another.")
record = await asyncio.to_thread(history.create, vm_id, rows, cols)
terminal = None
try:
terminal, username = await asyncio.to_thread(self.application.lifecycle.open_terminal, vm_id, rows=rows, cols=cols)
await asyncio.to_thread(history.activate, record["id"], username)
live = _LiveSession(vm_id, terminal)
self._live[record["id"]] = live
live.task = asyncio.create_task(self._read(history, record["id"], live))
except BaseException as error:
if terminal is not None:
terminal.close()
message = str(error) if isinstance(error, UvmError) else "Could not launch the guest terminal."
await asyncio.to_thread(history.finish, record["id"], "failed", None, message)
raise
return await asyncio.to_thread(history.get, vm_id, record["id"])
async def _read(self, history: SessionStore, session_id: str, live: _LiveSession) -> None:
status, exit_code, error = "interrupted", None, None
try:
while True:
message = await live.terminal.read()
if message["type"] == "exit":
status, exit_code = "closed", message["exit_code"]
break
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."
except (UvmError, OSError) as exception:
error = str(exception) if isinstance(exception, UvmError) else "The guest terminal connection was interrupted."
finally:
live.terminal.close()
try:
await _persist(history.finish, session_id, status, exit_code, error)
finally:
live.changed.set()
self._live.pop(session_id, None)
async def read(self, vm_id: str, session_id: str, offset: int) -> tuple[dict, int, bytes]:
return await asyncio.to_thread(self._history().read, vm_id, session_id, offset)
async def wait(self, session_id: str) -> None:
live = self._live.get(session_id)
if live is None:
await asyncio.sleep(0.1)
return
try:
await asyncio.wait_for(live.changed.wait(), timeout=1)
except TimeoutError:
pass
live.changed.clear()
async def _active(self, vm_id: str, session_id: str) -> _LiveSession:
live = self._live.get(session_id)
if live is None:
await self.get(vm_id, session_id)
raise ConflictError("This session has ended. Launch a new session to run commands.")
if live.vm_id != vm_id:
raise NotFoundError("Session not found for this VM.")
return live
async def write(self, vm_id: str, session_id: str, data: bytes) -> None:
live = await self._active(vm_id, session_id)
try:
await live.terminal.write(data)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def resize(self, vm_id: str, session_id: str, rows: int, cols: int) -> None:
validate_size(rows, cols)
live = await self._active(vm_id, session_id)
try:
await live.terminal.resize(rows, cols)
await asyncio.to_thread(self._history().resize, session_id, rows, cols)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def end(self, vm_id: str, session_id: str) -> dict:
record = await self.get(vm_id, session_id)
live = self._live.get(session_id)
if live is not None:
try:
await live.terminal.end()
await asyncio.wait_for(asyncio.shield(live.task), timeout=8)
except (OSError, UvmError):
live.task.cancel()
await asyncio.gather(live.task, return_exceptions=True)
record = await self.get(vm_id, session_id)
return record
async def shutdown(self) -> None:
self._closing = True
await asyncio.gather(*self._pending, return_exceptions=True)
tasks = [live.task for live in self._live.values()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if self._store is not None:
self._store.close()
+2 -1
View File
@@ -11,6 +11,7 @@ dependencies = [
"fastapi>=0.115,<1",
"pydantic>=2,<3",
"uvicorn>=0.30,<1",
"websockets>=13,<17",
]
[project.optional-dependencies]
@@ -25,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"]
+82
View File
@@ -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()
+101
View File
@@ -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; });
+1
View File
@@ -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)
+60 -6
View File
@@ -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,13 +138,63 @@ 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)
self.replace_guest_file("/etc/uvm-image", "uvm-ubuntu-jammy-v4\n")
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()
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()
@@ -148,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])
+60 -1
View File
@@ -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)
+3 -1
View File
@@ -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()
+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 '\\nREAL-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"sh -c 'printf \"\\nJOB-STARTED\\n\"; exec sleep 20'\r")
await self.until(b"\r\nJOB-STARTED\r\n")
await self.terminal.write(b"\x03")
await self.until(self.prompt)
await self.terminal.write(b"printf '\\nINTERRUPTED\\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 '\\nREADY\\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, "Restart this VM to apply automatic guest-agent setup"):
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()
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import asyncio
import base64
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
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, GuestExecutionError, 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):
event = await self.events.get()
if isinstance(event, Exception):
raise event
return event
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)
async def test_guest_loss_ends_the_session_and_retains_output(self):
record = await self.manager.create("vm-test", 24, 80)
terminal = self.application.terminals[0]
await terminal.events.put(GuestExecutionError("VM connection lost", outcome_unknown=True))
async with asyncio.timeout(2):
while (await self.manager.get("vm-test", record["id"]))["status"] == "active":
await asyncio.sleep(0.01)
saved, _start, output = await self.manager.read("vm-test", record["id"], 0)
self.assertEqual(saved["status"], "interrupted")
self.assertEqual(output, b"guest$ ")
self.assertTrue(terminal.closed)
async def test_shutdown_finishes_inflight_output_before_releasing_history_ownership(self):
import threading
entered, release = threading.Event(), threading.Event()
history = self.manager._history()
append = history.append
def slow_append(*args):
entered.set()
release.wait(2)
append(*args)
with patch.object(history, "append", side_effect=slow_append):
record = await self.manager.create("vm-test", 24, 80)
self.assertTrue(await asyncio.to_thread(entered.wait, 2))
closing = asyncio.create_task(self.manager.shutdown())
try:
await asyncio.sleep(0.03)
self.assertFalse(closing.done())
with self.assertRaises(ConflictError):
SessionStore(history.path)
finally:
release.set()
await closing
reopened = SessionStore(history.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ")
@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)
def test_malformed_input_ends_only_the_browser_attachment(self):
record = self.create()
ws = self.connect(record)
self.output(ws)
ws.send_json({"type": "input", "data": "not base64!"})
while (message := ws.receive_json())["type"] == "session":
pass
self.assertEqual(message["type"], "error")
self.assertEqual(self.application.terminals[0].input, [])
self.assertFalse(self.application.terminals[0].closed)
self.assertEqual(self.client.get(self.base + "/" + record["id"], headers=self.headers).json()["status"], "active")
+135 -4
View File
@@ -6,6 +6,9 @@ The host also imports its wire-format helpers; importing it never starts a serve
from __future__ import annotations
import base64
import errno
import fcntl
import json
import math
import os
@@ -18,6 +21,7 @@ import stat
import struct
import subprocess
import sys
import termios
import threading
import time
import uuid
@@ -35,6 +39,8 @@ MAX_RESPONSE_BYTES = 6 * MAX_OUTPUT_BYTES + 4096
CONNECT_TIMEOUT_SECONDS = 5.0
CLEANUP_SECONDS = 5.0
MAX_CONCURRENT_COMMANDS = 8
MAX_TERMINAL_BYTES = 8192
MAX_TERMINAL_FRAME = 16384
CONFIG_PATH = Path("/etc/uvm-agent.json")
@@ -48,6 +54,23 @@ class ExecutionFailure(Exception):
self.outcome_unknown = outcome_unknown
def validate_terminal_size(rows: object, cols: object) -> None:
if type(rows) is not int or type(cols) is not int or not (2 <= rows <= 200 and 2 <= cols <= 400):
raise ProtocolError("terminal dimensions must be integers: 2–200 rows and 2–400 columns")
def terminal_data(value: object) -> bytes:
if not isinstance(value, str) or len(value) > 4 * ((MAX_TERMINAL_BYTES + 2) // 3):
raise ProtocolError("terminal data exceeds its size limit")
try:
data = base64.b64decode(value, validate=True)
except (ValueError, UnicodeError) as error:
raise ProtocolError("invalid terminal data") from error
if len(data) > MAX_TERMINAL_BYTES:
raise ProtocolError("terminal data exceeds its size limit")
return data
def validate_command(command: object, sudo: object, timeout_seconds: object) -> None:
if not isinstance(command, str) or not command.strip() or "\0" in command:
raise ProtocolError("command must be non-empty text without NUL characters")
@@ -167,7 +190,9 @@ def _cleanup_job(process: subprocess.Popen | None, scope: JobScope) -> None:
failed = True
finally:
for name in ("stdout", "stderr"):
getattr(process, name).close()
stream = getattr(process, name)
if stream is not None:
stream.close()
try:
scope.close()
except (OSError, ExecutionFailure):
@@ -239,9 +264,12 @@ def run_command(command: str, username: str, timeout_seconds: float, *, scope_fa
}
def child_main(scope: Path, username: str, command: str) -> None:
def child_main(scope: Path, username: str, command: str | None) -> None:
"""Enter the job cgroup and account in a fresh interpreter, then replace it."""
(scope / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
if command is None:
# Popen's start_new_session already performed setsid in this fresh child.
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
account = pwd.getpwnam(username)
if os.geteuid() == 0:
os.initgroups(account.pw_name, account.pw_gid)
@@ -256,15 +284,111 @@ def child_main(scope: Path, username: str, command: str) -> None:
"SHELL": "/bin/sh", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
}
os.execve("/bin/sh", ["sh", "-c", command], environment)
if command is None:
shell = account.pw_shell or "/bin/sh"
environment.update(SHELL=shell, TERM="xterm-256color", COLORTERM="truecolor")
os.execve(shell, [shell, "-i"], environment)
else:
os.execve("/bin/sh", ["sh", "-c", command], environment)
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command) -> None:
def run_terminal(connection: socket.socket, username: str, rows: int, cols: int, *, scope_factory=JobScope) -> None:
"""Bridge one real guest PTY until shell exit, explicit close, or host loss."""
validate_terminal_size(rows, cols)
scope = scope_factory()
process = None
master = slave = None
try:
master, slave = os.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--terminal-child", str(scope.path), username],
stdin=slave, stdout=slave, stderr=slave, start_new_session=True,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
os.close(slave)
slave = None
os.set_blocking(master, False)
send_frame(connection, {"type": "ready"}, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
pending = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(connection, selectors.EVENT_READ, "host")
selector.register(master, selectors.EVENT_READ, "pty")
finished = False
while not finished:
for key, events in selector.select(0.1):
if key.data == "host":
message = receive_frame(connection, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if message == {"type": "close"}:
finished = True
break
if set(message) == {"type", "data"} and message["type"] == "input":
pending.extend(terminal_data(message["data"]))
if len(pending) > 65536:
raise ProtocolError("terminal input backlog exceeded")
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
validate_terminal_size(message["rows"], message["cols"])
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", message["rows"], message["cols"], 0, 0))
else:
raise ProtocolError("invalid terminal control message")
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
else:
if events & selectors.EVENT_READ:
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except OSError as error:
if error.errno != errno.EIO:
raise
data = b""
if not data:
finished = True
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if events & selectors.EVENT_WRITE and pending:
try:
del pending[:os.write(master, pending)]
except BlockingIOError:
pass
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
if _exited(process):
# Drain the leader's final output before terminating any background jobs.
for _ in range(16):
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except (BlockingIOError, OSError):
break
if not data:
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
break
finally:
try:
_cleanup_job(process, scope)
finally:
for descriptor in (master, slave):
if descriptor is not None:
os.close(descriptor)
send_frame(connection, {"type": "exit", "exit_code": process.returncode}, MAX_TERMINAL_FRAME,
time.monotonic() + CONNECT_TIMEOUT_SECONDS)
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command, terminal_runner=run_terminal) -> None:
with connection:
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(connection, {"version": PROTOCOL_VERSION, **config}, 4096, deadline)
request = receive_frame(connection, MAX_REQUEST_BYTES, deadline)
if set(request) == {"version", "terminal"}:
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
size = request["terminal"]
if not isinstance(size, dict) or set(size) != {"rows", "cols"}:
raise ProtocolError("invalid terminal request")
validate_terminal_size(size["rows"], size["cols"])
terminal_runner(connection, config["username"], size["rows"], size["cols"])
return
if set(request) != {"version", "command", "sudo", "timeout_seconds"}:
raise ProtocolError("invalid execution request fields")
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
@@ -305,6 +429,13 @@ def load_config(path: Path = CONFIG_PATH) -> dict:
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--terminal-child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], None)
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest terminal: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) == 5 and sys.argv[1] == "--child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
+2 -2
View File
@@ -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:
+89
View File
@@ -0,0 +1,89 @@
"""Interactive, process-pinned guest terminal transport over the existing vsock agent."""
from __future__ import annotations
import asyncio
import base64
import json
import struct
import time
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from .guest_agent import (
CONNECT_TIMEOUT_SECONDS, MAX_TERMINAL_FRAME, PROTOCOL_VERSION, ProtocolError,
receive_frame, send_frame, terminal_data, validate_terminal_size,
)
from .guest_exec import GuestSession
def validate_size(rows: object, cols: object) -> None:
try:
validate_terminal_size(rows, cols)
except ProtocolError as error:
raise ValidationError(str(error)) from error
class GuestTerminal(GuestSession):
def start(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(self._connection, {"version": PROTOCOL_VERSION, "terminal": {"rows": rows, "cols": cols}},
MAX_TERMINAL_FRAME, deadline)
response = receive_frame(self._connection, MAX_TERMINAL_FRAME, deadline)
if response != {"type": "ready"}:
raise ProtocolError("terminal support is unavailable")
self._connection.setblocking(False)
self._write_lock = asyncio.Lock()
except (OSError, EOFError, ProtocolError) as error:
self.close()
raise GuestAgentUnavailableError(
"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:
data = bytearray()
while len(data) < size:
chunk = await asyncio.get_running_loop().sock_recv(self._connection, size - len(data))
if not chunk:
raise EOFError("guest terminal connection closed")
data.extend(chunk)
return bytes(data)
async def read(self) -> dict:
try:
# An idle shell has no output deadline; partial messages are bounded.
first = await self._receive_exact(1)
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
size, = struct.unpack("!I", first + await self._receive_exact(3))
if not 0 < size <= MAX_TERMINAL_FRAME:
raise ProtocolError("invalid terminal frame length")
message = json.loads(await self._receive_exact(size))
if isinstance(message, dict):
if set(message) == {"type", "data"} and message["type"] == "output":
return {"type": "output", "data": terminal_data(message["data"])}
if (set(message) == {"type", "exit_code"} and message["type"] == "exit"
and type(message["exit_code"]) is int and -64 <= message["exit_code"] <= 255):
return message
raise ProtocolError("invalid guest terminal response")
except (OSError, EOFError, ValueError, UnicodeError, RecursionError) as error:
raise GuestExecutionError("The guest terminal connection ended unexpectedly.", outcome_unknown=True) from error
async def _send(self, message: dict) -> None:
payload = json.dumps(message, separators=(",", ":")).encode("ascii")
if len(payload) > MAX_TERMINAL_FRAME:
raise ValidationError("terminal input exceeds its size limit")
async with self._write_lock:
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
await asyncio.get_running_loop().sock_sendall(self._connection, struct.pack("!I", len(payload)) + payload)
async def write(self, data: bytes) -> None:
await self._send({"type": "input", "data": base64.b64encode(data).decode("ascii")})
async def resize(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
await self._send({"type": "resize", "rows": rows, "cols": cols})
async def end(self) -> None:
await self._send({"type": "close"})
+50 -11
View File
@@ -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(
@@ -299,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:
@@ -314,11 +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") != "uvm-ubuntu-jammy-v4\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)
@@ -345,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 = {}
@@ -372,7 +408,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)
+9 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import hashlib
import json
import os
import shlex
import shutil
@@ -21,7 +23,7 @@ from .system import CommandRunner, check_kvm, ensure_data_directories, require_r
_UBUNTU_RELEASE = "jammy"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v4\n"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v5\n"
_GUEST_PACKAGES = (
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", "python3",
@@ -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)
+22
View File
@@ -19,6 +19,7 @@ from .firecracker.config import build_config, write_config
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
from .guest_agent import DEFAULT_TIMEOUT_SECONDS
from .guest_exec import ExecResult, GuestSession, validate_exec
from .guest_terminal import GuestTerminal, validate_size
from .images import ImageStore
from .integrity import load_manifest, verify_file
from .known_hosts import KnownHostsManager
@@ -61,6 +62,7 @@ class LifecycleService:
known_hosts: KnownHostsManager,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
guest_connector: Callable[[Path, str, str, int], GuestSession] = GuestSession.connect,
terminal_connector: Callable[[Path, str, str, int], GuestTerminal] = GuestTerminal.connect,
clock: Callable[[], float] = time.time,
) -> None:
self._settings = settings
@@ -71,6 +73,7 @@ class LifecycleService:
self._known_hosts = known_hosts
self._client_factory = client_factory
self._guest_connector = guest_connector
self._terminal_connector = terminal_connector
self._clock = clock
def create(self, spec: VmSpec) -> VmRecord:
@@ -195,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
@@ -520,6 +524,24 @@ class LifecycleService:
finally:
session.close()
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
require_root()
validate_size(rows, cols)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("terminal sessions require a running VM with no pending lifecycle operation")
terminal = self._terminal_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
terminal.start(rows, cols)
return terminal, vm.username
except BaseException:
terminal.close()
raise
def install_guest_agent(self, vm_id: str) -> VmRecord:
"""Explicitly install/upgrade the agent on a cleanly stopped private disk."""
require_root()