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:
+18
-4
@@ -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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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.
|
explicit close, or host connection loss cleans up the job cgroup and processes.
|
||||||
|
|
||||||
`SessionManager` owns background output readers independently of WebSocket
|
`SessionManager` owns background output readers independently of WebSocket
|
||||||
attachments. Creating a shell is shielded from an HTTP disconnect; its connection
|
attachments. Accepted creation, rename, close, and delete operations are tracked
|
||||||
is either tracked or closed. Browser navigation, reload, and logout detach only
|
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
|
the browser. Nest shutdown closes guest connections and marks live sessions
|
||||||
interrupted. In-flight transcript writes finish before releasing store ownership.
|
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
|
protect history. On reopening after a server restart, unfinished records become
|
||||||
interrupted. Session lookups always include the exact VM ID. Output is capped at
|
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;
|
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
|
or password log. Nest permits 32 active sessions; guest terminals share the
|
||||||
agent's eight connection slots with one-shot jobs.
|
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
|
backpressure. Reattachment resumes from an acknowledged offset; a truncated
|
||||||
prefix resets the viewer. Closed sessions use the same channel for read-only
|
prefix resets the viewer. Closed sessions use the same channel for read-only
|
||||||
replay. WebSocket transport support comes from the `websockets` dependency.
|
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.
|
The dashboard's Sessions tab and nested hash routes belong to the selected VM.
|
||||||
`sessions.js` owns attachment teardown, replay, reconnects, and the terminal
|
`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,
|
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
|
When `nft` and `conntrack` are installed, command-parser tests append an invalid
|
||||||
final command/option so parsing is checked without executing any mutation.
|
final command/option so parsing is checked without executing any mutation.
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ The server exposes these routes:
|
|||||||
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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
|
**End session** closes the shell and its processes. Stopping/restarting the VM
|
||||||
or the Nest server ends live sessions; their saved history remains available.
|
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
|
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`).
|
`$UVM_BASE/nest/sessions.sqlite3` (default `/var/lib/uvm/nest/sessions.sqlite3`).
|
||||||
The viewer indicates truncated output. Raw keyboard input is not logged
|
The viewer indicates truncated output. Raw keyboard input is not logged
|
||||||
|
|||||||
@@ -823,6 +823,11 @@ passwords in reports.
|
|||||||
5. Use **Session history** to switch between that VM's sessions. Active sessions
|
5. Use **Session history** to switch between that VM's sessions. Active sessions
|
||||||
reconnect to their existing shell; ended sessions replay saved output.
|
reconnect to their existing shell; ended sessions replay saved output.
|
||||||
6. Use **End session** when finished, or run `exit` inside the shell.
|
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
|
Sessions continue while you navigate elsewhere, reload, or sign out of the
|
||||||
dashboard. VM shutdown/restart or Nest shutdown ends the shell. Session metadata,
|
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.
|
`$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
|
Only sessions belonging to the selected exact VM ID are shown. Reusing a VM name
|
||||||
does not attach another VM's history.
|
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
|
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
|
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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated live terminal/replay (Nest) |
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
.session-history > .panel-footnote { margin-top: auto; padding: 15px 8px 0; }
|
.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 { 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-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:hover { background: #fff8; }
|
||||||
.session-item.active { background: var(--white); border-color: #d9d0e4; }
|
.session-item.active { background: var(--white); border-color: #d9d0e4; }
|
||||||
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
|
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
|
||||||
@@ -20,11 +23,14 @@
|
|||||||
.session-state.failed, .session-state.interrupted { background: var(--peach); }
|
.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-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 { 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 > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; }
|
||||||
.terminal-toolbar strong { font-size: 11px; }
|
.terminal-toolbar strong { font-size: 11px; overflow-wrap: anywhere; }
|
||||||
.terminal-status { color: #b8afc6; font-size: 9px; }
|
.terminal-status { color: #b8afc6; font-size: 9px; }
|
||||||
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
|
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
|
||||||
.terminal-toolbar .button:hover { background: #ffffff19; }
|
.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 { display: inline-flex; gap: 4px; margin-right: 3px; }
|
||||||
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
|
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
|
||||||
.terminal-lights i:nth-child(2) { background: var(--yellow); }
|
.terminal-lights i:nth-child(2) { background: var(--yellow); }
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
|
<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/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/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/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/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/sessions.js?v=3" defer></script>
|
||||||
<script src="/dashboard/static/js/dashboard.js?v=4" defer></script>
|
<script src="/dashboard/static/js/dashboard.js?v=5" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<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>
|
<p class="panel-footnote">Saved for this microVM.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<article class="terminal-panel">
|
<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-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>
|
<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>
|
<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="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>
|
<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="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>
|
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1428,7 +1428,7 @@
|
|||||||
if (event.target.closest('.xterm')) return;
|
if (event.target.closest('.xterm')) return;
|
||||||
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
|
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 (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();
|
event.preventDefault();
|
||||||
focusSearch();
|
focusSearch();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,31 @@
|
|||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.launching = new Set();
|
this.launching = new Set();
|
||||||
this.upgrading = new Set();
|
this.upgrading = new Set();
|
||||||
|
this.mutating = new Map();
|
||||||
this.offset = 0;
|
this.offset = 0;
|
||||||
this.renderPending = Promise.resolve();
|
this.renderPending = Promise.resolve();
|
||||||
$('#sessions-refresh').addEventListener('click', () => this.refresh());
|
$('#sessions-refresh').addEventListener('click', () => this.refresh());
|
||||||
$('#session-end').addEventListener('click', () => this.end());
|
$('#session-end').addEventListener('click', () => this.end());
|
||||||
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
|
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
|
||||||
$('#session-install-agent').addEventListener('click', () => this.installAgent());
|
$('#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) {
|
sync(vm, selectedId, visible) {
|
||||||
@@ -73,10 +92,16 @@
|
|||||||
|
|
||||||
renderList() {
|
renderList() {
|
||||||
const markup = this.records.length ? this.records.map((record) => `
|
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)}">
|
||||||
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
|
<a class="session-link" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
|
||||||
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
|
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
|
||||||
</a>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
|
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
|
||||||
|
</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;
|
if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +118,12 @@
|
|||||||
this.records = records;
|
this.records = records;
|
||||||
if (this.current) {
|
if (this.current) {
|
||||||
const latest = records.find((record) => record.id === this.current.id);
|
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;
|
if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
|
||||||
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
|
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
|
||||||
}
|
}
|
||||||
@@ -204,7 +235,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
canType() {
|
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) {
|
sendSize(rows, cols) {
|
||||||
@@ -239,7 +271,7 @@
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type === 'session') {
|
if (message.type === 'session') {
|
||||||
const wasReady = this.caughtUp;
|
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) {
|
if (message.offset > this.offset) {
|
||||||
this.term.reset();
|
this.term.reset();
|
||||||
this.offset = message.offset;
|
this.offset = message.offset;
|
||||||
@@ -248,7 +280,7 @@
|
|||||||
this.caughtUp = this.offset >= this.replayEnd;
|
this.caughtUp = this.offset >= this.replayEnd;
|
||||||
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
||||||
const index = this.records.findIndex((record) => record.id === message.session.id);
|
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.renderList();
|
||||||
this.renderStatus();
|
this.renderStatus();
|
||||||
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
||||||
@@ -271,6 +303,12 @@
|
|||||||
resolve();
|
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') {
|
} else if (message.type === 'error') {
|
||||||
rejected = true;
|
rejected = true;
|
||||||
this.showError(message.message);
|
this.showError(message.message);
|
||||||
@@ -298,7 +336,10 @@
|
|||||||
const active = record?.status === 'active';
|
const active = record?.status === 'active';
|
||||||
$('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
|
$('#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-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;
|
$('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
|
||||||
if (this.term) this.term.options.disableStdin = !this.canType();
|
if (this.term) this.term.options.disableStdin = !this.canType();
|
||||||
$('#session-meta').textContent = record
|
$('#session-meta').textContent = record
|
||||||
@@ -308,10 +349,11 @@
|
|||||||
|
|
||||||
async end() {
|
async end() {
|
||||||
const record = this.current;
|
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');
|
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;
|
if (!confirmed) return;
|
||||||
this.ending = record.id;
|
this.ending = record.id;
|
||||||
|
this.renderList();
|
||||||
this.renderStatus();
|
this.renderStatus();
|
||||||
try {
|
try {
|
||||||
const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
|
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);
|
if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
this.ending = null;
|
this.ending = null;
|
||||||
|
this.renderList();
|
||||||
this.renderStatus();
|
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() {
|
detachTerminal() {
|
||||||
this.generation += 1;
|
this.generation += 1;
|
||||||
window.clearTimeout(this.reconnectTimer);
|
window.clearTimeout(this.reconnectTimer);
|
||||||
@@ -348,6 +504,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
leave() {
|
leave() {
|
||||||
|
if ($('#session-rename-dialog').open) $('#session-rename-dialog').close();
|
||||||
|
this.renameTarget = null;
|
||||||
this.sequence += 1;
|
this.sequence += 1;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.lastRefresh = 0;
|
this.lastRefresh = 0;
|
||||||
|
|||||||
+29
-4
@@ -10,15 +10,16 @@ from contextlib import suppress
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, Request, Response, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
|
||||||
|
|
||||||
from uvm.app import Application
|
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.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data
|
||||||
from uvm.routers.dependencies import get_authorized_application
|
from uvm.routers.dependencies import get_authorized_application
|
||||||
|
|
||||||
from .sessions import SessionManager
|
from .sessions import SessionManager
|
||||||
|
from .session_store import MAX_SESSION_NAME_LENGTH
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
|
||||||
@@ -48,6 +49,11 @@ class SessionResponse(BaseModel):
|
|||||||
truncated: bool
|
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:
|
def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager:
|
||||||
return request.app.state.nest_sessions
|
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)
|
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:
|
def _same_origin(websocket: WebSocket) -> bool:
|
||||||
origin = websocket.headers.get("origin")
|
origin = websocket.headers.get("origin")
|
||||||
if origin is None:
|
if origin is None:
|
||||||
@@ -101,6 +118,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
|
|||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
tasks = []
|
tasks = []
|
||||||
|
attached = False
|
||||||
try:
|
try:
|
||||||
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
|
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
|
||||||
# frame so tokens never appear in URLs, access logs, or session history.
|
# 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
|
return
|
||||||
sessions = websocket.app.state.nest_sessions
|
sessions = websocket.app.state.nest_sessions
|
||||||
await sessions.get(vm_id, session_id)
|
await sessions.get(vm_id, session_id)
|
||||||
|
attached = True
|
||||||
offset = auth.get("offset", 0)
|
offset = auth.get("offset", 0)
|
||||||
acknowledgements = asyncio.Queue(maxsize=1)
|
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:
|
if type(acknowledged) is not int or acknowledged != cursor:
|
||||||
raise ProtocolError("invalid terminal output acknowledgement")
|
raise ProtocolError("invalid terminal output acknowledgement")
|
||||||
continue
|
continue
|
||||||
if record["status"] not in {"active", "starting"}:
|
if record["status"] not in {"active", "starting"} and not sessions.is_deleting(session_id):
|
||||||
return
|
return
|
||||||
await sessions.wait(session_id)
|
await sessions.wait(session_id)
|
||||||
|
|
||||||
@@ -157,6 +176,12 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
|
|||||||
task.result()
|
task.result()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
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):
|
except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError):
|
||||||
with suppress(WebSocketDisconnect, RuntimeError):
|
with suppress(WebSocketDisconnect, RuntimeError):
|
||||||
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
|
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
|
||||||
|
|||||||
+32
-1
@@ -16,6 +16,16 @@ from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError
|
|||||||
|
|
||||||
|
|
||||||
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
|
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:
|
class SessionStore:
|
||||||
@@ -100,7 +110,10 @@ class SessionStore:
|
|||||||
session_id = "session-" + uuid.uuid4().hex
|
session_id = "session-" + uuid.uuid4().hex
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._db() as db:
|
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
|
db.execute("""INSERT INTO sessions
|
||||||
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
|
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
|
||||||
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
|
||||||
@@ -117,6 +130,24 @@ class SessionStore:
|
|||||||
with self._db() as db:
|
with self._db() as db:
|
||||||
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
|
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:
|
def activate(self, session_id: str, username: str) -> None:
|
||||||
with self._db() as db:
|
with self._db() as db:
|
||||||
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
|
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
|
||||||
|
|||||||
+52
-6
@@ -10,7 +10,7 @@ from uvm.app import Application
|
|||||||
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
|
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
|
||||||
from uvm.guest_terminal import GuestTerminal, validate_size
|
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):
|
async def _persist(function, *arguments):
|
||||||
@@ -38,7 +38,8 @@ class SessionManager:
|
|||||||
self._store: SessionStore | None = None
|
self._store: SessionStore | None = None
|
||||||
self._live: dict[str, _LiveSession] = {}
|
self._live: dict[str, _LiveSession] = {}
|
||||||
self._pending: set[asyncio.Task] = set()
|
self._pending: set[asyncio.Task] = set()
|
||||||
self._create_lock = asyncio.Lock()
|
self._deleting: set[str] = set()
|
||||||
|
self._session_lock = asyncio.Lock()
|
||||||
self._closing = False
|
self._closing = False
|
||||||
|
|
||||||
def _history(self) -> SessionStore:
|
def _history(self) -> SessionStore:
|
||||||
@@ -68,7 +69,11 @@ class SessionManager:
|
|||||||
await self._vm(vm_id)
|
await self._vm(vm_id)
|
||||||
history = self._history()
|
history = self._history()
|
||||||
# A dropped HTTP request must not abandon a just-created guest connection.
|
# 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)
|
self._pending.add(task)
|
||||||
|
|
||||||
def completed(future):
|
def completed(future):
|
||||||
@@ -80,7 +85,7 @@ class SessionManager:
|
|||||||
return await asyncio.shield(task)
|
return await asyncio.shield(task)
|
||||||
|
|
||||||
async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict:
|
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:
|
if self._closing:
|
||||||
raise ConflictError("Nest is shutting down.")
|
raise ConflictError("Nest is shutting down.")
|
||||||
if len(self._live) >= 32:
|
if len(self._live) >= 32:
|
||||||
@@ -165,7 +170,15 @@ class SessionManager:
|
|||||||
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
|
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
|
||||||
|
|
||||||
async def end(self, vm_id: str, session_id: str) -> dict:
|
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)
|
live = self._live.get(session_id)
|
||||||
if live is not None:
|
if live is not None:
|
||||||
try:
|
try:
|
||||||
@@ -174,9 +187,42 @@ class SessionManager:
|
|||||||
except (OSError, UvmError):
|
except (OSError, UvmError):
|
||||||
live.task.cancel()
|
live.task.cancel()
|
||||||
await asyncio.gather(live.task, return_exceptions=True)
|
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
|
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:
|
async def shutdown(self) -> None:
|
||||||
self._closing = True
|
self._closing = True
|
||||||
await asyncio.gather(*self._pending, return_exceptions=True)
|
await asyncio.gather(*self._pending, return_exceptions=True)
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const { chromium } = require('playwright');
|
|||||||
}
|
}
|
||||||
assert.ok(ready, `Fixture server did not start: ${logs}`);
|
assert.ok(ready, `Fixture server did not start: ${logs}`);
|
||||||
browser = await chromium.launch({ headless: true });
|
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 = [];
|
const errors = [];
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
|
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
|
||||||
@@ -51,15 +52,49 @@ const { chromium } = require('playwright');
|
|||||||
await output('result:echo alpha');
|
await output('result:echo alpha');
|
||||||
assert.equal(await page.locator('.session-item').count(), 1);
|
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.locator('#detail-actions [data-action="launch-session"]').click();
|
||||||
await page.waitForURL((url) => url.href !== firstUrl);
|
await page.waitForURL((url) => url.href !== firstUrl);
|
||||||
await connected();
|
await connected();
|
||||||
|
const secondUrl = page.url();
|
||||||
assert.equal(await page.locator('.session-item').count(), 2);
|
assert.equal(await page.locator('.session-item').count(), 2);
|
||||||
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
|
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
|
||||||
await page.keyboard.type('echo beta');
|
await page.keyboard.type('echo beta');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await output('result:echo beta');
|
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 connected();
|
||||||
await output('result:echo alpha');
|
await output('result:echo alpha');
|
||||||
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
|
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
|
||||||
@@ -80,14 +115,59 @@ const { chromium } = require('playwright');
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await output('result:echo alpha');
|
await output('result:echo alpha');
|
||||||
assert.ok(await page.locator('#session-end').isDisabled());
|
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.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
|
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
|
||||||
assert.ok(fits, 'mobile layout overflows the viewport');
|
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 });
|
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) {
|
} catch (error) {
|
||||||
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
|
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
|
||||||
console.error(logs);
|
console.error(logs);
|
||||||
|
|||||||
+182
-2
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,7 +14,7 @@ from nest.server import create_app
|
|||||||
from nest.session_store import SessionStore
|
from nest.session_store import SessionStore
|
||||||
from nest.sessions import SessionManager
|
from nest.sessions import SessionManager
|
||||||
from tests.test_server import FASTAPI_AVAILABLE, fake_application
|
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:
|
if FASTAPI_AVAILABLE:
|
||||||
@@ -28,6 +29,7 @@ class FakeTerminal:
|
|||||||
self.input = []
|
self.input = []
|
||||||
self.sizes = []
|
self.sizes = []
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
self.end_calls = 0
|
||||||
|
|
||||||
async def read(self):
|
async def read(self):
|
||||||
event = await self.events.get()
|
event = await self.events.get()
|
||||||
@@ -43,6 +45,7 @@ class FakeTerminal:
|
|||||||
self.sizes.append((rows, cols))
|
self.sizes.append((rows, cols))
|
||||||
|
|
||||||
async def end(self):
|
async def end(self):
|
||||||
|
self.end_calls += 1
|
||||||
await self.events.put({"type": "exit", "exit_code": 0})
|
await self.events.put({"type": "exit", "exit_code": 0})
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -133,6 +136,70 @@ class SessionStoreTests(unittest.TestCase):
|
|||||||
SessionStore(self.path)
|
SessionStore(self.path)
|
||||||
self.assertEqual(target.read_bytes(), b"keep this")
|
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):
|
class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def asyncSetUp(self):
|
async def asyncSetUp(self):
|
||||||
@@ -219,6 +286,83 @@ class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.addCleanup(reopened.close)
|
self.addCleanup(reopened.close)
|
||||||
self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ")
|
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")
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
|
||||||
class SessionApiTests(unittest.TestCase):
|
class SessionApiTests(unittest.TestCase):
|
||||||
@@ -252,7 +396,8 @@ class SessionApiTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_all_http_operations_and_websocket_output_require_authentication(self):
|
def test_all_http_operations_and_websocket_output_require_authentication(self):
|
||||||
for method, path in (("GET", self.base), ("POST", self.base),
|
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)
|
self.assertEqual(self.client.request(method, path, json={}).status_code, 401)
|
||||||
for token in ("wrong", "", "\u00e9"):
|
for token in ("wrong", "", "\u00e9"):
|
||||||
with self.client.websocket_connect(self.base + "/session-unknown/terminal") as ws:
|
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, headers=self.headers).json(), [])
|
||||||
self.assertEqual(self.client.get(other + "/" + record["id"], headers=self.headers).status_code, 404)
|
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.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")
|
ws = self.connect(record, vm_id="vm-other")
|
||||||
self.assertEqual(ws.receive_json()["type"], "error")
|
self.assertEqual(ws.receive_json()["type"], "error")
|
||||||
self.assertFalse(self.application.terminals[0].closed)
|
self.assertFalse(self.application.terminals[0].closed)
|
||||||
@@ -320,3 +467,36 @@ class SessionApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.application.terminals[0].input, [])
|
self.assertEqual(self.application.terminals[0].input, [])
|
||||||
self.assertFalse(self.application.terminals[0].closed)
|
self.assertFalse(self.application.terminals[0].closed)
|
||||||
self.assertEqual(self.client.get(self.base + "/" + record["id"], headers=self.headers).json()["status"], "active")
|
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(), [])
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user