From 645bf6321ae75f4a49d6ca57b85cf2c6e9bab4be Mon Sep 17 00:00:00 2001 From: kstyagi Date: Fri, 25 Sep 2026 22:59:32 +0530 Subject: [PATCH] feat: add session rename and delete functionality with validation - Introduced `SessionRename` model for renaming sessions with validation on name length and content. - Implemented `rename_session` endpoint in `session_routes.py` to handle session renaming. - Added `delete_session` endpoint to remove sessions, ensuring proper cleanup of associated data. - Enhanced `SessionStore` with `rename` and `delete` methods, including validation and transaction management. - Updated `SessionManager` to manage session renaming and deletion, ensuring live terminal state is preserved. - Added tests for renaming and deleting sessions, ensuring proper functionality and validation. - Introduced a new `tool_catalog.py` for managing guest tool selections and installation recipes. --- DEVELOPER.md | 22 ++- README.md | 9 ++ USAGE.md | 10 ++ nest/dashboard/static/css/sessions.css | 12 +- nest/dashboard/static/index.html | 17 ++- nest/dashboard/static/js/dashboard.js | 2 +- nest/dashboard/static/js/sessions.js | 176 +++++++++++++++++++++-- nest/session_routes.py | 33 ++++- nest/session_store.py | 33 ++++- nest/sessions.py | 58 +++++++- tests/browser_sessions.cjs | 88 +++++++++++- tests/test_sessions.py | 184 ++++++++++++++++++++++++- uvm/tool_catalog.py | 162 ++++++++++++++++++++++ 13 files changed, 768 insertions(+), 38 deletions(-) create mode 100644 uvm/tool_catalog.py diff --git a/DEVELOPER.md b/DEVELOPER.md index e15e0f4..e589aa5 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -909,6 +909,8 @@ Nest. Both server entry points expose the UVM management routes below. | `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) | +| `PATCH` | `/vms/{vm_id}/sessions/{session_id}` | `nest/session_routes.py` | Rename via a strict `name` body; identity retained (Nest only) | +| `DELETE` | `/vms/{vm_id}/sessions/{session_id}` | `nest/session_routes.py` | Close channel, join output reader, delete history; 204 (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) | @@ -1051,8 +1053,12 @@ 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 +attachments. Accepted creation, rename, close, and delete operations are tracked +and shielded from HTTP disconnects. Creation and termination share a session +lock, so deleting a starting session waits for its channel to be tracked and +then closes it. Deletion joins the output reader and its pending writes before +atomically removing the metadata and transcript rows. 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. @@ -1062,7 +1068,11 @@ 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 +session metadata is retained until explicit deletion. Session names are trimmed, +1–80 printable characters, and are display labels rather than identifiers. +Renaming updates only the name and modification time; generated default names +avoid labels still present after renames or deletions. 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. @@ -1074,6 +1084,9 @@ 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. +Renames notify attached viewers through updated session metadata. A deleted +session sends a `deleted` event to authenticated attachments; polling also clears +stale selections for completed sessions whose replay connection already closed. The dashboard's Sessions tab and nested hash routes belong to the selected VM. `sessions.js` owns attachment teardown, replay, reconnects, and the terminal @@ -1182,7 +1195,8 @@ NODE_PATH=/tmp/nest-browser/node_modules \ ``` 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. +closed-session replay, rename persistence, active/ended deletion, cancellation, +multi-tab updates, 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. diff --git a/README.md b/README.md index 95329b9..e7a657b 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,8 @@ The server exposes these routes: | `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) | +| `PATCH` | `/vms/{vm_id}/sessions/{session_id}` | Rename a session; body: `{"name":"Deployment"}` (Nest) | +| `DELETE` | `/vms/{vm_id}/sessions/{session_id}` | Close the session and delete its saved history; returns 204 (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) | @@ -379,6 +381,13 @@ 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. +Use **Rename** in a history row or the terminal toolbar to give any session a +custom name. Names are trimmed and accept 1–80 printable characters; they persist +across reloads and server restarts. The session keeps its identity and shell. +**Delete** removes a session and its saved terminal output after confirmation, +closing its shell first if it is active. Both actions are scoped to the session's +microVM. + 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 diff --git a/USAGE.md b/USAGE.md index 4054b01..5983edb 100644 --- a/USAGE.md +++ b/USAGE.md @@ -823,6 +823,11 @@ passwords in reports. 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. +7. Use **Rename** in the history row or terminal toolbar to save a custom label. + Names accept 1–80 printable characters, with surrounding whitespace trimmed. +8. Use **Delete** to permanently remove a session and its saved output. Confirming + deletion closes an active shell before removing its history. **End session** + retains history for later viewing. Sessions continue while you navigate elsewhere, reload, or sign out of the dashboard. VM shutdown/restart or Nest shutdown ends the shell. Session metadata, @@ -830,6 +835,9 @@ 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. +Renaming works for active and ended sessions and keeps their IDs and URLs stable. +Deleting the open session clears the terminal selection; other sessions continue +to appear under their own history entries. 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 @@ -916,6 +924,8 @@ curl --cacert /path/to/ca.pem \ | `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) | +| `PATCH` | `/vms/{vm_id}/sessions/{session_id}` | Rename with `{"name":"Deployment"}`; returns updated metadata (Nest) | +| `DELETE` | `/vms/{vm_id}/sessions/{session_id}` | End the shell and remove its record/output; HTTP 204 (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) | diff --git a/nest/dashboard/static/css/sessions.css b/nest/dashboard/static/css/sessions.css index ee49b3a..c4234c0 100644 --- a/nest/dashboard/static/css/sessions.css +++ b/nest/dashboard/static/css/sessions.css @@ -10,7 +10,10 @@ .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 { display: grid; gap: 9px; min-width: 0; padding: 12px 10px; border: 1px solid transparent; border-radius: 12px; color: var(--ink-soft); } +.session-link { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } +.session-item-actions { display: flex; gap: 15px; padding-top: 8px; border-top: 1px solid var(--line); } +.session-item-actions .quiet-link { min-height: 24px; font-size: 9px; } .session-item:hover { background: #fff8; } .session-item.active { background: var(--white); border-color: #d9d0e4; } .session-item strong { overflow-wrap: anywhere; font-size: 11px; } @@ -20,11 +23,14 @@ .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-toolbar > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; } +.terminal-toolbar strong { font-size: 11px; overflow-wrap: anywhere; } .terminal-status { color: #b8afc6; font-size: 9px; } .terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; } .terminal-toolbar .button:hover { background: #ffffff19; } +#session-delete { color: #e5b4aa; } +.session-rename-dialog .field { margin-top: 22px; } +#session-rename-error { color: var(--red); } .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); } diff --git a/nest/dashboard/static/index.html b/nest/dashboard/static/index.html index f4ca18a..39c7730 100644 --- a/nest/dashboard/static/index.html +++ b/nest/dashboard/static/index.html @@ -11,11 +11,11 @@ - + - - + +

Saved for this microVM.

-
TerminalSelect a session
+
TerminalSelect a session

A little room to work.

Select a saved session, or launch a fresh terminal
inside this microVM.

Your sessions and terminal output are saved on the host.
@@ -246,6 +246,15 @@
A moment before we continue

+ +
+ A name for this workspace +

Rename session

+ + +
+
+
diff --git a/nest/dashboard/static/js/dashboard.js b/nest/dashboard/static/js/dashboard.js index 84b21d4..e818fa4 100644 --- a/nest/dashboard/static/js/dashboard.js +++ b/nest/dashboard/static/js/dashboard.js @@ -1428,7 +1428,7 @@ 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; + if (document.querySelector('dialog[open]')) return; event.preventDefault(); focusSearch(); } diff --git a/nest/dashboard/static/js/sessions.js b/nest/dashboard/static/js/sessions.js index c069c9c..86ca8f7 100644 --- a/nest/dashboard/static/js/sessions.js +++ b/nest/dashboard/static/js/sessions.js @@ -19,12 +19,31 @@ this.generation = 0; this.launching = new Set(); this.upgrading = new Set(); + this.mutating = new Map(); 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()); + $('#session-rename').addEventListener('click', () => this.rename(this.current)); + $('#session-delete').addEventListener('click', () => this.deleteSession(this.current)); + $('#session-list').addEventListener('click', (event) => { + const button = event.target.closest('[data-session-action]'); + if (!button || button.disabled) return; + const record = this.records.find((item) => item.id === button.dataset.sessionId); + if (button.dataset.sessionAction === 'rename') this.rename(record); + if (button.dataset.sessionAction === 'delete') this.deleteSession(record); + }); + $('#session-rename-form').addEventListener('submit', (event) => { event.preventDefault(); this.saveName(); }); + $('#session-rename-cancel').addEventListener('click', () => $('#session-rename-dialog').close()); + $('#session-rename-dialog').addEventListener('close', () => { + if (!$('#session-rename-dialog').open) this.renameTarget = null; + }); + $('#session-rename-dialog').addEventListener('cancel', (event) => { + if (this.isBusy(this.renameTarget?.id)) event.preventDefault(); + }); + $('#session-name-input').addEventListener('input', () => { $('#session-rename-error').textContent = ''; }); } sync(vm, selectedId, visible) { @@ -73,10 +92,16 @@ renderList() { const markup = this.records.length ? this.records.map((record) => ` - - ${this.esc(record.name)} - ${this.esc(this.formatDate(record.created_at))}
${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}
-
`).join('') : `

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

`; + `).join('') : `

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

`; if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup; } @@ -93,6 +118,12 @@ this.records = records; if (this.current) { const latest = records.find((record) => record.id === this.current.id); + if (!latest) { + const localDelete = this.mutating.get(this.current.id) === 'delete'; + this.removeRecord(vmId, this.current.id); + if (!localDelete) this.toast('This session was deleted.'); + return; + } if (latest && latest.updated_at >= this.current.updated_at) this.current = latest; else if (latest) this.records[this.records.indexOf(latest)] = this.current; } @@ -204,7 +235,8 @@ } canType() { - return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp; + return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp + && this.ending !== this.current.id && this.mutating.get(this.current.id) !== 'delete'; } sendSize(rows, cols) { @@ -239,7 +271,7 @@ const message = JSON.parse(event.data); if (message.type === 'session') { const wasReady = this.caughtUp; - this.current = message.session; + if (message.session.updated_at >= this.current.updated_at) this.current = message.session; if (message.offset > this.offset) { this.term.reset(); this.offset = message.offset; @@ -248,7 +280,7 @@ 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; + if (index >= 0) this.records[index] = this.current; this.renderList(); this.renderStatus(); if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); } @@ -271,6 +303,12 @@ resolve(); }); }); + } else if (message.type === 'deleted') { + const vmId = this.vm.id; + const sessionId = this.current.id; + const localDelete = this.mutating.get(sessionId) === 'delete'; + this.removeRecord(vmId, sessionId); + if (!localDelete) this.toast('This session was deleted.'); } else if (message.type === 'error') { rejected = true; this.showError(message.message); @@ -298,7 +336,10 @@ 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-end').disabled = !active || this.isBusy(record?.id); + $('#session-rename').disabled = !record || this.isBusy(record.id); + $('#session-delete').disabled = !record || this.isBusy(record.id); + $('#session-delete').textContent = record && this.mutating.get(record.id) === 'delete' ? 'Deleting…' : 'Delete'; $('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN; if (this.term) this.term.options.disableStdin = !this.canType(); $('#session-meta').textContent = record @@ -308,10 +349,11 @@ async end() { const record = this.current; - if (!record || this.ending) return; + if (!record || this.isBusy(record.id)) 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.renderList(); this.renderStatus(); try { const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' }); @@ -321,10 +363,124 @@ if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message); } finally { this.ending = null; + this.renderList(); this.renderStatus(); } } + isBusy(id) { + return Boolean(id && (this.mutating.has(id) || this.ending === id)); + } + + invalidateRefresh() { + this.sequence += 1; + this.loading = false; + this.lastRefresh = 0; + $('#sessions-refresh').disabled = false; + } + + rename(record) { + if (!record || this.isBusy(record.id) || document.querySelector('dialog[open]')) return; + this.renameTarget = record; + $('#session-name-input').value = record.name; + $('#session-name-input').disabled = false; + $('#session-rename-save').disabled = false; + $('#session-rename-save').textContent = 'Save name'; + $('#session-rename-cancel').disabled = false; + $('#session-rename-error').textContent = ''; + $('#session-rename-dialog').showModal(); + $('#session-name-input').focus(); + $('#session-name-input').select(); + } + + async saveName() { + const target = this.renameTarget; + if (!target || this.isBusy(target.id)) return; + const name = $('#session-name-input').value.trim(); + if (!name) { + $('#session-rename-error').textContent = 'Enter a session name.'; + $('#session-name-input').focus(); + return; + } + const token = this.getToken(); + this.mutating.set(target.id, 'rename'); + $('#session-name-input').disabled = true; + $('#session-rename-save').disabled = true; + $('#session-rename-save').textContent = 'Saving…'; + $('#session-rename-cancel').disabled = true; + this.renderList(); + this.renderStatus(); + try { + const renamed = await this.api(path(target.vm_id, `/${encodeURIComponent(target.id)}`), { method: 'PATCH', body: { name } }); + if (this.renameTarget === target) $('#session-rename-dialog').close(); + if (token === this.getToken() && this.vm?.id === target.vm_id) { + this.invalidateRefresh(); + const index = this.records.findIndex((item) => item.id === target.id); + if (index >= 0 && renamed.updated_at >= this.records[index].updated_at) this.records[index] = renamed; + if (this.current?.id === target.id && renamed.updated_at >= this.current.updated_at) this.current = renamed; + this.toast('Session renamed.'); + } + } catch (error) { + if (error.name !== 'AbortError' && this.renameTarget === target) $('#session-rename-error').textContent = error.message; + } finally { + this.mutating.delete(target.id); + if (this.renameTarget === target) { + $('#session-name-input').disabled = false; + $('#session-rename-save').disabled = false; + $('#session-rename-save').textContent = 'Save name'; + $('#session-rename-cancel').disabled = false; + } + this.renderList(); + this.renderStatus(); + } + } + + async deleteSession(record) { + if (!record || this.isBusy(record.id) || document.querySelector('dialog[open]')) return; + const live = ['active', 'starting'].includes(record.status); + const confirmed = await this.confirmAction(`Delete ${record.name}?`, + live ? 'End this shell and permanently delete the session and its saved terminal output.' + : 'Permanently delete this session and its saved terminal output.', 'Delete session', true); + if (!confirmed) return; + const token = this.getToken(); + this.mutating.set(record.id, 'delete'); + this.showError(''); + this.renderList(); + this.renderStatus(); + try { + await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}`), { method: 'DELETE' }); + if (token === this.getToken() && this.vm?.id === record.vm_id) { + this.removeRecord(record.vm_id, record.id); + this.toast('Session deleted.'); + } + } catch (error) { + if (error.name !== 'AbortError' && token === this.getToken() && this.vm?.id === record.vm_id) this.showError(error.message); + } finally { + this.mutating.delete(record.id); + this.renderList(); + this.renderStatus(); + } + } + + removeRecord(vmId, sessionId) { + if (this.vm?.id !== vmId) return; + this.invalidateRefresh(); + this.records = this.records.filter((record) => record.id !== sessionId); + $('#vm-session-count').textContent = this.records.length; + if (this.renameTarget?.id === sessionId) { + $('#session-rename-dialog').close(); + this.renameTarget = null; + } + if (this.selectedId === sessionId || this.current?.id === sessionId) { + this.detachTerminal(); + this.selectedId = null; + if (this.visible && this.isSelectedVm(vmId)) location.hash = href(vmId); + } + this.showError(''); + this.renderList(); + this.renderStatus(); + } + detachTerminal() { this.generation += 1; window.clearTimeout(this.reconnectTimer); @@ -348,6 +504,8 @@ } leave() { + if ($('#session-rename-dialog').open) $('#session-rename-dialog').close(); + this.renameTarget = null; this.sequence += 1; this.loading = false; this.lastRefresh = 0; diff --git a/nest/session_routes.py b/nest/session_routes.py index 7ba12ef..05c0d92 100644 --- a/nest/session_routes.py +++ b/nest/session_routes.py @@ -10,15 +10,16 @@ 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 fastapi import APIRouter, Depends, Request, Response, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from uvm.app import Application -from uvm.errors import UvmError +from uvm.errors import NotFoundError, UvmError from uvm.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data from uvm.routers.dependencies import get_authorized_application from .sessions import SessionManager +from .session_store import MAX_SESSION_NAME_LENGTH router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"]) @@ -48,6 +49,11 @@ class SessionResponse(BaseModel): truncated: bool +class SessionRename(BaseModel): + model_config = ConfigDict(extra="forbid") + name: StrictStr = Field(min_length=1, max_length=MAX_SESSION_NAME_LENGTH) + + def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager: return request.app.state.nest_sessions @@ -72,6 +78,17 @@ async def close_session(vm_id: str, session_id: str, sessions: SessionManager = return await sessions.end(vm_id, session_id) +@router.patch("/{session_id}", response_model=SessionResponse) +async def rename_session(vm_id: str, session_id: str, body: SessionRename, sessions: SessionManager = Depends(manager)): + return await sessions.rename(vm_id, session_id, body.name) + + +@router.delete("/{session_id}", status_code=204) +async def delete_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)) -> Response: + await sessions.delete(vm_id, session_id) + return Response(status_code=204) + + def _same_origin(websocket: WebSocket) -> bool: origin = websocket.headers.get("origin") if origin is None: @@ -101,6 +118,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str): return await websocket.accept() tasks = [] + attached = False try: # Browser WebSockets cannot set X-UVM-Token. Authenticate in the first # frame so tokens never appear in URLs, access logs, or session history. @@ -113,6 +131,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str): return sessions = websocket.app.state.nest_sessions await sessions.get(vm_id, session_id) + attached = True offset = auth.get("offset", 0) acknowledgements = asyncio.Queue(maxsize=1) @@ -133,7 +152,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str): if type(acknowledged) is not int or acknowledged != cursor: raise ProtocolError("invalid terminal output acknowledgement") continue - if record["status"] not in {"active", "starting"}: + if record["status"] not in {"active", "starting"} and not sessions.is_deleting(session_id): return await sessions.wait(session_id) @@ -157,6 +176,12 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str): task.result() except WebSocketDisconnect: pass + except NotFoundError as error: + with suppress(WebSocketDisconnect, RuntimeError): + await websocket.send_json( + {"type": "deleted", "session_id": session_id} if attached + else {"type": "error", "message": str(error)} + ) except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError): with suppress(WebSocketDisconnect, RuntimeError): await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."}) diff --git a/nest/session_store.py b/nest/session_store.py index 4167dfe..97abf74 100644 --- a/nest/session_store.py +++ b/nest/session_store.py @@ -16,6 +16,16 @@ from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024 +MAX_SESSION_NAME_LENGTH = 80 + + +def validate_session_name(value: object) -> str: + if not isinstance(value, str): + raise ValidationError("Session name must be text.") + name = value.strip() + if not 1 <= len(name) <= MAX_SESSION_NAME_LENGTH or not name.isprintable(): + raise ValidationError(f"Session name must contain 1–{MAX_SESSION_NAME_LENGTH} printable characters.") + return name class SessionStore: @@ -100,7 +110,10 @@ class SessionStore: 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 + names = [row[0] for row in db.execute("SELECT name FROM sessions WHERE vm_id=?", (vm_id,))] + number = len(names) + 1 + while f"Session {number}" in names: + number += 1 db.execute("""INSERT INTO sessions (id, vm_id, name, username, status, created_at, updated_at, rows, cols) VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""", @@ -117,6 +130,24 @@ class SessionStore: with self._db() as db: return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone()) + def rename(self, vm_id: str, session_id: str, name: str) -> dict: + name = validate_session_name(name) + with self._db() as db: + self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone()) + db.execute("UPDATE sessions SET name=?, updated_at=? WHERE vm_id=? AND id=?", + (name, time.time(), vm_id, session_id)) + return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone()) + + def delete(self, vm_id: str, session_id: str) -> None: + with self._db() as db: + record = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone()) + if record["status"] in {"starting", "active"}: + raise ConflictError("End the session before deleting its history.") + # Both tables are removed in one transaction, after the output reader + # has finished. Explicit cleanup also supports existing databases. + db.execute("DELETE FROM output WHERE session_id=?", (session_id,)) + db.execute("DELETE FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)) + 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=?", diff --git a/nest/sessions.py b/nest/sessions.py index 2619e7e..52595e4 100644 --- a/nest/sessions.py +++ b/nest/sessions.py @@ -10,7 +10,7 @@ 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 +from .session_store import SessionStore, validate_session_name async def _persist(function, *arguments): @@ -38,7 +38,8 @@ class SessionManager: self._store: SessionStore | None = None self._live: dict[str, _LiveSession] = {} self._pending: set[asyncio.Task] = set() - self._create_lock = asyncio.Lock() + self._deleting: set[str] = set() + self._session_lock = asyncio.Lock() self._closing = False def _history(self) -> SessionStore: @@ -68,7 +69,11 @@ class SessionManager: 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)) + return await self._run_pending(self._create(history, vm_id, rows, cols)) + + async def _run_pending(self, operation): + """Finish accepted mutations even if their HTTP request disconnects.""" + task = asyncio.create_task(operation) self._pending.add(task) def completed(future): @@ -80,7 +85,7 @@ class SessionManager: return await asyncio.shield(task) async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict: - async with self._create_lock: + async with self._session_lock: if self._closing: raise ConflictError("Nest is shutting down.") if len(self._live) >= 32: @@ -165,7 +170,15 @@ class SessionManager: 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) + await self.get(vm_id, session_id) + return await self._run_pending(self._end(self._history(), vm_id, session_id)) + + async def _end(self, history: SessionStore, vm_id: str, session_id: str) -> dict: + async with self._session_lock: + return await self._end_locked(history, vm_id, session_id) + + async def _end_locked(self, history: SessionStore, vm_id: str, session_id: str) -> dict: + record = await asyncio.to_thread(history.get, vm_id, session_id) live = self._live.get(session_id) if live is not None: try: @@ -174,9 +187,42 @@ class SessionManager: except (OSError, UvmError): live.task.cancel() await asyncio.gather(live.task, return_exceptions=True) - record = await self.get(vm_id, session_id) + elif record["status"] in {"starting", "active"}: + # The session lock excludes creation here. A missing channel can + # only be an interrupted session whose final metadata write failed. + await _persist(history.finish, session_id, "interrupted", None, "This shell is no longer connected.") + return await asyncio.to_thread(history.get, vm_id, session_id) + + async def rename(self, vm_id: str, session_id: str, name: str) -> dict: + name = validate_session_name(name) + await self.get(vm_id, session_id) + return await self._run_pending(self._rename(self._history(), vm_id, session_id, name)) + + async def _rename(self, history: SessionStore, vm_id: str, session_id: str, name: str) -> dict: + record = await _persist(history.rename, vm_id, session_id, name) + live = self._live.get(session_id) + if live is not None: + live.changed.set() return record + async def delete(self, vm_id: str, session_id: str) -> None: + await self.get(vm_id, session_id) + await self._run_pending(self._delete(self._history(), vm_id, session_id)) + + async def _delete(self, history: SessionStore, vm_id: str, session_id: str) -> None: + # Creation must finish before deletion can close its channel. Join the + # output reader (including pending disk writes) before removing history. + async with self._session_lock: + self._deleting.add(session_id) + try: + await self._end_locked(history, vm_id, session_id) + await _persist(history.delete, vm_id, session_id) + finally: + self._deleting.discard(session_id) + + def is_deleting(self, session_id: str) -> bool: + return session_id in self._deleting + async def shutdown(self) -> None: self._closing = True await asyncio.gather(*self._pending, return_exceptions=True) diff --git a/tests/browser_sessions.cjs b/tests/browser_sessions.cjs index d121fe3..22dde30 100644 --- a/tests/browser_sessions.cjs +++ b/tests/browser_sessions.cjs @@ -30,7 +30,8 @@ const { chromium } = require('playwright'); } 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 context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); + page = await context.newPage(); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected'); @@ -51,15 +52,49 @@ const { chromium } = require('playwright'); await output('result:echo alpha'); assert.equal(await page.locator('.session-item').count(), 1); + // Rename is cancellable, validated, persisted, and preserves the live widget. + const firstId = new URL(firstUrl).hash.split('/').at(-1); + const firstRow = () => page.locator(`.session-item[data-session-id="${firstId}"]`); + await page.locator('#session-rename').click(); + await page.locator('#session-name-input').fill('Discarded name'); + await page.locator('#session-rename-cancel').click(); + assert.equal(await firstRow().locator('strong').textContent(), 'Session 1'); + await page.evaluate(() => { window.originalTerminal = document.querySelector('#session-terminal .xterm'); }); + await page.locator('#session-rename').click(); + await page.locator('#session-name-input').fill(' '); + await page.locator('#session-rename-save').click(); + assert.ok(await page.locator('#session-rename-dialog').isVisible()); + assert.match(await page.locator('#session-rename-error').textContent(), /Enter a session name/); + await page.locator('#session-name-input').fill(' Deployment '); + await page.locator('#session-rename-save').click(); + await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' }); + await page.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Deployment')); + assert.equal(page.url(), firstUrl); + assert.ok(await page.evaluate(() => window.originalTerminal === document.querySelector('#session-terminal .xterm'))); + await page.reload(); + await connected(); + await output('result:echo alpha'); + assert.equal(await firstRow().locator('strong').textContent(), 'Deployment'); + await page.locator('#detail-actions [data-action="launch-session"]').click(); await page.waitForURL((url) => url.href !== firstUrl); await connected(); + const secondUrl = page.url(); 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(); + // History actions also work without selecting the affected session. + await firstRow().locator('[data-session-action="rename"]').click(); + await page.locator('#session-name-input').fill('Operations '); + await page.locator('#session-rename-save').click(); + await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' }); + assert.equal(page.url(), secondUrl); + assert.equal(await firstRow().locator('strong').textContent(), 'Operations '); + assert.equal(await firstRow().locator('alpha').count(), 0); + await output('result:echo beta'); + await firstRow().locator('.session-link').click(); await connected(); await output('result:echo alpha'); assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta')); @@ -80,14 +115,59 @@ const { chromium } = require('playwright'); await page.reload(); await output('result:echo alpha'); assert.ok(await page.locator('#session-end').isDisabled()); + await page.locator('#session-rename').click(); + await page.locator('#session-name-input').fill('Archived run'); + await page.locator('#session-rename-save').click(); + await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' }); + await page.reload(); + await output('result:echo alpha'); + assert.equal(await firstRow().locator('strong').textContent(), 'Archived run'); 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.'); + + // Cancel preserves history; confirmation removes an ended session and clears selection. + await page.locator('#session-delete').click(); + await page.locator('#confirm-dialog button[value="cancel"]').click(); + assert.equal(await page.locator('.session-item').count(), 2); + await firstRow().locator('[data-session-action="delete"]').click(); + await page.locator('#confirm-ok').click(); + await page.waitForURL((url) => url.hash === '#vm/vm-test/sessions'); + await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1'); + assert.ok(await page.locator('#session-terminal').isHidden()); + await page.reload(); + await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1'); + assert.equal(await firstRow().count(), 0); + + // Deleting an active session clears other attached browser views as well. + await page.goto(secondUrl); + await connected(); + await output('result:echo beta'); + const observer = await page.context().newPage(); + observer.on('pageerror', (error) => errors.push(error.message)); + await observer.goto(secondUrl); + await observer.locator('#token-input').fill('browser-test-token'); + await observer.locator('#auth-btn').click(); + await observer.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected'); + await page.locator('#session-rename').click(); + await page.locator('#session-name-input').fill('Second workspace'); + await page.locator('#session-rename-save').click(); + await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' }); + await observer.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Second workspace')); + await page.locator('#session-delete').click(); + await page.locator('#confirm-ok').click(); + await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0'); + await observer.waitForURL((url) => url.hash === '#vm/vm-test/sessions'); + assert.ok(await observer.locator('#session-terminal').isHidden()); + await page.reload(); + await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0'); + assert.equal(await page.locator('.session-item').count(), 0); + assert.deepEqual(errors, []); + await observer.close(); + console.log('Browser sessions passed: terminal/reconnect, VM isolation, persistent rename, active/ended deletion, cancellation, live browser updates, 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); diff --git a/tests/test_sessions.py b/tests/test_sessions.py index d7ff58f..2488c28 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import base64 import os +import sqlite3 import tempfile import unittest from pathlib import Path @@ -13,7 +14,7 @@ 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 +from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, StateError, ValidationError if FASTAPI_AVAILABLE: @@ -28,6 +29,7 @@ class FakeTerminal: self.input = [] self.sizes = [] self.closed = False + self.end_calls = 0 async def read(self): event = await self.events.get() @@ -43,6 +45,7 @@ class FakeTerminal: self.sizes.append((rows, cols)) async def end(self): + self.end_calls += 1 await self.events.put({"type": "exit", "exit_code": 0}) def close(self): @@ -133,6 +136,70 @@ class SessionStoreTests(unittest.TestCase): SessionStore(self.path) self.assertEqual(target.read_bytes(), b"keep this") + def test_rename_preserves_session_identity_state_and_output_across_reopening(self): + record = self.store.create("vm-a", 24, 80) + self.store.activate(record["id"], "worker") + self.store.append(record["id"], b"history") + before = self.store.get("vm-a", record["id"]) + renamed = self.store.rename("vm-a", record["id"], " Déploiement 'alpha' ") + self.assertEqual(renamed["name"], "Déploiement 'alpha'") + for key in before.keys() - {"name", "updated_at"}: + self.assertEqual(renamed[key], before[key]) + self.store.close() + reopened = SessionStore(self.path) + self.addCleanup(reopened.close) + saved, _start, output = reopened.read("vm-a", record["id"], 0) + self.assertEqual(saved["name"], renamed["name"]) + self.assertEqual(output, b"history") + + def test_invalid_session_names_do_not_change_the_record(self): + record = self.store.create("vm-a", 24, 80) + for name in ("", " ", "a" * 81, "two\nlines", "bad\x00name", "\ud800", 123, None): + with self.subTest(name=repr(name)), self.assertRaises(ValidationError): + self.store.rename("vm-a", record["id"], name) + self.assertEqual(self.store.get("vm-a", record["id"]), record) + + def test_rename_and_delete_cannot_cross_vm_boundaries(self): + record = self.store.create("vm-a", 24, 80) + self.store.finish(record["id"], "closed", 0) + for operation in (lambda: self.store.rename("vm-b", record["id"], "other"), + lambda: self.store.delete("vm-b", record["id"])): + with self.assertRaises(NotFoundError): + operation() + self.assertEqual(self.store.get("vm-a", record["id"])["name"], record["name"]) + + def test_delete_removes_metadata_and_transcript_and_keeps_other_sessions(self): + first = self.store.create("vm-a", 24, 80) + second = self.store.create("vm-a", 24, 80) + self.store.append(first["id"], b"remove") + self.store.append(second["id"], b"keep") + self.store.finish(first["id"], "closed", 0) + self.store.delete("vm-a", first["id"]) + with self.assertRaises(NotFoundError): + self.store.read("vm-a", first["id"], 0) + with sqlite3.connect(self.path) as db: + self.assertEqual(db.execute("SELECT COUNT(*) FROM output WHERE session_id=?", (first["id"],)).fetchone()[0], 0) + self.assertEqual(self.store.read("vm-a", second["id"], 0)[2], b"keep") + + def test_failed_delete_rolls_back_metadata_and_output_together(self): + record = self.store.create("vm-a", 24, 80) + self.store.append(record["id"], b"keep") + self.store.finish(record["id"], "closed", 0) + with sqlite3.connect(self.path) as db: + db.execute("CREATE TRIGGER reject_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'test failure'); END") + with self.assertRaises(StateError): + self.store.delete("vm-a", record["id"]) + self.assertEqual(self.store.read("vm-a", record["id"], 0)[2], b"keep") + + def test_running_history_requires_closure_and_default_names_avoid_existing_labels(self): + records = [self.store.create("vm-a", 24, 80) for _ in range(3)] + with self.assertRaises(ConflictError): + self.store.delete("vm-a", records[1]["id"]) + self.store.finish(records[1]["id"], "closed", 0) + self.store.delete("vm-a", records[1]["id"]) + created = self.store.create("vm-a", 24, 80) + self.assertNotIn(created["name"], {records[0]["name"], records[2]["name"]}) + class SessionManagerTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -219,6 +286,83 @@ class SessionManagerTests(unittest.IsolatedAsyncioTestCase): self.addCleanup(reopened.close) self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ") + async def test_rename_keeps_the_same_live_terminal(self): + record = await self.manager.create("vm-test", 24, 80) + terminal = self.application.terminals[0] + renamed = await self.manager.rename("vm-test", record["id"], "Deployment") + self.assertEqual((renamed["id"], renamed["status"], renamed["name"]), (record["id"], "active", "Deployment")) + await self.manager.write("vm-test", record["id"], b"pwd\r") + self.assertEqual(terminal.input, [b"pwd\r"]) + self.assertFalse(terminal.closed) + self.assertEqual(len(self.application.terminals), 1) + + async def test_delete_closes_only_its_live_terminal_and_removes_all_history(self): + record = await self.manager.create("vm-test", 24, 80) + other = await self.manager.create("vm-test", 24, 80) + await self.manager.write("vm-test", record["id"], b"last output\r") + await self.manager.delete("vm-test", record["id"]) + self.assertTrue(self.application.terminals[0].closed) + self.assertFalse(self.application.terminals[1].closed) + self.assertEqual([item["id"] for item in await self.manager.list("vm-test")], [other["id"]]) + with sqlite3.connect(self.manager._history().path) as db: + self.assertEqual(db.execute("SELECT COUNT(*) FROM output WHERE session_id=?", (record["id"],)).fetchone()[0], 0) + + async def test_delete_waits_for_an_inflight_launch_before_closing_its_channel(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 + creating = asyncio.create_task(self.manager.create("vm-test", 24, 80)) + self.assertTrue(await asyncio.to_thread(entered.wait, 2)) + record = (await self.manager.list("vm-test"))[0] + deleting = asyncio.create_task(self.manager.delete("vm-test", record["id"])) + try: + await asyncio.sleep(0.03) + self.assertFalse(deleting.done()) + finally: + release.set() + await asyncio.gather(creating, deleting) + self.assertTrue(self.application.terminals[0].closed) + self.assertEqual(await self.manager.list("vm-test"), []) + + async def test_cancelled_delete_request_finishes_cleanup_even_during_shutdown(self): + record = await self.manager.create("vm-test", 24, 80) + terminal = self.application.terminals[0] + entered, release = asyncio.Event(), asyncio.Event() + end = terminal.end + + async def slow_end(): + entered.set() + await release.wait() + await end() + + terminal.end = slow_end + request = asyncio.create_task(self.manager.delete("vm-test", record["id"])) + await asyncio.wait_for(entered.wait(), timeout=2) + request.cancel() + await asyncio.gather(request, return_exceptions=True) + release.set() + await self.manager.shutdown() + reopened = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3") + self.addCleanup(reopened.close) + self.assertTrue(terminal.closed) + self.assertEqual(reopened.list("vm-test"), []) + + async def test_close_and_delete_are_serialized_for_the_same_session(self): + record = await self.manager.create("vm-test", 24, 80) + results = await asyncio.gather(self.manager.end("vm-test", record["id"]), + self.manager.delete("vm-test", record["id"]), return_exceptions=True) + self.assertTrue(all(not isinstance(result, Exception) or isinstance(result, NotFoundError) for result in results)) + self.assertTrue(self.application.terminals[0].closed) + self.assertEqual(self.application.terminals[0].end_calls, 1) + self.assertEqual(await self.manager.list("vm-test"), []) + @unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required") class SessionApiTests(unittest.TestCase): @@ -252,7 +396,8 @@ class SessionApiTests(unittest.TestCase): 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")): + ("GET", self.base + "/session-unknown"), ("POST", self.base + "/session-unknown/close"), + ("PATCH", self.base + "/session-unknown"), ("DELETE", self.base + "/session-unknown")): 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: @@ -281,6 +426,8 @@ class SessionApiTests(unittest.TestCase): 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) + self.assertEqual(self.client.patch(other + "/" + record["id"], headers=self.headers, json={"name": "Other"}).status_code, 404) + self.assertEqual(self.client.delete(other + "/" + record["id"], 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) @@ -320,3 +467,36 @@ class SessionApiTests(unittest.TestCase): 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") + + def test_rename_is_validated_persisted_and_sent_to_attached_terminals(self): + record = self.create() + url = self.base + "/" + record["id"] + for body in ({}, {"name": " "}, {"name": 123}, {"name": "x" * 81}, + {"name": "two\nlines"}, {"name": "bad\x00name"}, {"name": "New", "vm_id": "vm-other"}): + with self.subTest(body=body): + self.assertEqual(self.client.patch(url, headers=self.headers, json=body).status_code, 422) + ws = self.connect(record) + self.output(ws) + response = self.client.patch(url, headers=self.headers, json={"name": " Déploiement "}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["name"], "Déploiement") + while (message := ws.receive_json()).get("session", {}).get("name") != "Déploiement": + self.assertEqual(message["type"], "session") + self.assertEqual(message["session"]["status"], "active") + self.assertEqual(self.client.get(url, headers=self.headers).json()["name"], "Déploiement") + self.assertFalse(self.application.terminals[0].closed) + + def test_delete_live_session_returns_no_content_and_notifies_attached_browser(self): + record = self.create() + ws = self.connect(record) + self.output(ws) + url = self.base + "/" + record["id"] + response = self.client.delete(url, headers=self.headers) + self.assertEqual(response.status_code, 204, response.text) + self.assertEqual(response.content, b"") + while (message := ws.receive_json())["type"] == "session": + pass + self.assertEqual(message, {"type": "deleted", "session_id": record["id"]}) + self.assertTrue(self.application.terminals[0].closed) + self.assertEqual(self.client.get(url, headers=self.headers).status_code, 404) + self.assertEqual(self.client.get(self.base, headers=self.headers).json(), []) diff --git a/uvm/tool_catalog.py b/uvm/tool_catalog.py new file mode 100644 index 0000000..e5005c9 --- /dev/null +++ b/uvm/tool_catalog.py @@ -0,0 +1,162 @@ +"""The allowlisted guest tool catalog and pinned, architecture-specific recipes. + +Download digests come from the publishers' release SHA256SUMS/.sha256 files: +Node v24.21.0, python-build-standalone 20260924, Miniforge 26.5.3-0, and +micromamba-releases 2.9.0-0. Update versions, URLs, and digests together. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .errors import ValidationError + + +CATALOG = ( + {"id": "git", "name": "Git", "description": "Version control for your projects.", + "version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"}, + {"id": "nodejs", "name": "Node.js", "description": "JavaScript runtime with npm and npx.", + "version_label": "24 LTS · 24.21.0", "versions": [], "source": "nodejs.org"}, + {"id": "python", "name": "Python", "description": "Side-by-side Python runtimes with pip and venv.", + "version_label": "3.11 / 3.12 / 3.13", "versions": ["3.11", "3.12", "3.13"], + "source": "Astral python-build-standalone"}, + {"id": "jdk", "name": "JDK", "description": "OpenJDK development kit: java and javac.", + "version_label": "OpenJDK 21 LTS", "versions": [], "source": "Ubuntu"}, + {"id": "wget", "name": "wget", "description": "Download files from the command line.", + "version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"}, + {"id": "curl", "name": "curl", "description": "Transfer data and call HTTP APIs.", + "version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"}, + {"id": "htop", "name": "htop", "description": "Interactive process and resource monitor.", + "version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"}, + {"id": "php", "name": "PHP", "description": "PHP command-line runtime.", + "version_label": "8.5 CLI", "versions": [], "source": "Ondřej Surý PHP PPA"}, + {"id": "conda", "name": "Conda", "description": "Miniforge with the conda-forge package channel.", + "version_label": "Miniforge 26.5.3-0", "versions": [], "source": "conda-forge"}, + {"id": "micromamba", "name": "micromamba", "description": "Lightweight Conda-compatible environment manager.", + "version_label": "2.9.0", "versions": [], "source": "mamba-org"}, +) +TOOL_IDS = frozenset(tool["id"] for tool in CATALOG) +MAX_SELECTIONS = 12 + + +@dataclass(frozen=True, slots=True) +class ToolSelection: + id: str + version: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.id, str) or self.id not in TOOL_IDS: + raise ValidationError("unknown tool; choose a tool from GET /tools") + if self.id == "python": + if self.version not in ("3.11", "3.12", "3.13"): + raise ValidationError("Python requires version 3.11, 3.12, or 3.13") + elif self.version is not None: + raise ValidationError("only Python accepts a version; other tools use the catalog default") + + @property + def key(self) -> str: + return f"{self.id}-{self.version}" if self.version else self.id + + def to_dict(self) -> dict: + return {"id": self.id, "version": self.version} + + +def normalize_tools(values: object, *, allow_empty: bool = False) -> tuple[ToolSelection, ...]: + if not isinstance(values, (list, tuple)) or len(values) > MAX_SELECTIONS: + raise ValidationError(f"tools must be a list of at most {MAX_SELECTIONS} catalog selections") + result = {} + for value in values: + if isinstance(value, ToolSelection): + selection = ToolSelection(value.id, value.version) + elif isinstance(value, dict) and "id" in value and not set(value) - {"id", "version"}: + selection = ToolSelection(value["id"], value.get("version")) + else: + raise ValidationError("each tool selection requires an id and optional Python version") + result[selection.key] = selection + if not result and not allow_empty: + raise ValidationError("select at least one tool to install") + # Stable ordering also makes duplicate requests independent of checkbox order. + order = {tool["id"]: index for index, tool in enumerate(CATALOG)} + return tuple(sorted(result.values(), key=lambda item: (order[item.id], item.version or ""))) + + +def all_selections() -> tuple[ToolSelection, ...]: + return tuple(ToolSelection(tool["id"], version) for tool in CATALOG for version in tool["versions"] or [None]) + + +_PYTHON_RELEASE = "20260924" +_PYTHON = { + "3.11": ("3.11.16", { + "x86_64": "68c6739376b65258dee5058ccf6777232fe38d31a578965ae8bda327ec7da3a8", + "aarch64": "763dae20c6abe982de86ceb7829cc050c5bda79a9824846d106cc1c86d57701b", + }), + "3.12": ("3.12.14", { + "x86_64": "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41", + "aarch64": "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35", + }), + "3.13": ("3.13.15", { + "x86_64": "d0b640eed27fbdd6f5f2bd33444aee53df2c8863f8b2a96f4094717411e3de9c", + "aarch64": "5ad58156cbec94e5643c13caa792e92df72a23f33d3a6425d4cbff5c4b7a040c", + }), +} +_NODE_SHA256 = { + "x86_64": "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6", + "aarch64": "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2", +} +_CONDA_SHA256 = { + "x86_64": "14db468222ad564658656f769506056209b6dc375f5e7dfd31eb5ebbf08fa529", + "aarch64": "0391e42075a7632e9665d6e728387ee6b905f6c3e704d3513e1c1133d0d69b89", +} +_MICROMAMBA_SHA256 = { + "x86_64": "366cd9cd8be14df1ab8ed50352a82111082a36686b2d389fdb79a92c3fafb3e3", + "aarch64": "9f93b974adcb4d166996af969b6cd371287d1a3e52733704727884d9b74cb7a7", +} + + +def recipe(selection: ToolSelection, architecture: str) -> dict: + """Return a JSON-serializable recipe; no request text becomes a shell program.""" + if architecture not in {"x86_64", "aarch64"}: + raise ValidationError("tool installation supports x86_64 and ARM64 guests") + tool = selection.id + result = {**selection.to_dict(), "key": selection.key, "kind": "apt", "packages": [tool], + "checks": [[tool, "--version"]], "match": r"(\d+(?:\.\d+)+)", "prefix_match": "", + "links": {}, "usage": f"{tool} --version"} + if tool == "nodejs": + arch = "x64" if architecture == "x86_64" else "arm64" + result.update(kind="archive", release="nodejs-24.21.0", packages=["ca-certificates", "libstdc++6"], + url=f"https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-{arch}.tar.xz", + sha256=_NODE_SHA256[architecture], archive_root=f"node-v24.21.0-linux-{arch}", + checks=[["node", "--version"], ["npm", "--version"], ["npx", "--version"]], prefix_match="24.", + links={name: f"bin/{name}" for name in ("node", "npm", "npx")}, usage="node --version; npm --version") + elif tool == "python": + version, hashes = _PYTHON[selection.version] + binary = f"python{selection.version}" + result.update(kind="archive", release=f"python-{version}", packages=["ca-certificates", "libcrypt1"], + url=(f"https://github.com/astral-sh/python-build-standalone/releases/download/{_PYTHON_RELEASE}/" + f"cpython-{version}+{_PYTHON_RELEASE}-{architecture}-unknown-linux-gnu-install_only_stripped.tar.gz"), + sha256=hashes[architecture], archive_root="python", prefix_match=selection.version + ".", + checks=[[binary, "-c", "import sys, ssl, sqlite3, venv, ensurepip; print(sys.version.split()[0])"], + [binary, "-m", "pip", "--version"]], links={binary: f"bin/{binary}"}, + usage=f"{binary} -m venv .venv; . .venv/bin/activate") + elif tool == "jdk": + arch = "amd64" if architecture == "x86_64" else "arm64" + home = f"/usr/lib/jvm/java-21-openjdk-{arch}" + result.update(packages=["openjdk-21-jdk-headless"], checks=[["javac", "-version"], ["java", "-version"]], + prefix_match="21.", java_home=home, links={name: f"{home}/bin/{name}" for name in ("java", "javac")}, + usage="java -version; javac -version") + elif tool == "php": + result.update(kind="php", packages=["php8.5-cli"], prefix_match="8.5.", + links={"php": "/usr/bin/php8.5"}, usage="php --version") + elif tool == "conda": + result.update(kind="conda", release="miniforge-26.5.3-0", packages=["ca-certificates", "bzip2"], + url=("https://github.com/conda-forge/miniforge/releases/download/26.5.3-0/" + f"Miniforge3-26.5.3-0-Linux-{architecture}.sh"), sha256=_CONDA_SHA256[architecture], + links={"conda": "bin/conda", "mamba": "bin/mamba"}, + usage="conda create -n myenv python=3.13; conda activate myenv") + elif tool == "micromamba": + arch = "64" if architecture == "x86_64" else "aarch64" + result.update(kind="binary", release="micromamba-2.9.0", packages=["ca-certificates"], + url=f"https://github.com/mamba-org/micromamba-releases/releases/download/2.9.0-0/micromamba-linux-{arch}", + sha256=_MICROMAMBA_SHA256[architecture], links={"micromamba": "micromamba"}, + usage="micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv") + return result