Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e9cefbc3 | ||
|
|
6573f483e6 | ||
|
|
d870c98bfa | ||
|
|
caaea12163 | ||
|
|
d202f9e2f8 | ||
|
|
6aa70133c5 | ||
|
|
73d85fefe4 | ||
|
|
9541e29b76 | ||
|
|
3b94ac1b48 |
+103
-7
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
page: 'overview',
|
||||
detailId: null,
|
||||
detail: null,
|
||||
detailTab: 'overview',
|
||||
terminalId: null,
|
||||
portEditing: false,
|
||||
resizeEditing: false,
|
||||
selected: new Set(),
|
||||
@@ -45,6 +47,13 @@
|
||||
menuTrigger: null,
|
||||
};
|
||||
|
||||
const sessions = new window.NestSessions({
|
||||
api, getToken: () => state.token,
|
||||
isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id,
|
||||
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
||||
esc, formatDate, toast, confirmAction,
|
||||
});
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -598,9 +607,22 @@
|
||||
const busy = state.busy.has(vm.id);
|
||||
const running = runtimeStatus(vm) === 'running';
|
||||
const actionButton = (action, caption, symbol, primary = false) => `<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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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; });
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user