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.
This commit is contained in:
2026-09-25 22:59:32 +05:30
parent a4e9cefbc3
commit 645bf6321a
13 changed files with 768 additions and 38 deletions
+18 -4
View File
@@ -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.
+9
View File
@@ -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
+10
View File
@@ -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) |
+9 -3
View File
@@ -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); }
+13 -4
View File
@@ -11,11 +11,11 @@
<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" />
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=1" />
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=2" />
<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>
<script src="/dashboard/static/js/sessions.js?v=3" defer></script>
<script src="/dashboard/static/js/dashboard.js?v=5" defer></script>
</head>
<body>
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
@@ -207,7 +207,7 @@
<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>
<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-rename" class="button button-small" type="button" disabled>Rename</button><button id="session-end" class="button button-small" type="button" disabled>End session</button><button id="session-delete" class="button button-small" type="button" disabled>Delete</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>
@@ -246,6 +246,15 @@
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
<dialog id="session-rename-dialog" class="confirm-dialog session-rename-dialog" aria-labelledby="session-rename-title">
<form id="session-rename-form">
<span class="subtle-label">A name for this workspace</span>
<h2 id="session-rename-title">Rename session</h2>
<label class="field" for="session-name-input"><span>Session name</span><input id="session-name-input" type="text" maxlength="80" autocomplete="off" spellcheck="false" required aria-describedby="session-rename-error" /></label>
<p id="session-rename-error" class="form-error" role="alert"></p>
<div class="dialog-actions"><button id="session-rename-cancel" class="button button-quiet" type="button">Cancel</button><button id="session-rename-save" class="button button-primary" type="submit">Save name</button></div>
</form>
</dialog>
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
</body>
</html>
+1 -1
View File
@@ -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();
}
+165 -7
View File
@@ -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) => `
<a class="session-item${record.id === this.selectedId ? ' active' : ''}" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
<div class="session-item${record.id === this.selectedId ? ' active' : ''}" data-session-id="${this.esc(record.id)}">
<a class="session-link" 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>`;
</a>
<div class="session-item-actions">
<button class="quiet-link" type="button" data-session-action="rename" data-session-id="${this.esc(record.id)}" aria-label="Rename ${this.esc(record.name)}" ${this.isBusy(record.id) ? 'disabled' : ''}>Rename</button>
<button class="quiet-link danger-text" type="button" data-session-action="delete" data-session-id="${this.esc(record.id)}" aria-label="Delete ${this.esc(record.name)}" ${this.isBusy(record.id) ? 'disabled' : ''}>${this.mutating.get(record.id) === 'delete' ? 'Deleting…' : 'Delete'}</button>
</div>
</div>`).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;
}
@@ -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;
+29 -4
View File
@@ -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."})
+32 -1
View File
@@ -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=?",
+52 -6
View File
@@ -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)
+84 -4
View File
@@ -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 <alpha>');
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 <alpha>');
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);
+182 -2
View File
@@ -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(), [])
+162
View File
@@ -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