Compare commits
7
Commits
a4e9cefbc3
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e17e3e72f | ||
|
|
a41aafce83 | ||
|
|
da84e93a6e | ||
|
|
2d04430627 | ||
|
|
0b22c81e6c | ||
|
|
e1b934e35e | ||
|
|
645bf6321a |
+126
-10
@@ -19,7 +19,7 @@ expose health, installation, VM lifecycle controls, and managed port-policy
|
|||||||
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
||||||
operations, and bounded guest command execution over vsock. Interactive SSH remains CLI-only.
|
operations, and bounded guest command execution over vsock. Interactive SSH remains CLI-only.
|
||||||
|
|
||||||
There is no autostart or background reconciler, multi-host control plane,
|
There is no general VM autostart or background reconciler, multi-host control plane,
|
||||||
tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port
|
tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port
|
||||||
filtering is not public-port publishing or a hardened multi-tenant boundary;
|
filtering is not public-port publishing or a hardened multi-tenant boundary;
|
||||||
see Current Production Gaps for the remaining scope limits.
|
see Current Production Gaps for the remaining scope limits.
|
||||||
@@ -95,6 +95,10 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
│ ├── guest_agent.py # Standalone guest daemon, job scopes, and bounded wire protocol
|
│ ├── guest_agent.py # Standalone guest daemon, job scopes, and bounded wire protocol
|
||||||
│ ├── guest_exec.py # Host-side guest sessions and execution results
|
│ ├── guest_exec.py # Host-side guest sessions and execution results
|
||||||
│ ├── guest_terminal.py # Async PTY transport over a process-pinned guest connection
|
│ ├── guest_terminal.py # Async PTY transport over a process-pinned guest connection
|
||||||
|
│ ├── tool_catalog.py # Allowlisted tools, Python selections, and pinned release recipes
|
||||||
|
│ ├── guest_tools.py # Standalone guest-only package/download installer and probes
|
||||||
|
│ ├── tool_store.py # Atomic per-VM job history and inventory observations
|
||||||
|
│ ├── tools.py # Bounded background tool installation workers
|
||||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
|
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
|
||||||
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
|
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
|
||||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
||||||
@@ -106,6 +110,7 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
│ │ ├── exit.py # Authenticated server shutdown
|
│ │ ├── exit.py # Authenticated server shutdown
|
||||||
│ │ ├── health.py # Liveness endpoint
|
│ │ ├── health.py # Liveness endpoint
|
||||||
│ │ ├── installation.py # Installation endpoint
|
│ │ ├── installation.py # Installation endpoint
|
||||||
|
│ │ ├── tools.py # Catalog, inventory, and installation job endpoints
|
||||||
│ │ └── vms.py # VM lifecycle endpoints
|
│ │ └── vms.py # VM lifecycle endpoints
|
||||||
│ └── firecracker/
|
│ └── firecracker/
|
||||||
│ ├── config.py # Firecracker JSON and boot arguments
|
│ ├── config.py # Firecracker JSON and boot arguments
|
||||||
@@ -218,6 +223,8 @@ process:
|
|||||||
| FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control |
|
| FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control |
|
||||||
| LifecycleService | `LifecycleService` | Ordered VM resource changes and rollback |
|
| LifecycleService | `LifecycleService` | Ordered VM resource changes and rollback |
|
||||||
| Installer | `Installer` | Host packages and artifacts |
|
| Installer | `Installer` | Host packages and artifacts |
|
||||||
|
| ToolService | `ToolService` | Browser-independent guest installation queue and workers |
|
||||||
|
| ToolJobStore | Constructed by lifecycle/tool services | Per-VM installation intent, results, and cached inventory |
|
||||||
|
|
||||||
Composition can disable command tracing with `emit=None`; `create_api()` does
|
Composition can disable command tracing with `emit=None`; `create_api()` does
|
||||||
so when it constructs its own application. The CLI supplies an emitter that
|
so when it constructs its own application. The CLI supplies an emitter that
|
||||||
@@ -245,10 +252,12 @@ Default runtime storage is `/var/lib/uvm`:
|
|||||||
│ ├── config.json
|
│ ├── config.json
|
||||||
│ ├── firecracker.log
|
│ ├── firecracker.log
|
||||||
│ ├── firecracker.sock
|
│ ├── firecracker.sock
|
||||||
|
│ ├── tools.json
|
||||||
│ └── vsock.sock
|
│ └── vsock.sock
|
||||||
├── state.json
|
├── state.json
|
||||||
├── state.lock
|
├── state.lock
|
||||||
├── operations.lock
|
├── operations.lock
|
||||||
|
├── tool-worker.lock
|
||||||
└── integrity.json
|
└── integrity.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -272,6 +281,8 @@ state file.
|
|||||||
|
|
||||||
`VmSpec` is the validated create request. `VmRecord` is the persisted local
|
`VmSpec` is the validated create request. `VmRecord` is the persisted local
|
||||||
record. Do not make API models or CLI namespaces the persistence format.
|
record. Do not make API models or CLI namespaces the persistence format.
|
||||||
|
`VmSpec.tools` is a tuple of validated `ToolSelection` values. Tool job state
|
||||||
|
uses its own schema-1 `vms/<id>/tools.json`; the VM registry remains schema 3.
|
||||||
|
|
||||||
| Record Fields | Purpose |
|
| Record Fields | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -363,7 +374,8 @@ a ruleset/attachment snapshot, not application health or a reachability test.
|
|||||||
3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`.
|
3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`.
|
||||||
4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
|
4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
|
||||||
disk, resize ext4 to the requested storage capacity, provision credentials/host
|
disk, resize ext4 to the requested storage capacity, provision credentials/host
|
||||||
keys and the managed guest network profile, then persist `disk_ready=True`.
|
keys, the managed guest network profile, and the agent, then persist `disk_ready=True`.
|
||||||
|
If tools were selected, persist their queued job in the owned runtime directory.
|
||||||
5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
|
5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
|
||||||
6. Apply/read back the nftables bridge policy, then activate the TAP.
|
6. Apply/read back the nftables bridge policy, then activate the TAP.
|
||||||
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
|
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
|
||||||
@@ -371,10 +383,13 @@ a ruleset/attachment snapshot, not application health or a reachability test.
|
|||||||
8. Publish PID/start time/boot ID through `on_spawn` before socket readiness
|
8. Publish PID/start time/boot ID through `on_spawn` before socket readiness
|
||||||
or API configuration; clear the pending-spawn guard.
|
or API configuration; clear the pending-spawn guard.
|
||||||
9. Configure Firecracker and `InstanceStart`, verify policy enforcement again,
|
9. Configure Firecracker and `InstanceStart`, verify policy enforcement again,
|
||||||
then save `running` with no pending operation.
|
then save `running` with no pending operation.
|
||||||
|
|
||||||
This records both intent and acquired ownership across crash windows. Never
|
This records both intent and acquired ownership across crash windows. Never
|
||||||
make a managed TAP usable before enforcement or mark a partial disk ready.
|
make a managed TAP usable before enforcement or mark a partial disk ready.
|
||||||
|
Tool workers claim the queued intent after this operation releases its lock.
|
||||||
|
Tool installation errors are job results on the successfully created VM and
|
||||||
|
never enter create's resource rollback path.
|
||||||
|
|
||||||
### Rollback Rules
|
### Rollback Rules
|
||||||
|
|
||||||
@@ -505,7 +520,8 @@ snapshot produced before CLI confirmation. It reloads by **exact ID**, not a
|
|||||||
reusable name/IP. Results are `succeeded`, `failed`, `skipped` (already absent),
|
reusable name/IP. Results are `succeeded`, `failed`, `skipped` (already absent),
|
||||||
or `interrupted`; ordinary failures do not undo other successes or stop later
|
or `interrupted`; ordinary failures do not undo other successes or stop later
|
||||||
targets. Interruptions skip the remaining targets. Read-only views and API
|
targets. Interruptions skip the remaining targets. Read-only views and API
|
||||||
startup never reconcile; there is no autostart or background repair loop.
|
startup never reconcile lifecycle or firewall intent. The tool worker resumes
|
||||||
|
explicitly queued installation requests, as described under Guest Tools below.
|
||||||
|
|
||||||
## 9. Firecracker Integration
|
## 9. Firecracker Integration
|
||||||
|
|
||||||
@@ -792,7 +808,7 @@ do not import its unimplemented topology checks into current documentation.
|
|||||||
|
|
||||||
## 12. Installer and Host Commands
|
## 12. Installer and Host Commands
|
||||||
|
|
||||||
`Installer` is the only owner of apt installation, Firecracker artifact
|
`Installer` is the owner of host apt installation, Firecracker artifact
|
||||||
handling, downloads, and integrity-manifest publication.
|
handling, downloads, and integrity-manifest publication.
|
||||||
|
|
||||||
`Settings` detects and normalizes the host architecture at construction time
|
`Settings` detects and normalizes the host architecture at construction time
|
||||||
@@ -893,7 +909,7 @@ Nest. Both server entry points expose the UVM management routes below.
|
|||||||
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
||||||
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
||||||
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
||||||
| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
|
| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports`, `tools` |
|
||||||
| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action |
|
| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action |
|
||||||
| `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP |
|
| `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP |
|
||||||
| `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk |
|
| `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk |
|
||||||
@@ -905,10 +921,16 @@ Nest. Both server entry points expose the UVM management routes below.
|
|||||||
| `POST` | `/vms/{vm_id}/ports/sync` | `vms.py` | Exact ID only; reapply managed policy |
|
| `POST` | `/vms/{vm_id}/ports/sync` | `vms.py` | Exact ID only; reapply managed policy |
|
||||||
| `POST` | `/vms/{vm_id}/exec` | `vms.py` | Exact ID; `command`, strict `sudo`, bounded `timeout_seconds` |
|
| `POST` | `/vms/{vm_id}/exec` | `vms.py` | Exact ID; `command`, strict `sudo`, bounded `timeout_seconds` |
|
||||||
| `POST` | `/vms/{vm_id}/agent/install` | `vms.py` | Exact ID; install/upgrade the agent on a stopped disk |
|
| `POST` | `/vms/{vm_id}/agent/install` | `vms.py` | Exact ID; install/upgrade the agent on a stopped disk |
|
||||||
|
| `GET` | `/tools` | `tools.py` | Authenticated catalog and supported Python versions |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools` | `tools.py` | Cached inventory/history; `refresh=true` probes the running guest |
|
||||||
|
| `POST` | `/vms/{vm_id}/tools/install` | `tools.py` | Exact ID; `tools`, `start_if_stopped`, `restart`, optional `request_id`; HTTP 202 |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools/jobs/{job_id}` | `tools.py` | Exact VM-scoped installation job and bounded output |
|
||||||
| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail |
|
| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail |
|
||||||
| `GET` | `/vms/{vm_id}/sessions` | `nest/session_routes.py` | Saved session list (Nest only) |
|
| `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) |
|
||||||
|
|
||||||
@@ -1028,6 +1050,78 @@ device and uses CID 3. The process adapter retains both sockets while identity
|
|||||||
is uncertain and removes them only after confirmed VMM exit. IPv4 port policies
|
is uncertain and removes them only after confirmed VMM exit. IPv4 port policies
|
||||||
do not govern this host-only management channel.
|
do not govern this host-only management channel.
|
||||||
|
|
||||||
|
### Guest Tools
|
||||||
|
|
||||||
|
`tool_catalog.py` is the source of truth for catalog metadata, normalized
|
||||||
|
`ToolSelection` values, and installer recipes. The API accepts catalog IDs and
|
||||||
|
explicit Python minors; executable text, download URLs, paths, and other runtime
|
||||||
|
versions are not request fields. There are ten tools and twelve selectable
|
||||||
|
entries when Python 3.11/3.12/3.13 are expanded. Update publisher versions, URLs,
|
||||||
|
and SHA-256 values together when changing a downloaded default.
|
||||||
|
|
||||||
|
`ToolService` owns two bounded worker threads by default. The shared UVM server
|
||||||
|
lifespan starts/stops them, so both Nest and the standalone API process the same
|
||||||
|
queue. An exclusive, mode-0600 `$UVM_BASE/tool-worker.lock` permits one management
|
||||||
|
server to own these workers per registry. Construction alone starts no threads.
|
||||||
|
Queued requests are dispatched on server startup; prior in-flight jobs are
|
||||||
|
marked interrupted with unknown outcomes after ownership has been acquired.
|
||||||
|
|
||||||
|
`LifecycleService.queue_tools()` validates an exact VM ID and persists a request
|
||||||
|
under the operation lock. Only one queued/active batch is allowed per VM.
|
||||||
|
Identical active requests are deduplicated. Optional request IDs also identify
|
||||||
|
completed jobs until they age out of the ten-job history; conflicting reuse is
|
||||||
|
an error. `prepare_tool_install()` performs an explicitly requested start or
|
||||||
|
restart under the normal lifecycle lock and returns the selected process identity.
|
||||||
|
Creation writes its tool job after disk provisioning and before boot.
|
||||||
|
|
||||||
|
Workers wait up to `Settings.tool_ready_timeout_s` (120 seconds) for the agent.
|
||||||
|
Each tool has `Settings.tool_install_timeout_s` (1,200 seconds), bounded by the
|
||||||
|
existing guest-exec protocol maximum. Readiness connection failures can be
|
||||||
|
retried before submission; an uncertain installation result is never replayed.
|
||||||
|
Each step passes `expected_identity` to `LifecycleService.execute()`, so a restart
|
||||||
|
between steps cannot retarget the batch to a new VMM. The guest boot ID also
|
||||||
|
detects in-guest reboots. Guest downloads and package waits hold no host operation
|
||||||
|
lock. Shutdown closes tracked waiting sockets, preserves interruption results,
|
||||||
|
and keeps worker ownership until outstanding host work has finished.
|
||||||
|
|
||||||
|
`guest_tools.py` is a standalone stdlib-only Python 3.10+ payload. Its source and
|
||||||
|
server-generated JSON recipes are shell-quoted into the bounded guest exec
|
||||||
|
request. Importing it on the host performs no installation. In the guest it:
|
||||||
|
|
||||||
|
- Checks Ubuntu 22.04 and the expected x86_64/ARM64 architecture before mutation.
|
||||||
|
- Serializes installation through `/run/uvm-tools.lock`, including inherited
|
||||||
|
lock ownership during package commands. A previous disconnected job can finish
|
||||||
|
in the guest, and this lock prevents overlapping retries.
|
||||||
|
- Uses non-interactive apt with bounded dpkg-lock waits and authenticated sources.
|
||||||
|
A pending marker permits a retry to finish its interrupted dpkg configuration.
|
||||||
|
PHP's repository key is checked against a pinned fingerprint and scoped with
|
||||||
|
`signed-by`.
|
||||||
|
- Checks pinned hashes before executing/extracting downloads. Archive extraction
|
||||||
|
validates paths/links, file types, and size bounds. Versioned payloads live in
|
||||||
|
`/opt/uvm-tools`; executable links and login-shell profiles have an ownership
|
||||||
|
manifest that detects guest edits.
|
||||||
|
- Checks command versions and companions as the configured guest account, including
|
||||||
|
npm/npx, Python pip/venv imports, and both Java tools. Python has versioned
|
||||||
|
executables; Conda uses per-user environments/caches. Get-started commands use
|
||||||
|
explicit POSIX shell hooks for activation in Nest's non-login shells.
|
||||||
|
- Captures a bounded 16 KiB output tail and returns structured JSON when each
|
||||||
|
step finishes. An ordinary recipe failure allows later tools to be attempted.
|
||||||
|
|
||||||
|
`ToolJobStore` keeps cached inventory and the last ten jobs in a mode-0600,
|
||||||
|
schema-1 `tools.json` in the existing private VM runtime directory. Atomic
|
||||||
|
replacement/fsync and short operation-lock transactions prevent races with
|
||||||
|
deletion or stale lifecycle record saves. Store writes require a still-registered,
|
||||||
|
owned VM directory and never recreate a deleted VM. VM deletion removes this
|
||||||
|
history with the runtime. Full inventory refreshes update `checked_at`; step
|
||||||
|
results also update individual tool observations. GET without `refresh=true`
|
||||||
|
returns cached observations, including the `stale` flag.
|
||||||
|
|
||||||
|
The dashboard's `tools.js` owns create selections, the VM Tools tab, job polling,
|
||||||
|
history, retries, and output rendering. Active jobs poll every 1.5 seconds while
|
||||||
|
visible; idle views poll cached status every five seconds. New selections use the
|
||||||
|
shared catalog. Navigation/logout invalidate in-flight UI responses, and tool
|
||||||
|
output is escaped before rendering.
|
||||||
|
|
||||||
### Nest Terminal Sessions
|
### Nest Terminal Sessions
|
||||||
|
|
||||||
Nest owns session HTTP/WebSocket routes and history. UVM owns the process-pinned
|
Nest owns session HTTP/WebSocket routes and history. UVM owns the process-pinned
|
||||||
@@ -1051,8 +1145,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 +1160,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 +1176,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 +1287,18 @@ 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.
|
||||||
|
|
||||||
|
With the same browser dependencies, `node tests/browser_tools.cjs` checks creation
|
||||||
|
selections, persisted background jobs across reload, installed-tool detection,
|
||||||
|
partial failure/retry, escaped output, history, stopped-VM startup, per-VM isolation,
|
||||||
|
multi-select installation, and mobile layout. Its fixture uses the real API,
|
||||||
|
worker threads, and atomic job store with simulated guest execution.
|
||||||
|
`tests/test_tool_*.py` cover validation, architecture-specific payloads, checksum
|
||||||
|
failure, archive traversal, exact-ID targeting, idempotency, recovery, and shutdown.
|
||||||
|
The opt-in real-VM catalog/runtime smoke test is documented in
|
||||||
|
[USAGE.md](USAGE.md#18-verification); run it on disposable x86_64 and ARM64 VMs.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ and `/dashboard` routes belong to the Nest server.
|
|||||||
|
|
||||||
The Nest dashboard includes a fleet overview, searchable machine collection,
|
The Nest dashboard includes a fleet overview, searchable machine collection,
|
||||||
private network and port-policy view, host diagnostics, and machine provisioning.
|
private network and port-policy view, host diagnostics, and machine provisioning.
|
||||||
Open a machine to manage its lifecycle, launch terminal sessions, edit or sync
|
Open a machine to manage its lifecycle, install developer tools, launch terminal sessions, edit or sync
|
||||||
its port rules, copy its SSH command, or read recent logs. Select machines in the
|
its port rules, copy its SSH command, or read recent logs. Select machines in the
|
||||||
collection for bulk operations.
|
collection for bulk operations.
|
||||||
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
|
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
|
||||||
@@ -330,7 +330,7 @@ The server exposes these routes:
|
|||||||
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
||||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||||
| `GET` | `/vms` | List VM records and observed status |
|
| `GET` | `/vms` | List VM records and observed status |
|
||||||
| `POST` | `/vms` | Create; supports `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
|
| `POST` | `/vms` | Create; supports `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports`, `tools` |
|
||||||
| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions |
|
| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions |
|
||||||
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
|
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
|
||||||
| `POST` | `/vms/{identifier}/start` | Boot a retained private disk |
|
| `POST` | `/vms/{identifier}/start` | Boot a retained private disk |
|
||||||
@@ -342,10 +342,16 @@ The server exposes these routes:
|
|||||||
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
|
||||||
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
|
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
|
||||||
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
|
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
|
||||||
|
| `GET` | `/tools` | Guest tool catalog and supported Python versions |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools` | Tool inventory and installation history; `refresh=true` probes the running guest |
|
||||||
|
| `POST` | `/vms/{vm_id}/tools/install` | Queue selected guest tools; HTTP 202 with a job ID |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools/jobs/{job_id}` | Per-tool progress, results, and bounded installation output |
|
||||||
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
|
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
|
||||||
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
|
| `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) |
|
||||||
|
|
||||||
@@ -363,7 +369,47 @@ Log reads use an optional `tail` query parameter from `0` to `1000`, default
|
|||||||
Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or
|
Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or
|
||||||
`ports-sync`) plus either `identifiers` or `all_vms: true`; they return a result
|
`ports-sync`) plus either `identifiers` or `all_vms: true`; they return a result
|
||||||
for each fixed target and do not prompt for confirmation. Interactive SSH
|
for each fixed target and do not prompt for confirmation. Interactive SSH
|
||||||
remains CLI-only. The API and dashboard are not background reconcilers.
|
remains CLI-only. VM lifecycle changes are explicit; accepted tool installation
|
||||||
|
jobs are processed in the background.
|
||||||
|
|
||||||
|
### One-click Guest Tools
|
||||||
|
|
||||||
|
Choose tools under **New machine → Pick your tools** to install them automatically
|
||||||
|
after boot. For an existing VM, open **Machines → select a VM → Tools** and click
|
||||||
|
**Install**, or select several tools and click **Install selected**. A stopped VM
|
||||||
|
offers **Start & install**. Installation works over vsock with SSH disabled.
|
||||||
|
|
||||||
|
The initial catalog targets Ubuntu 22.04 guests on x86_64 and ARM64:
|
||||||
|
|
||||||
|
| Tool | Default |
|
||||||
|
|---|---|
|
||||||
|
| Git, wget, curl, htop | Ubuntu packages |
|
||||||
|
| Node.js | 24 LTS (24.21.0), including npm and npx |
|
||||||
|
| Python | 3.11, 3.12, and 3.13 with pip and venv; versions can coexist |
|
||||||
|
| JDK | OpenJDK 21 LTS, including `java` and `javac` |
|
||||||
|
| PHP | 8.5 CLI from the signed Ondřej Surý PHP PPA |
|
||||||
|
| Conda | Miniforge 26.5.3-0, using conda-forge |
|
||||||
|
| micromamba | 2.9.0 |
|
||||||
|
|
||||||
|
Node, Python, Miniforge, and micromamba downloads use pinned release checksums.
|
||||||
|
Python uses Astral's standalone distributions, exposed as `python3.11`,
|
||||||
|
`python3.12`, and `python3.13`. The guest's OS Python continues serving the agent.
|
||||||
|
Downloaded tools live under `/opt/uvm-tools`, with executable links in
|
||||||
|
`/usr/local/bin`. Conda environments and caches are writable by the configured
|
||||||
|
guest user. **Get started** shows commands, including shell initialization for
|
||||||
|
Conda/micromamba activation in Nest's non-login terminals.
|
||||||
|
|
||||||
|
Jobs run independently of the browser and show progress and captured output per
|
||||||
|
tool. Already available tools are verified and skipped. A failed tool can be
|
||||||
|
retried on the created VM. Guest internet access is required for downloads.
|
||||||
|
Restart the management server to load the feature; older running VMs may need
|
||||||
|
**Restart & install** to apply the current guest-agent setup.
|
||||||
|
|
||||||
|
The most recent ten jobs and cached inventory are stored in the VM's private
|
||||||
|
`tools.json` file. Queued requests resume when the server starts. An interrupted
|
||||||
|
in-flight installation has an uncertain outcome and may finish inside the guest;
|
||||||
|
use **Check installed tools** before retrying. See
|
||||||
|
[API examples and recovery](USAGE.md#install-guest-tools) for details.
|
||||||
|
|
||||||
### MicroVM Terminal Sessions
|
### MicroVM Terminal Sessions
|
||||||
|
|
||||||
@@ -379,6 +425,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
|
||||||
@@ -912,10 +920,16 @@ curl --cacert /path/to/ca.pem \
|
|||||||
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID |
|
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID |
|
||||||
| `POST` | `/vms/{vm_id}/exec` | Execute a guest command/script over vsock, with optional root privileges |
|
| `POST` | `/vms/{vm_id}/exec` | Execute a guest command/script over vsock, with optional root privileges |
|
||||||
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the agent on a stopped VM's disk |
|
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the agent on a stopped VM's disk |
|
||||||
|
| `GET` | `/tools` | Authenticated tool catalog and Python version options |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools` | Cached tool inventory/history; `refresh=true` checks the running guest |
|
||||||
|
| `POST` | `/vms/{vm_id}/tools/install` | Queue tool installation; HTTP 202 with a job ID |
|
||||||
|
| `GET` | `/vms/{vm_id}/tools/jobs/{job_id}` | Installation progress, results, and captured output |
|
||||||
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
|
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
|
||||||
| `GET` | `/vms/{vm_id}/sessions` | List saved sessions for an exact VM ID (Nest) |
|
| `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) |
|
||||||
|
|
||||||
@@ -963,7 +977,7 @@ curl -X POST \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Request fields are `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`,
|
Request fields are `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`,
|
||||||
`ports`, and `no_default_ports`. The name has the same validation as the CLI.
|
`ports`, `no_default_ports`, and `tools`. The name has the same validation as the CLI.
|
||||||
`ports` is an array of objects (or null/omitted), not a CLI colon string.
|
`ports` is an array of objects (or null/omitted), not a CLI colon string.
|
||||||
Each object has a strict integer `port` from 1 to 65535 and an optional,
|
Each object has a strict integer `port` from 1 to 65535 and an optional,
|
||||||
case-sensitive `protocol` of `TCP` (default), `HTTP`, or `HTTPS`.
|
case-sensitive `protocol` of `TCP` (default), `HTTP`, or `HTTPS`.
|
||||||
@@ -978,6 +992,106 @@ The response never includes the password. Omitting credentials still uses
|
|||||||
labels return HTTP `422`. Example passwords above are placeholders; real
|
labels return HTTP `422`. Example passwords above are placeholders; real
|
||||||
secrets in curl arguments can enter shell history and process listings.
|
secrets in curl arguments can enter shell history and process listings.
|
||||||
|
|
||||||
|
### Install Guest Tools
|
||||||
|
|
||||||
|
The dashboard provides tool checkboxes during creation and a **Tools** tab on
|
||||||
|
each VM. Choose **Install** for a single tool or **Install selected** for a batch.
|
||||||
|
**Start & install** also boots a stopped VM. The catalog covers Ubuntu 22.04 on
|
||||||
|
x86_64 and ARM64, with guest internet access for package downloads.
|
||||||
|
|
||||||
|
Use the same API selections during creation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"dev-tools","username":"developer","password":"replace-me","tools":[{"id":"git"},{"id":"nodejs"},{"id":"python","version":"3.12"}]}' \
|
||||||
|
"$UVM_API_URL/vms"
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM response is HTTP 201 after creation. Selected tools install asynchronously;
|
||||||
|
read the returned VM's `/tools` endpoint for its job. Omitted `tools` defaults to
|
||||||
|
an empty list. An explicit null is rejected.
|
||||||
|
|
||||||
|
For an existing VM, use its exact ID from create/list:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export VM_ID='<exact-vm-id>'
|
||||||
|
curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/tools"
|
||||||
|
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tools":[{"id":"jdk"},{"id":"conda"},{"id":"micromamba"}],"start_if_stopped":true,"request_id":"dev-tools-1"}' \
|
||||||
|
"$UVM_API_URL/vms/$VM_ID/tools/install"
|
||||||
|
|
||||||
|
# Use the job ID from the HTTP 202 response.
|
||||||
|
export JOB_ID='<tools-job-id>'
|
||||||
|
curl -H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
"$UVM_API_URL/vms/$VM_ID/tools/jobs/$JOB_ID"
|
||||||
|
curl -H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||||
|
"$UVM_API_URL/vms/$VM_ID/tools?refresh=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
Selections are objects with `id` and an optional `version`. IDs are `git`,
|
||||||
|
`nodejs`, `python`, `jdk`, `wget`, `curl`, `htop`, `php`, `conda`, and
|
||||||
|
`micromamba`. Python requires `version: "3.11"`, `"3.12"`, or `"3.13"`;
|
||||||
|
the other tools use the curated catalog default and accept no version override.
|
||||||
|
Up to twelve selections are accepted, with identical selections deduplicated.
|
||||||
|
Multiple Python versions can be installed together.
|
||||||
|
|
||||||
|
Install requests require a nonempty `tools` list. `start_if_stopped` and `restart`
|
||||||
|
are strict booleans, both default false. Set `restart: true` to restart first and
|
||||||
|
refresh guest-agent setup. In the dashboard, the **Restart & install** recovery
|
||||||
|
button appears when agent readiness fails. Unknown tools/options return 422;
|
||||||
|
unavailable VM states or a different active install return 409.
|
||||||
|
|
||||||
|
`request_id` is an optional idempotency key of 1–80 letters, digits, underscores,
|
||||||
|
or hyphens. Reusing it with the same request returns the retained job; different
|
||||||
|
selections/options return 409. Use a new key for an intentional retry. Idempotency
|
||||||
|
keys last as long as their job in the ten-job history. One batch per VM is active
|
||||||
|
at a time, with two VM workers by default and sequential tools within a batch.
|
||||||
|
|
||||||
|
Jobs move through `queued`, `starting`, `waiting`, and `installing`, then
|
||||||
|
`succeeded`, `failed`, or `interrupted`. Each item reports `installed`,
|
||||||
|
`already_installed`, `failed`, `interrupted`, or `skipped`, plus its detected
|
||||||
|
version, error, bounded output, and truncation flag. Ordinary recipe failures
|
||||||
|
allow subsequent tools to run; uncertain execution outcomes end that batch.
|
||||||
|
Output is captured as a 16 KiB tail per tool and returned when the step finishes.
|
||||||
|
Default limits are 120 seconds for agent readiness and 1,200 seconds per tool.
|
||||||
|
|
||||||
|
`GET /vms/{vm_id}/tools` returns cached inventory, the latest job, and summaries
|
||||||
|
of the last ten jobs. `refresh=true` probes all tools in a running VM and updates
|
||||||
|
`checked_at`; it does not install software or start a stopped VM. `stale` marks
|
||||||
|
old or stopped-VM observations. Installed binaries and their version checks run
|
||||||
|
as the configured guest user. Matching installed runtimes are accepted, including
|
||||||
|
tools installed outside Nest. The install operation is not a general updater.
|
||||||
|
|
||||||
|
Tool jobs survive browser navigation, reload, and logout. The active management
|
||||||
|
server owns the queue through `$UVM_BASE/tool-worker.lock`. After server restart,
|
||||||
|
queued work resumes; in-flight jobs become interrupted with
|
||||||
|
`outcome_unknown: true`. The last command may still finish inside the guest.
|
||||||
|
Refresh inventory before retrying; a guest-side lock blocks overlapping installs.
|
||||||
|
Stopping/restarting the VM also interrupts a running batch. Successful tools and
|
||||||
|
the VM's private disk remain available. Deleting the VM removes its tool history.
|
||||||
|
|
||||||
|
Use versioned Python commands, for example `python3.12 -m venv .venv`. For Conda
|
||||||
|
or micromamba activation in a Nest terminal, initialize the current POSIX shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
eval "$(conda shell.posix hook)"
|
||||||
|
conda create -n myenv python=3.13
|
||||||
|
conda activate myenv
|
||||||
|
|
||||||
|
# In another shell, for micromamba:
|
||||||
|
eval "$(micromamba shell hook --shell posix)"
|
||||||
|
micromamba create -n myenv -c conda-forge python=3.13
|
||||||
|
micromamba activate myenv
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer also adds login-shell profiles under `/etc/profile.d`. See
|
||||||
|
[the catalog](README.md#one-click-guest-tools) for default versions and sources.
|
||||||
|
|
||||||
### Start, Restart, Stop, and Delete
|
### Start, Restart, Stop, and Delete
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -1587,6 +1701,21 @@ exercise real child processes with fake cgroup scopes, local Unix-socket protoco
|
|||||||
fixtures, and temporary ext4 upgrades; they do not prove the real vsock/cgroup
|
fixtures, and temporary ext4 upgrades; they do not prove the real vsock/cgroup
|
||||||
path inside a VM.
|
path inside a VM.
|
||||||
|
|
||||||
|
The tools smoke test installs the full catalog in a prepared **disposable Ubuntu
|
||||||
|
22.04 VM with a non-root login and no open TCP ports**. It downloads packages,
|
||||||
|
checks all runtimes, compiles Java, creates Python venvs, and creates/activates
|
||||||
|
temporary Conda and micromamba environments as the guest user. Run it separately
|
||||||
|
on x86_64 and ARM64 hosts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UVM_TOOLS_INTEGRATION=1 UVM_TOOLS_VM_ID='<disposable-vm-id>' \
|
||||||
|
python3 -B -m unittest discover -s tests/integration -p test_guest_tools.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `UVM_API_URL` and `UVM_API_TOKEN` as above. The test is opt-in and was not run
|
||||||
|
against a real microVM during this implementation. Automated API and browser
|
||||||
|
checks use simulated guest execution and real job persistence/workers.
|
||||||
|
|
||||||
## 19. Get Help
|
## 19. Get Help
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.tool-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||||
|
.tool-choice { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: #ffffff75; cursor: pointer; }
|
||||||
|
.tool-choice:hover { background: #fff; }
|
||||||
|
.tool-choice.is-selected { background: var(--yellow-soft); border-color: #ded09a; }
|
||||||
|
.tool-choice > span { display: grid; gap: 2px; min-width: 0; }
|
||||||
|
.tool-choice strong { font-size: 11px; }
|
||||||
|
.tool-choice small { color: var(--muted); font-size: 9px; }
|
||||||
|
.tool-choice input, .tool-card input { flex: 0 0 auto; }
|
||||||
|
.tools-section-heading { margin-block: 7px 15px; }
|
||||||
|
.tools-section-heading .button-row { justify-content: flex-end; }
|
||||||
|
.tools-section-heading .button-row > span { font-size: 10px; }
|
||||||
|
.tools-note { min-height: 22px; margin-bottom: 12px; }
|
||||||
|
.tools-error { margin-bottom: 12px; }
|
||||||
|
.tools-error:empty { display: none; }
|
||||||
|
.tools-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.tool-card { display: flex; flex-direction: column; min-width: 0; padding: 18px; border-radius: 18px; border: 1px solid #ffffffbd; background: #faf9fcbf; box-shadow: var(--shadow-card); }
|
||||||
|
.tool-card-heading { display: flex; gap: 11px; align-items: center; cursor: pointer; }
|
||||||
|
.tool-card-heading > span { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.tool-card-heading strong { font-size: 14px; letter-spacing: -.02em; }
|
||||||
|
.tool-card-heading small { font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-card > p { margin: 12px 0 5px; color: var(--muted); font-size: 10px; }
|
||||||
|
.tool-source { font-size: 8px; color: var(--faint); margin-bottom: 17px; }
|
||||||
|
.tool-card-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: auto; }
|
||||||
|
.tool-installed { border-color: #b8c6ae70; }
|
||||||
|
.tool-installed button:disabled { cursor: default; background: var(--sage-soft); color: #64705c; opacity: 1; }
|
||||||
|
.tool-state { font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-state-good { color: #68765c; }
|
||||||
|
.tool-state-error { color: #a45f57; }
|
||||||
|
.tool-state-active { color: #827037; }
|
||||||
|
.tool-state-active::before { content: ''; display: inline-block; width: 5px; height: 5px; margin-right: 5px; border-radius: 50%; background: var(--yellow); animation: tool-pulse 1.5s ease-in-out infinite; }
|
||||||
|
.tool-usage { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 9px; color: var(--muted); }
|
||||||
|
.tool-usage summary { cursor: pointer; }
|
||||||
|
.tool-usage code { display: block; margin: 9px 0; font-family: var(--mono); font-size: 9px; overflow-wrap: anywhere; }
|
||||||
|
.tool-job { margin-top: 22px; padding: 22px; }
|
||||||
|
.tool-job .section-heading { flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.tool-job .select-wrap { max-width: 100%; }
|
||||||
|
.tool-job select { max-width: 100%; }
|
||||||
|
.tool-job-message { color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
|
||||||
|
.tool-progress { display: block; width: 100%; height: 5px; margin: 15px 0; border: none; border-radius: 10px; background: #e7e2eb; accent-color: #b8ad69; }
|
||||||
|
.tool-progress::-webkit-progress-bar { background: #e7e2eb; border-radius: 10px; }
|
||||||
|
.tool-progress::-webkit-progress-value { background: #c3b875; border-radius: 10px; }
|
||||||
|
.tool-progress::-moz-progress-bar { background: #c3b875; border-radius: 10px; }
|
||||||
|
.tool-job-items { padding: 0; margin: 0; list-style: none; }
|
||||||
|
.tool-job-items > li { padding: 10px 0; border-top: 1px solid var(--line); }
|
||||||
|
.tool-job-items summary { cursor: pointer; font-size: 11px; }
|
||||||
|
.tool-job-items summary > .tool-state { float: right; margin-left: 12px; }
|
||||||
|
.tool-job-items .form-error { margin-top: 10px; }
|
||||||
|
.tool-output { max-height: 240px; overflow: auto; margin: 10px 0 0; padding: 15px; border-radius: 12px; background: #34313c; color: #ede8f1; white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/1.7 var(--mono); }
|
||||||
|
.tools-recovery { margin-top: 16px; }
|
||||||
|
@keyframes tool-pulse { 50% { opacity: .35; } }
|
||||||
|
@media (max-width: 1250px) { .tools-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.tools-section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.tools-section-heading .button-row { justify-content: flex-start; }
|
||||||
|
.tools-grid { grid-template-columns: 1fr; }
|
||||||
|
.tool-card { padding: 16px; }
|
||||||
|
.tool-job { padding: 16px; }
|
||||||
|
.tool-choice { padding: 10px; gap: 8px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tool-state-active::before { animation: none; } }
|
||||||
@@ -11,11 +11,13 @@
|
|||||||
<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" />
|
||||||
|
<link rel="stylesheet" href="/dashboard/static/css/tools.css?v=1" />
|
||||||
<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/tools.js?v=1" defer></script>
|
||||||
|
<script src="/dashboard/static/js/dashboard.js?v=6" 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">
|
||||||
@@ -178,6 +180,7 @@
|
|||||||
<div id="detail-error" class="notice notice-error" hidden></div>
|
<div id="detail-error" class="notice notice-error" hidden></div>
|
||||||
<nav class="machine-tabs" aria-label="Selected machine views">
|
<nav class="machine-tabs" aria-label="Selected machine views">
|
||||||
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
|
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
|
||||||
|
<a id="vm-tools-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-download"/></svg>Tools</a>
|
||||||
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
|
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="vm-overview-panel">
|
<div id="vm-overview-panel">
|
||||||
@@ -197,6 +200,19 @@
|
|||||||
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
|
||||||
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<section id="vm-tools-panel" aria-label="Selected machine tools" hidden>
|
||||||
|
<header class="section-heading tools-section-heading"><div><h2>Tools for your next idea</h2><span>One click. Ready inside this machine.</span></div><div class="button-row"><span id="tools-selected-count" class="muted">0 selected</span><button id="tools-install-selected" class="button button-primary" type="button" disabled>Install selected</button><button id="tools-refresh" class="icon-button" type="button" aria-label="Check installed tools" title="Check installed tools"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header>
|
||||||
|
<p id="tools-note" class="panel-footnote tools-note" role="status"></p>
|
||||||
|
<p id="tools-error" class="form-error tools-error" role="alert"></p>
|
||||||
|
<div id="tools-list" class="tools-grid"></div>
|
||||||
|
<article id="tools-job" class="soft-panel tool-job" hidden>
|
||||||
|
<header class="section-heading"><h3 id="tools-job-title">Installation</h3><label class="select-wrap"><span class="sr-only">Installation history</span><select id="tools-job-select"></select></label></header>
|
||||||
|
<p id="tools-job-message" class="tool-job-message" role="status"></p>
|
||||||
|
<progress id="tools-progress" class="tool-progress" max="1" value="0" aria-label="Installation progress"></progress>
|
||||||
|
<ol id="tools-job-items" class="tool-job-items"></ol>
|
||||||
|
</article>
|
||||||
|
<div class="button-row tools-recovery"><button id="tools-retry" class="button button-small" type="button" hidden>Retry unfinished tools</button><button id="tools-restart" class="button button-small" type="button" hidden>Restart & install</button></div>
|
||||||
|
</section>
|
||||||
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
|
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
|
||||||
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
|
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
|
||||||
<p id="sessions-error" class="form-error" role="alert"></p>
|
<p id="sessions-error" class="form-error" role="alert"></p>
|
||||||
@@ -207,7 +223,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>
|
||||||
@@ -229,7 +245,8 @@
|
|||||||
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
||||||
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
|
||||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section>
|
||||||
|
<section class="soft-panel form-section create-tools-section"><header class="section-heading"><div><h2><span class="step-number">04</span>Pick your tools</h2><span>Optional. Install automatically after your machine boots.</span></div><button id="create-tools-refresh" class="icon-button" type="button" aria-label="Reload tool catalog"><svg class="icon"><use href="#i-refresh"/></svg></button></header><div id="create-tools-list" class="tool-choices"><span class="muted">Loading available tools…</span></div><p id="create-tools-error" class="form-error" role="alert"></p><p class="panel-footnote">Python versions can coexist. You can add more tools later from the machine’s Tools tab.</p></section></div>
|
||||||
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -246,6 +263,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>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@
|
|||||||
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
||||||
esc, formatDate, toast, confirmAction,
|
esc, formatDate, toast, confirmAction,
|
||||||
});
|
});
|
||||||
|
const tools = new window.NestTools({
|
||||||
|
api, esc, toast, formatDate,
|
||||||
|
onSelectionChange: updateCreateSummary,
|
||||||
|
onMutation: refreshAfterMutation,
|
||||||
|
});
|
||||||
|
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||||
@@ -170,7 +175,7 @@
|
|||||||
return `The server returned HTTP ${status}.`;
|
return `The server returned HTTP ${status}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, { method = 'GET', body } = {}) {
|
async function api(path, { method = 'GET', body, timeout = 20000 } = {}) {
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
state.controllers.add(controller);
|
state.controllers.add(controller);
|
||||||
@@ -179,7 +184,7 @@
|
|||||||
const timer = method === 'GET' ? window.setTimeout(() => {
|
const timer = method === 'GET' ? window.setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, 20000) : null;
|
}, timeout) : null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
@@ -612,17 +617,21 @@
|
|||||||
: actionButton('start', 'Start machine', 'play', true))
|
: actionButton('start', 'Start machine', 'play', true))
|
||||||
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
||||||
const showingSessions = state.detailTab === 'sessions';
|
const showingSessions = state.detailTab === 'sessions';
|
||||||
$('#vm-overview-panel').hidden = showingSessions;
|
const showingTools = state.detailTab === 'tools';
|
||||||
|
$('#vm-overview-panel').hidden = showingSessions || showingTools;
|
||||||
$('#vm-sessions-panel').hidden = !showingSessions;
|
$('#vm-sessions-panel').hidden = !showingSessions;
|
||||||
|
$('#vm-tools-panel').hidden = !showingTools;
|
||||||
$('#vm-overview-tab').href = vmHref(vm.id);
|
$('#vm-overview-tab').href = vmHref(vm.id);
|
||||||
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
||||||
[['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => {
|
$('#vm-tools-tab').href = `${vmHref(vm.id)}/tools`;
|
||||||
|
[['#vm-overview-tab', !showingSessions && !showingTools], ['#vm-sessions-tab', showingSessions], ['#vm-tools-tab', showingTools]].forEach(([selector, active]) => {
|
||||||
$(selector).classList.toggle('active', active);
|
$(selector).classList.toggle('active', active);
|
||||||
if (active) $(selector).setAttribute('aria-current', 'page');
|
if (active) $(selector).setAttribute('aria-current', 'page');
|
||||||
else $(selector).removeAttribute('aria-current');
|
else $(selector).removeAttribute('aria-current');
|
||||||
});
|
});
|
||||||
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`);
|
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : showingTools ? ' / Tools' : ''}`);
|
||||||
sessions.sync(vm, state.terminalId, showingSessions);
|
sessions.sync(vm, state.terminalId, showingSessions);
|
||||||
|
tools.sync(vm, showingTools);
|
||||||
html('#detail-config', detailRows([
|
html('#detail-config', detailRows([
|
||||||
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
||||||
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
||||||
@@ -685,6 +694,7 @@
|
|||||||
$('#detail-error').hidden = false;
|
$('#detail-error').hidden = false;
|
||||||
} else {
|
} else {
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
$('#detail-content').hidden = true;
|
$('#detail-content').hidden = true;
|
||||||
$('#detail-loading').hidden = false;
|
$('#detail-loading').hidden = false;
|
||||||
@@ -927,6 +937,7 @@
|
|||||||
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
|
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
|
||||||
['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
|
['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
|
||||||
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
|
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
|
||||||
|
['Tools', tools.createSummary()],
|
||||||
]));
|
]));
|
||||||
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
||||||
let mib = null;
|
let mib = null;
|
||||||
@@ -966,6 +977,7 @@
|
|||||||
password: $('#create-password').value,
|
password: $('#create-password').value,
|
||||||
ports: parsePorts($('#create-ports').value),
|
ports: parsePorts($('#create-ports').value),
|
||||||
no_default_ports: !$('#create-default-ssh').checked,
|
no_default_ports: !$('#create-default-ssh').checked,
|
||||||
|
tools: tools.createSelections(),
|
||||||
};
|
};
|
||||||
if (!payload.password) throw new Error('Add a password for the guest account.');
|
if (!payload.password) throw new Error('Add a password for the guest account.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -977,22 +989,25 @@
|
|||||||
const previous = button.innerHTML;
|
const previous = button.innerHTML;
|
||||||
const unlock = lockForm($('#create-form'));
|
const unlock = lockForm($('#create-form'));
|
||||||
state.busy.add('create');
|
state.busy.add('create');
|
||||||
|
tools.setCreateBusy(true);
|
||||||
button.textContent = 'Creating your machine…';
|
button.textContent = 'Creating your machine…';
|
||||||
try {
|
try {
|
||||||
const vm = await api('/vms', { method: 'POST', body: payload });
|
const vm = await api('/vms', { method: 'POST', body: payload });
|
||||||
if (session !== state.session) return;
|
if (session !== state.session) return;
|
||||||
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
|
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
|
||||||
$('#create-form').reset();
|
$('#create-form').reset();
|
||||||
|
tools.resetCreate();
|
||||||
resetPasswordVisibility();
|
resetPasswordVisibility();
|
||||||
updateCreateSummary();
|
updateCreateSummary();
|
||||||
toast('A new little machine, ready for you.');
|
toast(payload.tools.length ? 'Machine created. Your selected tools are queued for installation.' : 'A new little machine, ready for you.');
|
||||||
location.hash = vmHref(vm.id);
|
location.hash = vmHref(vm.id) + (payload.tools.length ? '/tools' : '');
|
||||||
await loadVms();
|
await loadVms();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
|
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
button.innerHTML = previous;
|
button.innerHTML = previous;
|
||||||
|
tools.setCreateBusy(false);
|
||||||
if (session === state.session) state.busy.delete('create');
|
if (session === state.session) state.busy.delete('create');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1166,8 @@
|
|||||||
if (parts[2] === 'sessions' && parts.length <= 4) {
|
if (parts[2] === 'sessions' && parts.length <= 4) {
|
||||||
detailTab = 'sessions';
|
detailTab = 'sessions';
|
||||||
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
||||||
|
} else if (parts[2] === 'tools' && parts.length === 3) {
|
||||||
|
detailTab = 'tools';
|
||||||
} else if (parts.length !== 2) identifier = null;
|
} else if (parts.length !== 2) identifier = null;
|
||||||
} catch { identifier = null; }
|
} catch { identifier = null; }
|
||||||
value = identifier ? 'vm-detail' : 'overview';
|
value = identifier ? 'vm-detail' : 'overview';
|
||||||
@@ -1164,6 +1181,7 @@
|
|||||||
state.detailTab = detailTab;
|
state.detailTab = detailTab;
|
||||||
state.terminalId = terminalId;
|
state.terminalId = terminalId;
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.leave();
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
state.portEditing = false;
|
state.portEditing = false;
|
||||||
state.resizeEditing = false;
|
state.resizeEditing = false;
|
||||||
@@ -1192,7 +1210,7 @@
|
|||||||
loadDetail(identifier);
|
loadDetail(identifier);
|
||||||
}
|
}
|
||||||
if (value === 'system') loadDiagnostics();
|
if (value === 'system') loadDiagnostics();
|
||||||
if (value === 'create') updateCreateSummary();
|
if (value === 'create') { updateCreateSummary(); tools.mountCreate(); }
|
||||||
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
|
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1271,7 @@
|
|||||||
|
|
||||||
function logout(message = '') {
|
function logout(message = '') {
|
||||||
sessions.leave();
|
sessions.leave();
|
||||||
|
tools.reset();
|
||||||
state.session += 1;
|
state.session += 1;
|
||||||
state.controllers.forEach((controller) => controller.abort());
|
state.controllers.forEach((controller) => controller.abort());
|
||||||
state.controllers.clear();
|
state.controllers.clear();
|
||||||
@@ -1428,7 +1447,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;
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const active = (job) => ['queued', 'starting', 'waiting', 'installing'].includes(job?.status);
|
||||||
|
const retryable = (item) => ['failed', 'interrupted', 'skipped'].includes(item.status);
|
||||||
|
const labels = { queued: 'Queued', starting: 'Starting machine', waiting: 'Waiting for guest', installing: 'Installing',
|
||||||
|
installed: 'Installed', already_installed: 'Already installed', succeeded: 'Complete', failed: 'Failed',
|
||||||
|
interrupted: 'Interrupted', skipped: 'Not attempted' };
|
||||||
|
|
||||||
|
window.NestTools = class NestTools {
|
||||||
|
constructor({ api, esc, toast, formatDate, onSelectionChange, onMutation }) {
|
||||||
|
Object.assign(this, { api, esc, toast, formatDate, onSelectionChange, onMutation });
|
||||||
|
this.catalog = null;
|
||||||
|
this.catalogPromise = null;
|
||||||
|
this.epoch = 0;
|
||||||
|
this.generation = 0;
|
||||||
|
this.createSelected = new Set();
|
||||||
|
this.selected = new Set();
|
||||||
|
this.cache = new WeakMap();
|
||||||
|
this.createBusy = false;
|
||||||
|
this.vm = null;
|
||||||
|
this.data = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.submitting = false;
|
||||||
|
this.revision = 0;
|
||||||
|
this.probed = false;
|
||||||
|
this.lastProbe = 0;
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
$('#create-tools-list').addEventListener('change', (event) => {
|
||||||
|
const input = event.target.closest('[data-create-tool]');
|
||||||
|
if (!input) return;
|
||||||
|
if (input.checked) this.createSelected.add(input.dataset.createTool);
|
||||||
|
else this.createSelected.delete(input.dataset.createTool);
|
||||||
|
input.closest('label').classList.toggle('is-selected', input.checked);
|
||||||
|
this.onSelectionChange();
|
||||||
|
});
|
||||||
|
$('#create-tools-refresh').addEventListener('click', () => this.mountCreate());
|
||||||
|
$('#tools-refresh').addEventListener('click', () => this.load(true));
|
||||||
|
$('#tools-install-selected').addEventListener('click', () => this.submit([...this.selected]));
|
||||||
|
$('#tools-retry').addEventListener('click', () => this.submit(this.retryKeys()));
|
||||||
|
$('#tools-restart').addEventListener('click', () => this.submit(this.selected.size ? [...this.selected] : this.retryKeys(), true));
|
||||||
|
$('#tools-job-select').addEventListener('change', (event) => this.viewJob(event.target.value));
|
||||||
|
$('#tools-list').addEventListener('change', (event) => {
|
||||||
|
const input = event.target.closest('[data-tool-select]');
|
||||||
|
if (!input) return;
|
||||||
|
if (input.checked) this.selected.add(input.dataset.toolSelect);
|
||||||
|
else this.selected.delete(input.dataset.toolSelect);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
$('#tools-list').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-install-tool]');
|
||||||
|
if (button && !button.disabled) this.submit([button.dataset.installTool]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html(selector, value) {
|
||||||
|
const element = $(selector);
|
||||||
|
if (this.cache.get(element) !== value) {
|
||||||
|
element.innerHTML = value;
|
||||||
|
this.cache.set(element, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variants() {
|
||||||
|
return (this.catalog || []).flatMap((tool) => (tool.versions.length ? tool.versions : [null]).map((version) => ({
|
||||||
|
...tool, version, key: version ? `${tool.id}-${version}` : tool.id,
|
||||||
|
title: version ? `${tool.name} ${version}` : tool.name,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCatalog() {
|
||||||
|
if (this.catalog) return this.catalog;
|
||||||
|
if (this.catalogPromise) return this.catalogPromise;
|
||||||
|
const epoch = this.epoch;
|
||||||
|
const request = this.api('/tools').then((catalog) => {
|
||||||
|
if (!Array.isArray(catalog) || catalog.some((tool) => !tool.id || !Array.isArray(tool.versions))) {
|
||||||
|
throw new Error('The tool catalog response was not recognized.');
|
||||||
|
}
|
||||||
|
if (epoch === this.epoch) this.catalog = catalog;
|
||||||
|
return catalog;
|
||||||
|
}).finally(() => { if (this.catalogPromise === request) this.catalogPromise = null; });
|
||||||
|
this.catalogPromise = request;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountCreate() {
|
||||||
|
const epoch = this.epoch;
|
||||||
|
$('#create-tools-error').textContent = '';
|
||||||
|
try {
|
||||||
|
await this.loadCatalog();
|
||||||
|
if (epoch !== this.epoch) return;
|
||||||
|
this.html('#create-tools-list', this.variants().map((tool) => `<label class="tool-choice${this.createSelected.has(tool.key) ? ' is-selected' : ''}">
|
||||||
|
<input type="checkbox" data-create-tool="${this.esc(tool.key)}" ${this.createSelected.has(tool.key) ? 'checked' : ''} ${this.createBusy ? 'disabled' : ''} />
|
||||||
|
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? 'pip + venv' : tool.version_label)}</small></span></label>`).join(''));
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch === this.epoch && error.name !== 'AbortError') $('#create-tools-error').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSelections() {
|
||||||
|
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map(({ id, version }) => ({ id, version }));
|
||||||
|
}
|
||||||
|
|
||||||
|
createSummary() {
|
||||||
|
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map((tool) => tool.title).join(', ') || 'None selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCreate() {
|
||||||
|
this.createSelected.clear();
|
||||||
|
document.querySelectorAll('[data-create-tool]').forEach((input) => {
|
||||||
|
input.checked = false;
|
||||||
|
input.closest('label').classList.remove('is-selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateBusy(busy) {
|
||||||
|
this.createBusy = busy;
|
||||||
|
document.querySelectorAll('[data-create-tool]').forEach((input) => { input.disabled = busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(vm, visible) {
|
||||||
|
if (!visible) return;
|
||||||
|
if (this.vm?.id !== vm.id) {
|
||||||
|
this.leave();
|
||||||
|
this.vm = vm;
|
||||||
|
this.html('#tools-list', '<div class="loading-state">Bringing your tools into view…</div>');
|
||||||
|
$('#tools-job').hidden = true;
|
||||||
|
$('#tools-error').textContent = '';
|
||||||
|
this.load();
|
||||||
|
} else {
|
||||||
|
const started = this.vm.observed_status !== 'running' && vm.observed_status === 'running';
|
||||||
|
this.vm = vm;
|
||||||
|
this.render();
|
||||||
|
if (started && !this.loading) this.load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leave() {
|
||||||
|
this.generation += 1;
|
||||||
|
window.clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.vm = null;
|
||||||
|
this.data = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.submitting = false;
|
||||||
|
this.probed = false;
|
||||||
|
this.selected.clear();
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.leave();
|
||||||
|
this.epoch += 1;
|
||||||
|
this.catalog = null;
|
||||||
|
this.catalogPromise = null;
|
||||||
|
this.resetCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
window.clearTimeout(this.timer);
|
||||||
|
if (!this.vm) return;
|
||||||
|
this.timer = window.setTimeout(() => {
|
||||||
|
if (document.hidden) { this.schedule(); return; }
|
||||||
|
this.load(Boolean(this.data?.guest_busy && Date.now() - this.lastProbe > 20000));
|
||||||
|
}, active(this.data?.latest_job) ? 1500 : 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(refresh = false) {
|
||||||
|
if (!this.vm || this.loading) return;
|
||||||
|
const id = this.vm.id;
|
||||||
|
const generation = this.generation;
|
||||||
|
const revision = this.revision;
|
||||||
|
const wasActive = active(this.data?.latest_job);
|
||||||
|
this.loading = true;
|
||||||
|
if (refresh) { this.probed = true; this.lastProbe = Date.now(); }
|
||||||
|
$('#tools-refresh').disabled = true;
|
||||||
|
if (refresh) $('#tools-note').textContent = 'Checking installed tools inside this machine…';
|
||||||
|
let probeNext = false;
|
||||||
|
try {
|
||||||
|
const [, data] = await Promise.all([this.loadCatalog(), this.api(`/vms/${encodeURIComponent(id)}/tools${refresh ? '?refresh=true' : ''}`, { timeout: 100000 })]);
|
||||||
|
if (generation !== this.generation || revision !== this.revision) return;
|
||||||
|
this.data = data;
|
||||||
|
if (!data?.vm_id || data.vm_id !== id) throw new Error('The tool status response was not recognized.');
|
||||||
|
if (this.viewing && !data.jobs.some((job) => job.id === this.viewing)) { this.viewing = null; this.historyJob = null; }
|
||||||
|
this.selected = new Set([...this.selected].filter((key) => !data.inventory.some((item) => item.key === key && item.installed)));
|
||||||
|
$('#tools-error').textContent = data.error || '';
|
||||||
|
probeNext = !active(data.latest_job) && this.vm.observed_status === 'running'
|
||||||
|
&& ((wasActive && !refresh) || (!this.probed && data.stale));
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.loading = false;
|
||||||
|
$('#tools-refresh').disabled = false;
|
||||||
|
if (probeNext) this.load(true);
|
||||||
|
else this.schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retryKeys() {
|
||||||
|
return (this.data?.latest_job?.items || []).filter(retryable).map((item) => item.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
canInstall() {
|
||||||
|
const status = this.vm?.observed_status || this.vm?.status;
|
||||||
|
return ['running', 'stopped'].includes(status) && !this.vm.operation && !this.vm.pending_resize
|
||||||
|
&& (status !== 'stopped' || this.vm.ports !== null) && this.data?.supported !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.vm || !this.catalog) return;
|
||||||
|
const job = this.data?.latest_job;
|
||||||
|
const occupied = this.submitting || active(job) || this.data?.guest_busy;
|
||||||
|
const disabled = occupied || !this.canInstall();
|
||||||
|
const stopped = (this.vm.observed_status || this.vm.status) === 'stopped';
|
||||||
|
const focused = document.activeElement?.dataset.toolSelect;
|
||||||
|
const inventory = new Map((this.data?.inventory || []).map((item) => [item.key, item]));
|
||||||
|
const items = new Map((job?.items || []).map((item) => [item.key, item]));
|
||||||
|
this.html('#tools-list', this.variants().map((tool) => {
|
||||||
|
const found = inventory.get(tool.key);
|
||||||
|
const item = items.get(tool.key);
|
||||||
|
const installing = active(job) && item && ['queued', 'installing'].includes(item.status);
|
||||||
|
const installed = found?.installed && !installing;
|
||||||
|
const failed = item && retryable(item) && !installed && !installing;
|
||||||
|
const state = installing ? labels[item.status] : installed ? `Installed · ${found.detected_version}` : failed ? labels[item.status]
|
||||||
|
: found?.detected_version ? `Found ${found.detected_version}` : found ? 'Available' : 'Not checked yet';
|
||||||
|
const caption = stopped ? 'Start & install' : failed ? 'Retry' : 'Install';
|
||||||
|
return `<article class="tool-card${installed ? ' tool-installed' : ''}">
|
||||||
|
<label class="tool-card-heading"><input type="checkbox" data-tool-select="${this.esc(tool.key)}" aria-label="Select ${this.esc(tool.title)}" ${this.selected.has(tool.key) ? 'checked' : ''} ${disabled || installed ? 'disabled' : ''} />
|
||||||
|
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? `Python ${tool.version} · pip + venv` : tool.version_label)}</small></span></label>
|
||||||
|
<p>${this.esc(tool.description)}</p><small class="tool-source">${this.esc(tool.source)}</small>
|
||||||
|
<div class="tool-card-footer"><span class="tool-state ${installed ? 'tool-state-good' : failed ? 'tool-state-error' : installing ? 'tool-state-active' : ''}">${this.esc(state)}</span>
|
||||||
|
<button class="button button-small" type="button" data-install-tool="${this.esc(tool.key)}" ${disabled || installed ? 'disabled' : ''}>${installed ? 'Installed' : this.esc(caption)}</button></div>
|
||||||
|
${installed && found.usage ? `<details class="tool-usage"><summary>Get started</summary><code>${this.esc(found.usage)}</code><small>Run these commands in this machine’s terminal.</small></details>` : ''}</article>`;
|
||||||
|
}).join(''));
|
||||||
|
if (focused) document.querySelector(`[data-tool-select="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true });
|
||||||
|
$('#tools-selected-count').textContent = `${this.selected.size} selected`;
|
||||||
|
$('#tools-install-selected').textContent = this.submitting ? 'Queuing…' : stopped ? 'Start & install selected' : 'Install selected';
|
||||||
|
$('#tools-install-selected').disabled = disabled || !this.selected.size;
|
||||||
|
$('#tools-retry').hidden = !this.retryKeys().length || active(job);
|
||||||
|
$('#tools-retry').disabled = disabled;
|
||||||
|
$('#tools-restart').hidden = stopped || !this.data?.error || this.data.agent_ready || active(job);
|
||||||
|
$('#tools-restart').disabled = disabled || (!this.selected.size && !this.retryKeys().length);
|
||||||
|
$('#tools-note').textContent = this.data?.guest_busy
|
||||||
|
? 'An installation is still running inside the guest. Its status will be checked again shortly.'
|
||||||
|
: stopped ? 'Start & install boots this machine and installs your selected tools. Versions shown are last observed.'
|
||||||
|
: this.data?.checked_at ? `Last checked ${this.formatDate(this.data.checked_at)}${this.data.stale ? ' · cached observations' : ''}. Use Get started for commands and shell initialization.`
|
||||||
|
: 'Tools are installed directly inside this VM. Guest internet access is required for downloads.';
|
||||||
|
this.renderJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJob() {
|
||||||
|
const job = this.historyJob || this.data?.latest_job;
|
||||||
|
$('#tools-job').hidden = !job;
|
||||||
|
if (!job) return;
|
||||||
|
$('#tools-job-title').textContent = labels[job.status] || job.status;
|
||||||
|
$('#tools-job-message').textContent = job.error || (active(job) ? 'You can leave this page. Installation continues in the background.' : 'Tool installation results are saved with this machine.');
|
||||||
|
const complete = job.items.filter((item) => !['queued', 'installing'].includes(item.status)).length;
|
||||||
|
$('#tools-progress').max = job.items.length;
|
||||||
|
$('#tools-progress').value = complete;
|
||||||
|
$('#tools-progress').setAttribute('aria-label', `${complete} of ${job.items.length} tools processed`);
|
||||||
|
const names = new Map(this.variants().map((tool) => [tool.key, tool.title]));
|
||||||
|
const open = new Set([...document.querySelectorAll('#tools-job-items details[open]')].map((item) => item.dataset.output));
|
||||||
|
this.html('#tools-job-items', job.items.map((item) => `<li><details data-output="${this.esc(item.key)}" ${open.has(item.key) ? 'open' : ''}>
|
||||||
|
<summary><strong>${this.esc(names.get(item.key) || item.key)}</strong><span class="tool-state ${retryable(item) ? 'tool-state-error' : ''}">${this.esc(labels[item.status] || item.status)}</span></summary>
|
||||||
|
${item.error ? `<p class="form-error">${this.esc(item.error)}</p>` : ''}<pre class="tool-output" tabindex="0">${this.esc(item.output || 'No output recorded.')}</pre>
|
||||||
|
${item.truncated ? '<p class="panel-footnote">Showing the bounded tail of installation output.</p>' : ''}</details></li>`).join(''));
|
||||||
|
this.html('#tools-job-select', (this.data.jobs || []).map((item) => `<option value="${this.esc(item.id)}">${this.esc(this.formatDate(item.created_at))} · ${this.esc(labels[item.status] || item.status)}</option>`).join(''));
|
||||||
|
$('#tools-job-select').value = job.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewJob(id) {
|
||||||
|
if (!this.vm) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
this.viewing = id === this.data?.latest_job?.id ? null : id;
|
||||||
|
this.historyJob = null;
|
||||||
|
if (!this.viewing) { this.renderJob(); return; }
|
||||||
|
try {
|
||||||
|
const job = await this.api(`/vms/${encodeURIComponent(this.vm.id)}/tools/jobs/${encodeURIComponent(id)}`);
|
||||||
|
if (generation !== this.generation || this.viewing !== id) return;
|
||||||
|
this.historyJob = job;
|
||||||
|
this.renderJob();
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') this.toast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(keys, restart = false) {
|
||||||
|
if (!this.vm || this.submitting || active(this.data?.latest_job) || !this.canInstall()) return;
|
||||||
|
const tools = this.variants().filter((tool) => keys.includes(tool.key)).map(({ id, version }) => ({ id, version }));
|
||||||
|
if (!tools.length) return;
|
||||||
|
const id = this.vm.id;
|
||||||
|
const generation = this.generation;
|
||||||
|
const payload = { tools, start_if_stopped: true, restart };
|
||||||
|
const signature = JSON.stringify(payload);
|
||||||
|
if (this.pendingRequest?.signature !== signature) this.pendingRequest = { signature, id: crypto.randomUUID() };
|
||||||
|
payload.request_id = this.pendingRequest.id;
|
||||||
|
this.submitting = true;
|
||||||
|
this.revision += 1;
|
||||||
|
$('#tools-error').textContent = '';
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
const job = await this.api(`/vms/${encodeURIComponent(id)}/tools/install`, { method: 'POST', body: payload });
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.pendingRequest = null;
|
||||||
|
this.selected.clear();
|
||||||
|
this.viewing = null;
|
||||||
|
this.historyJob = null;
|
||||||
|
if (this.data) { this.data.latest_job = job; this.data.jobs = [{ id: job.id, status: job.status, created_at: job.created_at }, ...this.data.jobs.filter((item) => item.id !== job.id)]; }
|
||||||
|
this.toast('Tool installation queued. You can follow its progress here.');
|
||||||
|
this.onMutation(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (generation === this.generation) {
|
||||||
|
this.submitting = false;
|
||||||
|
this.render();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
+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);
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Browser fixture with the real API/job workers and simulated guest execution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from nest.server import create_app
|
||||||
|
from tests.browser_session_server import BrowserLifecycle
|
||||||
|
from uvm.config import Settings
|
||||||
|
from uvm.domain import new_vm_id
|
||||||
|
from uvm.errors import ConflictError, GuestExecutionError
|
||||||
|
from uvm.guest_exec import ExecResult
|
||||||
|
from uvm.state import StateStore
|
||||||
|
from uvm.tool_store import ToolJobStore
|
||||||
|
from uvm.tools import ToolService
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = threading.Event()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed.set()
|
||||||
|
|
||||||
|
|
||||||
|
class ToolBrowserLifecycle(BrowserLifecycle):
|
||||||
|
def __init__(self, settings, state, *, delay=1.2):
|
||||||
|
super().__init__()
|
||||||
|
self.settings, self.state, self.delay = settings, state, delay
|
||||||
|
self.tools = ToolJobStore(settings, state)
|
||||||
|
self.installed = {}
|
||||||
|
self.attempts = {}
|
||||||
|
for index, vm in enumerate(self.records.values()):
|
||||||
|
vm.runtime_owned = True
|
||||||
|
vm.pid, vm.process_start_time, vm.boot_id = index + 100, "1", "fixture-boot"
|
||||||
|
settings.vm_dir(vm.id).mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||||
|
with state.transaction() as document:
|
||||||
|
document.vms.update(self.records)
|
||||||
|
|
||||||
|
def create(self, spec):
|
||||||
|
vm = replace(self.vm, id=new_vm_id(), name=spec.name, username=spec.username, cpu=spec.cpu,
|
||||||
|
ram_mib=spec.ram_mib, storage_mib=spec.storage_mib, ports=spec.ports)
|
||||||
|
with self.state.operation_lock():
|
||||||
|
self.settings.vm_dir(vm.id).mkdir(mode=0o700)
|
||||||
|
self.records[vm.id] = vm
|
||||||
|
with self.state.transaction() as document:
|
||||||
|
document.vms[vm.id] = vm
|
||||||
|
if spec.tools:
|
||||||
|
self.tools.enqueue_locked(vm.id, spec.tools, source="create")
|
||||||
|
return vm
|
||||||
|
|
||||||
|
def queue_tools(self, vm_id, selections, **options):
|
||||||
|
self.state.get(vm_id)
|
||||||
|
with self.state.operation_lock():
|
||||||
|
return self.tools.enqueue_locked(vm_id, selections, **options)
|
||||||
|
|
||||||
|
def prepare_tool_install(self, vm_id, *, start_if_stopped=False, restart=False):
|
||||||
|
with self.state.operation_lock():
|
||||||
|
vm = self.records[vm_id]
|
||||||
|
if vm.status == "stopped" and not (start_if_stopped or restart):
|
||||||
|
raise ConflictError("start the VM first")
|
||||||
|
vm.status = "running"
|
||||||
|
with self.state.transaction() as document:
|
||||||
|
document.vms[vm.id] = vm
|
||||||
|
return vm
|
||||||
|
|
||||||
|
def execute(self, vm_id, command, *, expected_identity=None, on_connect=None, **_options):
|
||||||
|
self.state.get(vm_id)
|
||||||
|
payload = json.loads(shlex.split(command)[-1])
|
||||||
|
connection = FixtureConnection()
|
||||||
|
if on_connect:
|
||||||
|
on_connect(connection)
|
||||||
|
installed = self.installed.setdefault(vm_id, {"curl"})
|
||||||
|
|
||||||
|
def observation(tool):
|
||||||
|
versions = {"nodejs": "24.21.0", "jdk": "21.0.12", "php": "8.5.11", "conda": "26.5.3", "micromamba": "2.9.0"}
|
||||||
|
version = tool["version"] + ".15" if tool["id"] == "python" else versions.get(tool["id"], "2.0.0")
|
||||||
|
return {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": tool["key"] in installed,
|
||||||
|
"detected_version": version if tool["key"] in installed else None, "error": None, "usage": tool["usage"]}
|
||||||
|
|
||||||
|
if payload["action"] == "probe":
|
||||||
|
result = {"ok": True, "busy": False, "boot_id": "guest-fixture", "supported": True,
|
||||||
|
"tools": [observation(tool) for tool in payload["tools"]]}
|
||||||
|
else:
|
||||||
|
if connection.closed.wait(self.delay):
|
||||||
|
raise GuestExecutionError("fixture connection closed", outcome_unknown=True)
|
||||||
|
tool = payload["tools"][0]
|
||||||
|
key = (vm_id, tool["key"])
|
||||||
|
self.attempts[key] = self.attempts.get(key, 0) + 1
|
||||||
|
if tool["id"] == "php" and self.attempts[key] == 1:
|
||||||
|
result = {"ok": False, "error": "Simulated mirror failure", "output": "<script>bad()</script>\nTry again later."}
|
||||||
|
else:
|
||||||
|
installed.add(tool["key"])
|
||||||
|
result = {"ok": True, "status": "installed", "tool": observation(tool),
|
||||||
|
"output": f"Verified and installed {tool['key']} inside the guest."}
|
||||||
|
return ExecResult(0, json.dumps(result), "", False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_application(base, *, delay=1.2):
|
||||||
|
settings = replace(Settings(), base=base, api_token="browser-test-token", tool_workers=2)
|
||||||
|
state = StateStore(settings)
|
||||||
|
lifecycle = ToolBrowserLifecycle(settings, state, delay=delay)
|
||||||
|
return SimpleNamespace(settings=settings, state_store=state, lifecycle=lifecycle,
|
||||||
|
tools=ToolService(settings, state, lifecycle))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--base", type=Path, required=True)
|
||||||
|
parser.add_argument("--port", type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
application = fixture_application(args.base)
|
||||||
|
with patch("uvm.tools.require_root"):
|
||||||
|
api = create_app(application, host="127.0.0.1")
|
||||||
|
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
|
||||||
|
api.state.uvicorn_server = server
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Optional browser integration: real API/job persistence, simulated guest commands.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { mkdtemp, rm } = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-tool-browser-'));
|
||||||
|
const port = process.env.NEST_BROWSER_TEST_PORT || '18766';
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
|
||||||
|
['-B', '-m', 'tests.browser_tool_server', '--base', temporary, '--port', port],
|
||||||
|
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let logs = '';
|
||||||
|
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||||
|
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||||
|
let browser;
|
||||||
|
let page;
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
|
||||||
|
try { if ((await fetch(`${origin}/health`)).ok) { ready = true; break; } } catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
assert.ok(ready, logs);
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const installed = (key) => page.waitForFunction((value) =>
|
||||||
|
document.querySelector(`[data-install-tool="${value}"]`)?.textContent === 'Installed', key);
|
||||||
|
await page.goto(`${origin}/dashboard#create`);
|
||||||
|
await page.locator('#token-input').fill('browser-test-token');
|
||||||
|
await page.locator('#auth-btn').click();
|
||||||
|
await page.locator('[data-create-tool="python-3.12"]').check();
|
||||||
|
await page.locator('[data-create-tool="git"]').check();
|
||||||
|
await page.locator('[data-create-tool="nodejs"]').check();
|
||||||
|
assert.equal(await page.locator('[data-create-tool]').count(), 12);
|
||||||
|
assert.match(await page.locator('#create-summary-details').textContent(), /Git, Node.js, Python 3.12/);
|
||||||
|
const layout = await page.evaluate(() => ({
|
||||||
|
parent: document.querySelector('.create-tools-section').parentElement.className,
|
||||||
|
children: document.querySelector('#create-form').children.length,
|
||||||
|
}));
|
||||||
|
assert.equal(layout.parent, 'create-fields');
|
||||||
|
assert.equal(layout.children, 2);
|
||||||
|
await page.locator('#create-name').fill('tool-workspace');
|
||||||
|
await page.locator('#create-submit').click();
|
||||||
|
await page.waitForURL((url) => /#vm\/vm-[a-f0-9]+\/tools$/.test(url.hash));
|
||||||
|
const createdUrl = page.url();
|
||||||
|
await page.locator('#tools-job').waitFor({ state: 'visible' });
|
||||||
|
// A reload can occur while the server-owned worker is still installing.
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
|
||||||
|
await installed('curl');
|
||||||
|
assert.equal(await page.locator('.tool-installed').count(), 4); // Includes image-provided curl.
|
||||||
|
assert.ok(await page.locator('[data-install-tool="python-3.12"]').isDisabled());
|
||||||
|
await page.locator('#tools-job-items details').first().locator('summary').click();
|
||||||
|
assert.match(await page.locator('#tools-job-items').textContent(), /Verified and installed git/);
|
||||||
|
await page.locator('[data-install-tool="php"]').click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Failed');
|
||||||
|
await page.locator('#tools-job-items details[data-output="php"] summary').click();
|
||||||
|
assert.match(await page.locator('.tool-output').textContent(), /<script>bad/);
|
||||||
|
assert.equal(await page.locator('#tools-job-items script').count(), 0);
|
||||||
|
await page.locator('#tools-retry').click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
|
||||||
|
await installed('php');
|
||||||
|
assert.ok(await page.locator('[data-install-tool="php"]').isDisabled());
|
||||||
|
assert.equal(await page.locator('#tools-job-select option').count(), 3);
|
||||||
|
|
||||||
|
// The same click boots a stopped VM before installing its selected tool.
|
||||||
|
await page.goto(`${origin}/dashboard#vm/vm-other/tools`);
|
||||||
|
await page.locator('[data-install-tool="htop"]').waitFor();
|
||||||
|
assert.equal(await page.locator('[data-install-tool="htop"]').textContent(), 'Start & install');
|
||||||
|
await page.locator('[data-install-tool="htop"]').click();
|
||||||
|
await installed('htop');
|
||||||
|
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
|
||||||
|
assert.ok(await page.locator('[data-install-tool="htop"]').isDisabled());
|
||||||
|
assert.ok(!(await page.locator('#tools-job-items').textContent()).includes('python-3.12'));
|
||||||
|
|
||||||
|
await page.goto(createdUrl);
|
||||||
|
await page.locator('[data-tool-select="wget"]').check();
|
||||||
|
await page.locator('[data-tool-select="micromamba"]').check();
|
||||||
|
await page.locator('#tools-install-selected').click();
|
||||||
|
await installed('wget');
|
||||||
|
await installed('micromamba');
|
||||||
|
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'mobile layout overflows');
|
||||||
|
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('Browser tools passed: creation, background/reload, detection, failure/retry, safe output, history, Start & install, VM isolation, multi-select, and mobile layout.');
|
||||||
|
} catch (error) {
|
||||||
|
if (page) console.error('Tool UI:', await page.locator('#tools-error').textContent(), await page.locator('#tools-job-message').textContent());
|
||||||
|
console.error(logs);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
server.kill('SIGTERM');
|
||||||
|
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Opt-in tool installation smoke test against a prepared, disposable microVM."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("UVM_TOOLS_INTEGRATION") == "1",
|
||||||
|
"set UVM_TOOLS_INTEGRATION=1 on an isolated disposable VM host")
|
||||||
|
class GuestToolsIntegrationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.api_url = os.environ.get("UVM_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
cls.token = os.environ["UVM_API_TOKEN"]
|
||||||
|
cls.vm_id = os.environ["UVM_TOOLS_VM_ID"]
|
||||||
|
cls.path = "/vms/" + quote(cls.vm_id, safe="")
|
||||||
|
cls.vm = cls.request("GET", cls.path)
|
||||||
|
if (cls.vm["id"] != cls.vm_id or cls.vm["observed_status"] != "running"
|
||||||
|
or cls.vm["username"] == "root" or cls.vm["ports"] != []):
|
||||||
|
raise RuntimeError("provide a disposable running Ubuntu 22.04 VM with a non-root user and no open TCP ports")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def request(cls, method, path, body=None, *, timeout=120):
|
||||||
|
request = Request(cls.api_url + path, method=method,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers={"X-UVM-Token": cls.token, "Content-Type": "application/json"})
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
def test_install_catalog_and_use_runtimes_as_the_guest_user(self):
|
||||||
|
catalog = self.request("GET", "/tools")
|
||||||
|
selections = [{"id": tool["id"], "version": version} for tool in catalog for version in tool["versions"] or [None]]
|
||||||
|
body = {"tools": selections, "request_id": str(uuid.uuid4())}
|
||||||
|
job = self.request("POST", self.path + "/tools/install", body)
|
||||||
|
deadline = time.monotonic() + 3600
|
||||||
|
while job["status"] in {"queued", "starting", "waiting", "installing"} and time.monotonic() < deadline:
|
||||||
|
time.sleep(2)
|
||||||
|
job = self.request("GET", self.path + "/tools/jobs/" + job["id"])
|
||||||
|
self.assertEqual(job["status"], "succeeded", json.dumps(job, indent=2))
|
||||||
|
self.assertEqual(self.request("POST", self.path + "/tools/install", body)["id"], job["id"])
|
||||||
|
inventory = self.request("GET", self.path + "/tools?refresh=true")
|
||||||
|
self.assertEqual(len(inventory["inventory"]), 12)
|
||||||
|
self.assertTrue(all(item["installed"] for item in inventory["inventory"]), inventory)
|
||||||
|
|
||||||
|
script = r'''set -eu
|
||||||
|
test "$(id -u)" != 0
|
||||||
|
git --version
|
||||||
|
node -e 'if (1 + 1 !== 2) process.exit(1)'
|
||||||
|
npm --version
|
||||||
|
wget --version >/dev/null
|
||||||
|
curl --version >/dev/null
|
||||||
|
htop --version
|
||||||
|
php -r 'if (2 + 2 !== 4) exit(1);'
|
||||||
|
work=$(mktemp -d "$HOME/nest-tools-smoke.XXXXXX")
|
||||||
|
name=$(basename "$work")
|
||||||
|
export MAMBA_ROOT_PREFIX="$HOME/micromamba"
|
||||||
|
cleanup() {
|
||||||
|
conda env remove -n "$name" -y >/dev/null 2>&1 || true
|
||||||
|
micromamba env remove -n "$name" -y >/dev/null 2>&1 || true
|
||||||
|
rm -rf -- "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf 'class Hello { public static void main(String[] args) { System.out.println("ok"); } }\n' > "$work/Hello.java"
|
||||||
|
javac "$work/Hello.java"
|
||||||
|
test "$(java -cp "$work" Hello)" = ok
|
||||||
|
for version in 3.11 3.12 3.13; do
|
||||||
|
"python$version" -m venv "$work/venv-$version"
|
||||||
|
"$work/venv-$version/bin/python" -c 'import ssl, sqlite3, sys; assert sys.prefix != sys.base_prefix'
|
||||||
|
"$work/venv-$version/bin/python" -m pip --version
|
||||||
|
done
|
||||||
|
conda create -y -n "$name" python=3.13
|
||||||
|
conda run -n "$name" python -c 'import sys; assert sys.version_info[:2] == (3, 13)'
|
||||||
|
eval "$(conda shell.posix hook)"
|
||||||
|
conda activate "$name"
|
||||||
|
python -c 'import sys; assert sys.version_info[:2] == (3, 13)'
|
||||||
|
conda deactivate
|
||||||
|
micromamba create -y -n "$name" -c conda-forge python=3.12
|
||||||
|
micromamba run -n "$name" python -c 'import sys; assert sys.version_info[:2] == (3, 12)'
|
||||||
|
eval "$(micromamba shell hook --shell posix)"
|
||||||
|
micromamba activate "$name"
|
||||||
|
python -c 'import sys; assert sys.version_info[:2] == (3, 12)'
|
||||||
|
micromamba deactivate
|
||||||
|
'''
|
||||||
|
result = self.request("POST", self.path + "/exec",
|
||||||
|
{"command": script, "sudo": False, "timeout_seconds": 1200}, timeout=1220)
|
||||||
|
self.assertFalse(result["timed_out"], result)
|
||||||
|
self.assertEqual(result["exit_code"], 0, result)
|
||||||
|
after = self.request("GET", self.path)
|
||||||
|
for key in ("id", "guest_ip", "mac", "username", "ports", "cpu", "ram_mib", "storage_mib"):
|
||||||
|
self.assertEqual(after[key], self.vm[key])
|
||||||
@@ -878,6 +878,10 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
("/vms/{vm_id}/ports/sync", "post"),
|
("/vms/{vm_id}/ports/sync", "post"),
|
||||||
("/vms/{vm_id}/exec", "post"),
|
("/vms/{vm_id}/exec", "post"),
|
||||||
("/vms/{vm_id}/agent/install", "post"),
|
("/vms/{vm_id}/agent/install", "post"),
|
||||||
|
("/tools", "get"),
|
||||||
|
("/vms/{vm_id}/tools", "get"),
|
||||||
|
("/vms/{vm_id}/tools/install", "post"),
|
||||||
|
("/vms/{vm_id}/tools/jobs/{job_id}", "get"),
|
||||||
("/diagnostics", "get"),
|
("/diagnostics", "get"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+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,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from tests.test_server import FASTAPI_AVAILABLE, FakeLifecycle
|
||||||
|
from uvm.config import Settings
|
||||||
|
from uvm.server import create_api
|
||||||
|
from uvm.state import StateStore
|
||||||
|
from uvm.tools import ToolService
|
||||||
|
|
||||||
|
if FASTAPI_AVAILABLE:
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
|
||||||
|
class ToolApiTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
settings = replace(Settings(), base=Path(temporary), api_token="test-token")
|
||||||
|
state = StateStore(settings)
|
||||||
|
self.lifecycle = FakeLifecycle()
|
||||||
|
self.lifecycle.vm.runtime_owned = True
|
||||||
|
settings.vm_dir(self.lifecycle.vm.id).mkdir(parents=True, mode=0o700)
|
||||||
|
with state.transaction() as document:
|
||||||
|
document.vms[self.lifecycle.vm.id] = self.lifecycle.vm
|
||||||
|
self.tools = ToolService(settings, state, self.lifecycle)
|
||||||
|
self.tools.start = Mock()
|
||||||
|
self.tools.shutdown = Mock()
|
||||||
|
self.tools.notify = Mock()
|
||||||
|
self.application = SimpleNamespace(settings=settings, state_store=state, lifecycle=self.lifecycle, tools=self.tools)
|
||||||
|
self.api = create_api(self.application, host="127.0.0.1")
|
||||||
|
self.client = TestClient(self.api)
|
||||||
|
self.addCleanup(self.client.close)
|
||||||
|
self.headers = {"X-UVM-Token": "test-token"}
|
||||||
|
|
||||||
|
def job(self):
|
||||||
|
with self.application.state_store.operation_lock():
|
||||||
|
return self.tools.store.enqueue_locked("vm-test", [{"id": "git"}], request_id="request-1")
|
||||||
|
|
||||||
|
def test_catalog_and_job_routes_require_the_management_token(self):
|
||||||
|
for method, path in (("GET", "/tools"), ("GET", "/vms/vm-test/tools"),
|
||||||
|
("POST", "/vms/vm-test/tools/install"),
|
||||||
|
("GET", "/vms/vm-test/tools/jobs/tools-missing")):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.request(method, path, json={"tools": [{"id": "git"}]})
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.tools.start.assert_not_called()
|
||||||
|
|
||||||
|
def test_catalog_has_python_options_and_create_passes_normalized_selections(self):
|
||||||
|
result = self.client.get("/tools", headers=self.headers)
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
python = next(item for item in result.json() if item["id"] == "python")
|
||||||
|
self.assertEqual(python["versions"], ["3.11", "3.12", "3.13"])
|
||||||
|
created = self.client.post("/vms", headers=self.headers, json={"tools": [
|
||||||
|
{"id": "python", "version": "3.12"}, {"id": "git"}, {"id": "git"},
|
||||||
|
]})
|
||||||
|
self.assertEqual(created.status_code, 201, created.text)
|
||||||
|
self.assertEqual([item.key for item in self.lifecycle.last_spec.tools], ["git", "python-3.12"])
|
||||||
|
self.tools.start.assert_called_once()
|
||||||
|
self.tools.notify.assert_called_once()
|
||||||
|
self.assertNotIn("password", created.json())
|
||||||
|
|
||||||
|
def test_invalid_selections_and_options_are_rejected_before_submission(self):
|
||||||
|
self.tools.submit = Mock()
|
||||||
|
invalid = [
|
||||||
|
{"tools": []}, {"tools": None}, {"tools": ["git"]}, {"tools": [{"id": "unknown"}]},
|
||||||
|
{"tools": [{"id": "python", "version": "3.14"}]}, {"tools": [{"id": "python"}]},
|
||||||
|
{"tools": [{"id": "git", "script": "id"}]}, {"tools": [{"id": "git"}], "start_if_stopped": "true"},
|
||||||
|
{"tools": [{"id": "git"}], "restart": 1}, {"tools": [{"id": "git"}], "request_id": "../../x"},
|
||||||
|
{"tools": [{"id": "git"}], "sudo": False},
|
||||||
|
]
|
||||||
|
for body in invalid:
|
||||||
|
with self.subTest(body=body):
|
||||||
|
response = self.client.post("/vms/vm-test/tools/install", headers=self.headers, json=body)
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
self.tools.submit.assert_not_called()
|
||||||
|
for body in ({"tools": None}, {"tools": [{"id": "not-in-catalog"}]}):
|
||||||
|
response = self.client.post("/vms", headers=self.headers, json=body)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertIsNone(self.lifecycle.last_spec)
|
||||||
|
|
||||||
|
def test_install_returns_accepted_job_and_whitelists_its_response(self):
|
||||||
|
job = self.job()
|
||||||
|
job.update(password="secret", host_path="/host/private")
|
||||||
|
self.tools.submit = Mock(return_value=job)
|
||||||
|
result = self.client.post("/vms/vm-test/tools/install", headers=self.headers,
|
||||||
|
json={"tools": [{"id": "git"}], "start_if_stopped": True, "request_id": "request-1"})
|
||||||
|
self.assertEqual(result.status_code, 202, result.text)
|
||||||
|
self.assertEqual(result.json()["id"], job["id"])
|
||||||
|
self.assertNotIn("password", result.json())
|
||||||
|
self.assertNotIn("host_path", result.json())
|
||||||
|
self.assertTrue(self.tools.submit.call_args.kwargs["start_if_stopped"])
|
||||||
|
self.assertEqual(self.tools.submit.call_args.args[0], "vm-test")
|
||||||
|
|
||||||
|
def test_job_history_remains_exact_vm_scoped(self):
|
||||||
|
job = self.job()
|
||||||
|
result = self.client.get(f"/vms/vm-test/tools/jobs/{job['id']}", headers=self.headers)
|
||||||
|
self.assertEqual(result.status_code, 200, result.text)
|
||||||
|
self.assertEqual(result.json()["items"][0]["status"], "queued")
|
||||||
|
for identifier in ("test-vm", "10.42.0.2", "vm-other"):
|
||||||
|
response = self.client.get(f"/vms/{identifier}/tools/jobs/{job['id']}", headers=self.headers)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_server_lifespan_starts_and_stops_workers(self):
|
||||||
|
with TestClient(self.api) as client:
|
||||||
|
self.tools.start.assert_called_once()
|
||||||
|
self.assertEqual(client.get("/tools", headers=self.headers).status_code, 200)
|
||||||
|
self.tools.shutdown.assert_called_once()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import shlex
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from uvm import guest_tools
|
||||||
|
from uvm.errors import ValidationError
|
||||||
|
from uvm.tool_catalog import CATALOG, ToolSelection, all_selections, normalize_tools, recipe
|
||||||
|
from uvm.tools import guest_command
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCatalogTests(unittest.TestCase):
|
||||||
|
def test_requested_catalog_and_every_guest_payload_fit_the_agent_protocol(self):
|
||||||
|
self.assertEqual({tool["id"] for tool in CATALOG},
|
||||||
|
{"git", "nodejs", "python", "jdk", "wget", "curl", "htop", "php", "conda", "micromamba"})
|
||||||
|
for arch in ("x86_64", "aarch64"):
|
||||||
|
recipes = [recipe(item, arch) for item in all_selections()]
|
||||||
|
command = guest_command({"action": "probe", "tools": recipes, "username": "worker", "architecture": arch})
|
||||||
|
self.assertLess(len(command.encode()), 65536)
|
||||||
|
argv = shlex.split(command)
|
||||||
|
self.assertEqual(argv[:3], ["/usr/bin/python3", "-I", "-c"])
|
||||||
|
compile(argv[3], "guest-tools", "exec")
|
||||||
|
ast.parse(argv[3], feature_version=(3, 10))
|
||||||
|
for item in recipes:
|
||||||
|
if "url" in item:
|
||||||
|
self.assertTrue(item["url"].startswith("https://"))
|
||||||
|
self.assertNotIn("/latest", item["url"])
|
||||||
|
self.assertRegex(item["sha256"], r"^[0-9a-f]{64}$")
|
||||||
|
self.assertEqual(len(all_selections()), 12)
|
||||||
|
|
||||||
|
def test_selection_validation_is_allowlisted_strict_and_deduplicated(self):
|
||||||
|
expected = (ToolSelection("git"), ToolSelection("python", "3.12"))
|
||||||
|
self.assertEqual(normalize_tools([{"id": "python", "version": "3.12"}, {"id": "git"}, {"id": "git"}]), expected)
|
||||||
|
for value in (None, "git", [], ["git"], [{"id": "git; id"}], [{"id": "python"}],
|
||||||
|
[{"id": "python", "version": "3.14"}], [{"id": "git", "version": "latest"}],
|
||||||
|
[{"id": "git", "command": "id"}], [{"id": ["git"]}], [{"id": "git"}] * 13):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||||
|
normalize_tools(value)
|
||||||
|
self.assertEqual(normalize_tools([], allow_empty=True), ())
|
||||||
|
|
||||||
|
def test_versioned_python_commands_and_jdk_compiler_are_verified(self):
|
||||||
|
for version in ("3.11", "3.12", "3.13"):
|
||||||
|
item = recipe(ToolSelection("python", version), "aarch64")
|
||||||
|
self.assertEqual(set(item["links"]), {"python" + version})
|
||||||
|
self.assertIn("venv", item["checks"][0][-1])
|
||||||
|
self.assertEqual(item["checks"][1][:3], ["python" + version, "-m", "pip"])
|
||||||
|
jdk = recipe(ToolSelection("jdk"), "aarch64")
|
||||||
|
self.assertEqual(jdk["checks"], [["javac", "-version"], ["java", "-version"]])
|
||||||
|
self.assertIn("arm64", jdk["java_home"])
|
||||||
|
|
||||||
|
|
||||||
|
class GuestToolRecipeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
self.root = Path(self.temporary)
|
||||||
|
|
||||||
|
def test_download_mismatch_removes_artifact_and_https_redirect_is_checked(self):
|
||||||
|
target = self.root / "download"
|
||||||
|
response = io.BytesIO(b"publisher payload")
|
||||||
|
response.geturl = lambda: "https://publisher.test/tool"
|
||||||
|
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
|
||||||
|
with self.assertRaisesRegex(guest_tools.ToolError, "checksum"):
|
||||||
|
guest_tools.download("https://publisher.test/tool", target, "0" * 64)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
response = io.BytesIO(b"publisher payload")
|
||||||
|
response.geturl = lambda: "https://publisher.test/tool"
|
||||||
|
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
|
||||||
|
guest_tools.download("https://publisher.test/tool", target, hashlib.sha256(b"publisher payload").hexdigest())
|
||||||
|
self.assertEqual(target.read_bytes(), b"publisher payload")
|
||||||
|
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
|
||||||
|
guest_tools.download("http://publisher.test/tool", target)
|
||||||
|
response = io.BytesIO(b"publisher payload")
|
||||||
|
response.geturl = lambda: "http://publisher.test/tool"
|
||||||
|
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
|
||||||
|
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
|
||||||
|
guest_tools.download("https://publisher.test/tool", target)
|
||||||
|
|
||||||
|
def test_archive_traversal_links_and_devices_cannot_escape_staging(self):
|
||||||
|
cases = [("../outside", tarfile.REGTYPE, ""), ("/outside", tarfile.REGTYPE, ""),
|
||||||
|
("link", tarfile.SYMTYPE, "../../outside"), ("link", tarfile.LNKTYPE, "../outside"),
|
||||||
|
("device", tarfile.CHRTYPE, "")]
|
||||||
|
for name, kind, link in cases:
|
||||||
|
with self.subTest(name=name, kind=kind):
|
||||||
|
archive = self.root / "archive.tar"
|
||||||
|
with tarfile.open(archive, "w") as stream:
|
||||||
|
item = tarfile.TarInfo(name)
|
||||||
|
item.type = kind
|
||||||
|
item.linkname = link
|
||||||
|
stream.addfile(item)
|
||||||
|
output = self.root / "output"
|
||||||
|
output.mkdir(exist_ok=True)
|
||||||
|
with self.assertRaises(guest_tools.ToolError):
|
||||||
|
guest_tools.extract_archive(archive, output)
|
||||||
|
self.assertFalse((self.root / "outside").exists())
|
||||||
|
|
||||||
|
def test_internal_archive_symlinks_are_supported(self):
|
||||||
|
archive = self.root / "archive.tar"
|
||||||
|
with tarfile.open(archive, "w") as stream:
|
||||||
|
item = tarfile.TarInfo("python/bin/python3.12")
|
||||||
|
item.size = 4
|
||||||
|
item.mode = 0o755
|
||||||
|
stream.addfile(item, io.BytesIO(b"test"))
|
||||||
|
link = tarfile.TarInfo("python/bin/python3")
|
||||||
|
link.type = tarfile.SYMTYPE
|
||||||
|
link.linkname = "python3.12"
|
||||||
|
stream.addfile(link)
|
||||||
|
output = self.root / "output"
|
||||||
|
output.mkdir()
|
||||||
|
guest_tools.extract_archive(archive, output)
|
||||||
|
self.assertEqual((output / "python/bin/python3").read_bytes(), b"test")
|
||||||
|
|
||||||
|
def test_probe_uses_guest_account_and_rejects_wrong_runtime_or_missing_companion(self):
|
||||||
|
node = recipe(ToolSelection("nodejs"), "x86_64")
|
||||||
|
account = SimpleNamespace(pw_dir="/home/worker", pw_uid=1001, pw_gid=1002)
|
||||||
|
with (patch.object(guest_tools.pwd, "getpwnam", return_value=account),
|
||||||
|
patch.object(guest_tools.os, "getgrouplist", return_value=[1002, 1003]),
|
||||||
|
patch.object(guest_tools.shutil, "which", return_value="/usr/local/bin/node"),
|
||||||
|
patch.object(guest_tools.subprocess, "run") as run):
|
||||||
|
run.return_value = SimpleNamespace(returncode=0, stdout=b"v22.10.0\n")
|
||||||
|
result = guest_tools.probe(node, "worker")
|
||||||
|
self.assertFalse(result["installed"])
|
||||||
|
self.assertEqual(result["detected_version"], "22.10.0")
|
||||||
|
self.assertEqual(run.call_args.kwargs["user"], 1001)
|
||||||
|
self.assertEqual(run.call_args.kwargs["env"]["HOME"], "/home/worker")
|
||||||
|
run.side_effect = [SimpleNamespace(returncode=0, stdout=b"v24.21.0\n"),
|
||||||
|
SimpleNamespace(returncode=1, stdout=b"broken npm")]
|
||||||
|
self.assertFalse(guest_tools.probe(node, "worker")["installed"])
|
||||||
|
|
||||||
|
def test_managed_files_preserve_edits_and_can_finish_interrupted_publication(self):
|
||||||
|
installer = guest_tools.Installer("root")
|
||||||
|
path = self.root / "profile"
|
||||||
|
# Isolated directory fixtures stand in for the root-owned guest hierarchy.
|
||||||
|
with (patch.object(guest_tools, "ROOT", self.root),
|
||||||
|
patch.object(guest_tools, "safe_directory"),
|
||||||
|
patch.object(guest_tools, "regular_file")):
|
||||||
|
installer.write_managed(path, b"initial")
|
||||||
|
installer.write_managed(path, b"updated")
|
||||||
|
self.assertEqual(path.read_bytes(), b"updated")
|
||||||
|
path.write_bytes(b"guest edit")
|
||||||
|
with self.assertRaisesRegex(guest_tools.ToolError, "customized"):
|
||||||
|
installer.write_managed(path, b"next")
|
||||||
|
self.assertEqual(path.read_bytes(), b"guest edit")
|
||||||
|
path.write_bytes(b"updated")
|
||||||
|
installer.remember(path, hashlib.sha256(b"next").hexdigest())
|
||||||
|
installer.write_managed(path, b"next")
|
||||||
|
self.assertEqual(path.read_bytes(), b"next")
|
||||||
|
|
||||||
|
def test_log_capture_retains_a_bounded_tail(self):
|
||||||
|
installer = guest_tools.Installer("root")
|
||||||
|
installer.append(b"a" * 20000)
|
||||||
|
installer.append(b"last failure")
|
||||||
|
self.assertEqual(len(installer.log), guest_tools.LOG_LIMIT)
|
||||||
|
self.assertTrue(installer.truncated)
|
||||||
|
self.assertTrue(installer.log.endswith(b"last failure"))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from tests.test_server import FASTAPI_AVAILABLE
|
||||||
|
|
||||||
|
if FASTAPI_AVAILABLE:
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from tests.browser_tool_server import fixture_application
|
||||||
|
from uvm.server import create_api
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
|
||||||
|
class ToolFlowTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
self.application = fixture_application(Path(temporary), delay=0.01)
|
||||||
|
self.enterContext(patch("uvm.tools.require_root"))
|
||||||
|
self.headers = {"X-UVM-Token": "browser-test-token"}
|
||||||
|
|
||||||
|
def wait_for(self, condition):
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if value := condition():
|
||||||
|
return value
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.fail("background tool worker did not reach the expected state")
|
||||||
|
|
||||||
|
def test_create_and_later_install_use_real_workers_and_durable_job_responses(self):
|
||||||
|
with TestClient(create_api(self.application, host="127.0.0.1")) as client:
|
||||||
|
response = client.post("/vms", headers=self.headers, json={"name": "tools-vm", "tools": [
|
||||||
|
{"id": "git"}, {"id": "python", "version": "3.12"},
|
||||||
|
]})
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
vm_id = response.json()["id"]
|
||||||
|
self.wait_for(lambda: self.application.tools.store.read(vm_id)["jobs"][-1]["status"] == "succeeded")
|
||||||
|
snapshot = client.get(f"/vms/{vm_id}/tools?refresh=true", headers=self.headers)
|
||||||
|
self.assertEqual(snapshot.status_code, 200, snapshot.text)
|
||||||
|
installed = {item["key"] for item in snapshot.json()["inventory"] if item["installed"]}
|
||||||
|
self.assertEqual(installed, {"git", "python-3.12", "curl"})
|
||||||
|
self.assertEqual(snapshot.json()["latest_job"]["source"], "create")
|
||||||
|
later = client.post("/vms/vm-other/tools/install", headers=self.headers,
|
||||||
|
json={"tools": [{"id": "htop"}], "start_if_stopped": True})
|
||||||
|
self.assertEqual(later.status_code, 202, later.text)
|
||||||
|
job_id = later.json()["id"]
|
||||||
|
self.wait_for(lambda: self.application.tools.store.get_job("vm-other", job_id)["status"] == "succeeded")
|
||||||
|
self.assertEqual(self.application.state_store.get("vm-other").status, "running")
|
||||||
|
self.assertEqual(client.get(f"/vms/{vm_id}/tools/jobs/{job_id}", headers=self.headers).status_code, 404)
|
||||||
|
|
||||||
|
def test_queued_jobs_are_dispatched_on_server_start(self):
|
||||||
|
with self.application.state_store.operation_lock():
|
||||||
|
job = self.application.tools.store.enqueue_locked("vm-test", [{"id": "git"}])
|
||||||
|
with TestClient(create_api(self.application, host="127.0.0.1")):
|
||||||
|
self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job["id"])["status"] == "succeeded")
|
||||||
|
self.assertEqual(self.application.lifecycle.attempts[("vm-test", "git")], 1)
|
||||||
|
|
||||||
|
def test_shutdown_wakes_command_waiter_and_keeps_an_interrupted_job(self):
|
||||||
|
self.application.lifecycle.delay = 30
|
||||||
|
with TestClient(create_api(self.application, host="127.0.0.1")) as client:
|
||||||
|
response = client.post("/vms/vm-test/tools/install", headers=self.headers, json={"tools": [{"id": "git"}]})
|
||||||
|
job_id = response.json()["id"]
|
||||||
|
self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job_id)["status"] == "installing"
|
||||||
|
and bool(self.application.tools._sessions))
|
||||||
|
started = time.monotonic()
|
||||||
|
self.assertLess(time.monotonic() - started, 3)
|
||||||
|
job = self.application.tools.store.get_job("vm-test", job_id)
|
||||||
|
self.assertEqual(job["status"], "interrupted")
|
||||||
|
self.assertTrue(job["outcome_unknown"])
|
||||||
|
self.assertTrue(all(not thread.is_alive() for thread in self.application.tools._threads))
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from tests.test_server import FakeLifecycle
|
||||||
|
from uvm.config import Settings
|
||||||
|
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError
|
||||||
|
from uvm.state import StateStore
|
||||||
|
from uvm.tool_catalog import ToolSelection
|
||||||
|
from uvm.tool_store import ToolJobStore
|
||||||
|
from uvm.tools import ToolService
|
||||||
|
|
||||||
|
|
||||||
|
class ToolJobTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = self.enterContext(tempfile.TemporaryDirectory())
|
||||||
|
self.settings = replace(Settings(), base=Path(temporary), tool_workers=1)
|
||||||
|
self.state = StateStore(self.settings)
|
||||||
|
self.vm = replace(FakeLifecycle().vm, runtime_owned=True, pid=123, process_start_time="1", boot_id="host-boot")
|
||||||
|
self.settings.vm_dir(self.vm.id).mkdir(parents=True, mode=0o700)
|
||||||
|
with self.state.transaction() as state:
|
||||||
|
state.vms[self.vm.id] = self.vm
|
||||||
|
self.store = ToolJobStore(self.settings, self.state)
|
||||||
|
|
||||||
|
def enqueue(self, selections=None, **options):
|
||||||
|
with self.state.operation_lock():
|
||||||
|
return self.store.enqueue_locked(self.vm.id, selections or [ToolSelection("git")], **options)
|
||||||
|
|
||||||
|
def test_duplicate_concurrent_submissions_have_one_durable_job(self):
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
jobs = list(executor.map(lambda _: self.enqueue(request_id="request-1"), range(4)))
|
||||||
|
self.assertEqual(len({job["id"] for job in jobs}), 1)
|
||||||
|
self.assertEqual(len(self.store.read(self.vm.id)["jobs"]), 1)
|
||||||
|
path = self.settings.vm_dir(self.vm.id) / "tools.json"
|
||||||
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertNotIn("guest-secret", path.read_text())
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.enqueue([ToolSelection("htop")])
|
||||||
|
|
||||||
|
def test_history_is_vm_scoped_and_deletion_is_never_undone(self):
|
||||||
|
job = self.enqueue()
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.store.get_job(self.vm.name, job["id"])
|
||||||
|
with self.state.transaction() as state:
|
||||||
|
del state.vms[self.vm.id]
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.store.update_job(self.vm.id, job["id"], lambda current, data: current.update(status="succeeded"))
|
||||||
|
self.assertNotIn(self.vm.id, self.state.load().vms)
|
||||||
|
|
||||||
|
def test_recovery_preserves_queued_work_and_marks_inflight_outcomes_unknown(self):
|
||||||
|
job = self.enqueue()
|
||||||
|
self.store.recover()
|
||||||
|
self.assertEqual(self.store.get_job(self.vm.id, job["id"])["status"], "queued")
|
||||||
|
self.store.claim()
|
||||||
|
self.store.update_job(self.vm.id, job["id"], lambda current, data: current["items"][0].update(status="installing"))
|
||||||
|
self.store.recover()
|
||||||
|
recovered = self.store.get_job(self.vm.id, job["id"])
|
||||||
|
self.assertEqual(recovered["status"], "interrupted")
|
||||||
|
self.assertTrue(recovered["outcome_unknown"])
|
||||||
|
self.assertEqual(recovered["items"][0]["status"], "interrupted")
|
||||||
|
self.assertIsNone(self.store.claim())
|
||||||
|
|
||||||
|
def test_symlinks_and_foreign_history_are_rejected(self):
|
||||||
|
outside = self.settings.base / "outside"
|
||||||
|
outside.write_text("private data")
|
||||||
|
path = self.settings.vm_dir(self.vm.id) / "tools.json"
|
||||||
|
path.symlink_to(outside)
|
||||||
|
with self.assertRaises((StateError, OSError)):
|
||||||
|
self.store.read(self.vm.id)
|
||||||
|
self.assertEqual(outside.read_text(), "private data")
|
||||||
|
path.unlink()
|
||||||
|
self.enqueue()
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
data["jobs"][0]["vm_id"] = "vm-other"
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
with self.assertRaises(StateError):
|
||||||
|
self.store.read(self.vm.id)
|
||||||
|
|
||||||
|
def test_only_one_worker_can_own_this_registry_and_shutdown_releases_it(self):
|
||||||
|
first = ToolService(self.settings, self.state, Mock())
|
||||||
|
second = ToolService(self.settings, self.state, Mock())
|
||||||
|
self.addCleanup(first.shutdown)
|
||||||
|
self.addCleanup(second.shutdown)
|
||||||
|
first.start()
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
second.start()
|
||||||
|
first.shutdown()
|
||||||
|
second.start()
|
||||||
|
|
||||||
|
def make_service(self):
|
||||||
|
lifecycle = Mock()
|
||||||
|
lifecycle.prepare_tool_install.return_value = self.vm
|
||||||
|
service = ToolService(self.settings, self.state, lifecycle)
|
||||||
|
return service
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def observation(selection, installed=False):
|
||||||
|
return {**selection.to_dict(), "key": selection.key, "installed": installed,
|
||||||
|
"detected_version": "2.0.0" if installed else None, "usage": "tool --version", "error": None}
|
||||||
|
|
||||||
|
def test_worker_reports_partial_failure_and_retains_successful_tools(self):
|
||||||
|
selections = [ToolSelection("git"), ToolSelection("htop")]
|
||||||
|
job = self.enqueue(selections)
|
||||||
|
claimed = self.store.claim()
|
||||||
|
service = self.make_service()
|
||||||
|
results = [
|
||||||
|
{"ok": True, "busy": False, "boot_id": "guest-boot", "tools": [self.observation(item) for item in selections]},
|
||||||
|
{"ok": True, "status": "installed", "tool": self.observation(selections[0], True), "output": "git installed"},
|
||||||
|
{"ok": False, "error": "mirror unavailable", "output": "apt download failed"},
|
||||||
|
]
|
||||||
|
with patch.object(service, "_execute", side_effect=results) as execute:
|
||||||
|
service._run_job(claimed)
|
||||||
|
result = self.store.get_job(self.vm.id, job["id"])
|
||||||
|
self.assertEqual(result["status"], "failed")
|
||||||
|
self.assertEqual([item["status"] for item in result["items"]], ["installed", "failed"])
|
||||||
|
self.assertEqual(result["items"][1]["output"], "apt download failed")
|
||||||
|
self.assertEqual(self.state.get(self.vm.id).status, "running")
|
||||||
|
self.assertEqual(execute.call_args.args[1]["boot_id"], "guest-boot")
|
||||||
|
|
||||||
|
def test_lost_result_is_never_replayed_and_remaining_tools_are_skipped(self):
|
||||||
|
selections = [ToolSelection("git"), ToolSelection("htop")]
|
||||||
|
job = self.enqueue(selections)
|
||||||
|
service = self.make_service()
|
||||||
|
with patch.object(service, "_execute", side_effect=[
|
||||||
|
{"ok": True, "busy": False, "boot_id": "boot", "tools": [self.observation(item) for item in selections]},
|
||||||
|
GuestExecutionError("connection lost", outcome_unknown=True),
|
||||||
|
]) as execute:
|
||||||
|
service._run_job(self.store.claim())
|
||||||
|
result = self.store.get_job(self.vm.id, job["id"])
|
||||||
|
self.assertEqual(execute.call_count, 2)
|
||||||
|
self.assertEqual(result["status"], "interrupted")
|
||||||
|
self.assertTrue(result["outcome_unknown"])
|
||||||
|
self.assertEqual([item["status"] for item in result["items"]], ["interrupted", "skipped"])
|
||||||
|
|
||||||
|
def test_prior_guest_installation_blocks_a_retry_before_submission(self):
|
||||||
|
job = self.enqueue()
|
||||||
|
service = self.make_service()
|
||||||
|
with patch.object(service, "_execute", return_value={"ok": True, "busy": True}) as execute:
|
||||||
|
service._run_job(self.store.claim())
|
||||||
|
self.assertEqual(execute.call_count, 1)
|
||||||
|
result = self.store.get_job(self.vm.id, job["id"])
|
||||||
|
self.assertEqual(result["status"], "interrupted")
|
||||||
|
self.assertFalse(result["outcome_unknown"])
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from tests.test_lifecycle import LifecycleTestCase
|
||||||
|
from uvm.errors import ConflictError, NotFoundError, UvmError, ValidationError
|
||||||
|
from uvm.tool_catalog import ToolSelection
|
||||||
|
|
||||||
|
|
||||||
|
class ToolLifecycleTests(LifecycleTestCase):
|
||||||
|
def test_create_persists_tool_intent_before_boot(self):
|
||||||
|
def configure(config):
|
||||||
|
vm_id = next(iter(self.store.load().vms))
|
||||||
|
jobs = self.service._tool_store._read(vm_id)["jobs"]
|
||||||
|
self.assertEqual(jobs[0]["selections"], [{"id": "python", "version": "3.12"}])
|
||||||
|
self.assertEqual(jobs[0]["status"], "queued")
|
||||||
|
original = self.service._client_factory
|
||||||
|
def client(socket, timeout):
|
||||||
|
result = original(socket, timeout)
|
||||||
|
result.configure_and_start = configure
|
||||||
|
return result
|
||||||
|
self.service._client_factory = client
|
||||||
|
vm = self.create_vm(tools=(ToolSelection("python", "3.12"),))
|
||||||
|
self.assertEqual(vm.status, "running")
|
||||||
|
self.assertEqual(self.service._tool_store.read(vm.id)["jobs"][0]["source"], "create")
|
||||||
|
|
||||||
|
def test_invalid_tools_fail_before_reserving_a_vm_and_failed_boot_removes_intent(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.create_vm(tools=({"id": "invalid"},))
|
||||||
|
self.assertEqual(self.store.load().vms, {})
|
||||||
|
self.host.failures["client.configure", None] = UvmError("failed boot")
|
||||||
|
with self.assertRaises(UvmError):
|
||||||
|
self.create_vm(tools=(ToolSelection("git"),))
|
||||||
|
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||||
|
|
||||||
|
def test_stopped_installation_starts_only_when_requested_and_aliases_are_rejected(self):
|
||||||
|
vm = self.create_vm(name="worker")
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
for alias in (vm.name, vm.guest_ip):
|
||||||
|
with self.assertRaises(NotFoundError):
|
||||||
|
self.service.queue_tools(alias, [ToolSelection("git")], start_if_stopped=True)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.service.queue_tools(vm.id, [ToolSelection("git")])
|
||||||
|
job = self.service.queue_tools(vm.id, [ToolSelection("git")], start_if_stopped=True)
|
||||||
|
self.assertEqual(self.store.get(vm.id).status, "stopped")
|
||||||
|
ready = self.service.prepare_tool_install(vm.id, start_if_stopped=job["start_if_stopped"])
|
||||||
|
self.assertEqual(ready.status, "running")
|
||||||
|
self.assertEqual(ready.guest_ip, vm.guest_ip)
|
||||||
|
|
||||||
|
def test_a_tool_step_cannot_reconnect_to_a_restarted_vm(self):
|
||||||
|
vm = self.create_vm()
|
||||||
|
identity = vm.pid, vm.process_start_time, vm.boot_id
|
||||||
|
self.service.restart(vm.id)
|
||||||
|
self.service._guest_connector = Mock()
|
||||||
|
with self.assertRaisesRegex(ConflictError, "restarted"):
|
||||||
|
self.service.execute(vm.id, "id", expected_identity=identity)
|
||||||
|
self.service._guest_connector.assert_not_called()
|
||||||
@@ -11,6 +11,7 @@ from .domain import VmRecord
|
|||||||
from .errors import ValidationError
|
from .errors import ValidationError
|
||||||
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
|
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
|
||||||
from .guest_exec import validate_exec
|
from .guest_exec import validate_exec
|
||||||
|
from .tool_catalog import MAX_SELECTIONS, ToolSelection
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
@@ -42,6 +43,19 @@ class PortRuleModel(BaseModel):
|
|||||||
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
|
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSelectionModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(strict=True, min_length=1, max_length=32)
|
||||||
|
version: str | None = Field(default=None, strict=True, max_length=16,
|
||||||
|
description="Required for Python: 3.11, 3.12, or 3.13. Omit for other tools.")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_selection(self):
|
||||||
|
ToolSelection(self.id, self.version)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class VmCreateRequest(BaseModel):
|
class VmCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -54,6 +68,8 @@ class VmCreateRequest(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
ports: list[PortRuleModel] | None = None
|
ports: list[PortRuleModel] | None = None
|
||||||
no_default_ports: bool = Field(default=False, strict=True)
|
no_default_ports: bool = Field(default=False, strict=True)
|
||||||
|
tools: list[ToolSelectionModel] = Field(default_factory=list, max_length=MAX_SELECTIONS,
|
||||||
|
description="Optional guest tools to install asynchronously after boot")
|
||||||
|
|
||||||
|
|
||||||
class VmResizeRequest(BaseModel):
|
class VmResizeRequest(BaseModel):
|
||||||
@@ -149,6 +165,78 @@ class VmAgentInstallResponse(BaseModel):
|
|||||||
restart_required: bool = True
|
restart_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCatalogResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version_label: str
|
||||||
|
versions: list[str]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInstallRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
tools: list[ToolSelectionModel] = Field(min_length=1, max_length=MAX_SELECTIONS)
|
||||||
|
start_if_stopped: bool = Field(default=False, strict=True)
|
||||||
|
restart: bool = Field(default=False, strict=True, description="Restart first to refresh the guest agent")
|
||||||
|
request_id: str | None = Field(default=None, strict=True, max_length=80, pattern=r"^[A-Za-z0-9_-]{1,80}$",
|
||||||
|
description="Optional idempotency key, retained with the most recent 10 jobs")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInventoryResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
id: str
|
||||||
|
version: str | None
|
||||||
|
installed: bool
|
||||||
|
detected_version: str | None
|
||||||
|
error: str | None
|
||||||
|
usage: str
|
||||||
|
|
||||||
|
|
||||||
|
class ToolJobItemResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
id: str
|
||||||
|
version: str | None
|
||||||
|
status: Literal["queued", "installing", "installed", "already_installed", "failed", "interrupted", "skipped"]
|
||||||
|
detected_version: str | None
|
||||||
|
error: str | None
|
||||||
|
output: str
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ToolJobSummaryResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: Literal["queued", "starting", "waiting", "installing", "succeeded", "failed", "interrupted"]
|
||||||
|
created_at: float
|
||||||
|
completed_at: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolJobResponse(ToolJobSummaryResponse):
|
||||||
|
vm_id: str
|
||||||
|
source: Literal["create", "user"]
|
||||||
|
updated_at: float
|
||||||
|
start_if_stopped: bool
|
||||||
|
restart: bool
|
||||||
|
request_id: str | None
|
||||||
|
error: str | None
|
||||||
|
outcome_unknown: bool
|
||||||
|
items: list[ToolJobItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class VmToolsResponse(BaseModel):
|
||||||
|
vm_id: str
|
||||||
|
inventory: list[ToolInventoryResponse]
|
||||||
|
checked_at: float | None
|
||||||
|
agent_ready: bool
|
||||||
|
guest_busy: bool
|
||||||
|
supported: bool | None
|
||||||
|
error: str | None
|
||||||
|
stale: bool
|
||||||
|
latest_job: ToolJobResponse | None
|
||||||
|
jobs: list[ToolJobSummaryResponse]
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticResponse(BaseModel):
|
class DiagnosticResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
ok: bool
|
ok: bool
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .lifecycle import LifecycleService
|
|||||||
from .network import NetworkManager
|
from .network import NetworkManager
|
||||||
from .state import StateStore
|
from .state import StateStore
|
||||||
from .system import CommandRunner
|
from .system import CommandRunner
|
||||||
|
from .tools import ToolService
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -24,6 +25,7 @@ class Application:
|
|||||||
state_store: StateStore
|
state_store: StateStore
|
||||||
installer: Installer
|
installer: Installer
|
||||||
lifecycle: LifecycleService
|
lifecycle: LifecycleService
|
||||||
|
tools: ToolService | None = None
|
||||||
|
|
||||||
|
|
||||||
def build_application(emit: Callable[[str], None] | None = print) -> Application:
|
def build_application(emit: Callable[[str], None] | None = print) -> Application:
|
||||||
@@ -41,4 +43,5 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
|
|||||||
state_store=state_store,
|
state_store=state_store,
|
||||||
installer=installer,
|
installer=installer,
|
||||||
lifecycle=lifecycle,
|
lifecycle=lifecycle,
|
||||||
|
tools=ToolService(settings, state_store, lifecycle),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from ipaddress import IPv4Address, IPv4Network
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
@@ -46,6 +47,9 @@ class Settings:
|
|||||||
api_timeout_s: float = 2.0
|
api_timeout_s: float = 2.0
|
||||||
api_socket_timeout_s: float = 5.0
|
api_socket_timeout_s: float = 5.0
|
||||||
terminate_timeout_s: float = 5.0
|
terminate_timeout_s: float = 5.0
|
||||||
|
tool_workers: int = 2
|
||||||
|
tool_ready_timeout_s: float = 120.0
|
||||||
|
tool_install_timeout_s: float = 1200.0
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
architecture = self.architecture.lower()
|
architecture = self.architecture.lower()
|
||||||
@@ -54,6 +58,13 @@ class Settings:
|
|||||||
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
|
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
|
||||||
object.__setattr__(self, "architecture", architecture)
|
object.__setattr__(self, "architecture", architecture)
|
||||||
|
|
||||||
|
if type(self.tool_workers) is not int or not 1 <= self.tool_workers <= 8:
|
||||||
|
raise ConfigurationError("tool_workers must be an integer between 1 and 8")
|
||||||
|
for name in ("tool_ready_timeout_s", "tool_install_timeout_s"):
|
||||||
|
value = getattr(self, name)
|
||||||
|
if type(value) not in (int, float) or not 0 < value <= 3600 or not math.isfinite(value):
|
||||||
|
raise ConfigurationError(f"{name} must be finite, greater than zero, and at most 3600 seconds")
|
||||||
|
|
||||||
# Resolve defaults at construction time, while preserving individual URL overrides.
|
# Resolve defaults at construction time, while preserving individual URL overrides.
|
||||||
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
|
||||||
if not self.kernel_url:
|
if not self.kernel_url:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Any
|
|||||||
|
|
||||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
|
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
|
||||||
from .errors import StateError, ValidationError
|
from .errors import StateError, ValidationError
|
||||||
|
from .tool_catalog import ToolSelection
|
||||||
|
|
||||||
|
|
||||||
STATE_SCHEMA_VERSION = 3
|
STATE_SCHEMA_VERSION = 3
|
||||||
@@ -55,6 +56,7 @@ class VmSpec:
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
ports: tuple[PortRule, ...] = (PortRule(22),)
|
ports: tuple[PortRule, ...] = (PortRule(22),)
|
||||||
storage_mib: int = MIN_STORAGE_MIB
|
storage_mib: int = MIN_STORAGE_MIB
|
||||||
|
tools: tuple[ToolSelection, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -111,4 +111,10 @@ class GuestSession:
|
|||||||
) from error
|
) from error
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
# Wake a thread waiting for a long-running tool command on server shutdown.
|
||||||
|
# The guest still owns its bounded command and package-manager cleanup.
|
||||||
|
try:
|
||||||
|
self._connection.shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
self._connection.close()
|
self._connection.close()
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""Standalone guest-only tool installer, submitted through the execution agent.
|
||||||
|
|
||||||
|
Importing this module on the host has no side effects. It uses only Python 3.10
|
||||||
|
stdlib; all package commands, downloads, and filesystem changes run in the guest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path("/opt/uvm-tools")
|
||||||
|
LOCK = Path("/run/uvm-tools.lock")
|
||||||
|
BIN_DIR = Path("/usr/local/bin")
|
||||||
|
PROFILE_DIR = Path("/etc/profile.d")
|
||||||
|
PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
LOG_LIMIT = 16384
|
||||||
|
PHP_KEY = "B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path, data, mode=0o644):
|
||||||
|
descriptor, name = tempfile.mkstemp(prefix=".uvm-tools-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
os.fchmod(stream.fileno(), mode)
|
||||||
|
stream.write(data)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(name, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
Path(name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_directory(path):
|
||||||
|
for parent in reversed((path, *path.parents)):
|
||||||
|
if not parent.exists():
|
||||||
|
parent.mkdir(mode=0o755)
|
||||||
|
metadata = parent.lstat()
|
||||||
|
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
|
||||||
|
raise ToolError(f"tool installation needs a root-owned directory: {parent}")
|
||||||
|
|
||||||
|
|
||||||
|
def regular_file(path):
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_nlink != 1 or metadata.st_mode & 0o022:
|
||||||
|
raise ToolError(f"refusing to replace an unmanaged tool file: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def download(url, destination, digest=None, *, max_bytes=1024 * 1024 * 1024):
|
||||||
|
if not url.startswith("https://"):
|
||||||
|
raise ToolError("tool downloads require HTTPS")
|
||||||
|
checksum = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as response, destination.open("wb") as output:
|
||||||
|
if not response.geturl().startswith("https://"):
|
||||||
|
raise ToolError("tool download redirected away from HTTPS")
|
||||||
|
while True:
|
||||||
|
chunk = response.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > max_bytes:
|
||||||
|
raise ToolError("tool download exceeds its size limit")
|
||||||
|
checksum.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
if digest is not None and checksum.hexdigest() != digest:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise ToolError("tool download checksum did not match the pinned release")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_archive(archive_path, destination):
|
||||||
|
"""Bound extraction and check each path, including previously extracted links."""
|
||||||
|
root = destination.resolve()
|
||||||
|
with tarfile.open(archive_path) as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
if len(members) > 50000 or sum(member.size for member in members) > 4 * 1024**3:
|
||||||
|
raise ToolError("tool archive exceeds its extraction limit")
|
||||||
|
for member in members:
|
||||||
|
name = Path(member.name)
|
||||||
|
target = destination / name
|
||||||
|
if name.is_absolute() or ".." in name.parts or not target.resolve().is_relative_to(root):
|
||||||
|
raise ToolError("unsafe path in tool archive")
|
||||||
|
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
||||||
|
raise ToolError("unsupported file in tool archive")
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
link = (target.parent if member.issym() else destination) / member.linkname
|
||||||
|
if Path(member.linkname).is_absolute() or not link.resolve().is_relative_to(root):
|
||||||
|
raise ToolError("unsafe link in tool archive")
|
||||||
|
member.mode &= 0o755
|
||||||
|
member.uid = member.gid = 0
|
||||||
|
member.uname = member.gname = "root"
|
||||||
|
options = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
|
||||||
|
archive.extract(member, destination, **options)
|
||||||
|
|
||||||
|
|
||||||
|
def platform_info():
|
||||||
|
values = {}
|
||||||
|
for line in Path("/etc/os-release").read_text().splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key] = value.strip('"')
|
||||||
|
arch = os.uname().machine
|
||||||
|
return {"os": values.get("PRETTY_NAME", "Unknown OS"), "architecture": arch,
|
||||||
|
"supported": values.get("ID") == "ubuntu" and values.get("VERSION_ID") == "22.04"
|
||||||
|
and arch in ("x86_64", "aarch64"),
|
||||||
|
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def guest_busy():
|
||||||
|
try:
|
||||||
|
descriptor = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return False
|
||||||
|
except BlockingIOError:
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def probe(tool, username):
|
||||||
|
account = pwd.getpwnam(username)
|
||||||
|
environment = {"PATH": PATH, "HOME": account.pw_dir, "USER": username, "LOGNAME": username,
|
||||||
|
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||||
|
result = {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": False,
|
||||||
|
"detected_version": None, "error": None, "usage": tool["usage"]}
|
||||||
|
for index, command in enumerate(tool["checks"]):
|
||||||
|
if shutil.which(command[0], path=PATH) is None:
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
process = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, timeout=3, env=environment, cwd=account.pw_dir,
|
||||||
|
user=account.pw_uid, group=account.pw_gid,
|
||||||
|
extra_groups=os.getgrouplist(username, account.pw_gid))
|
||||||
|
output = process.stdout[:4096].decode("utf-8", errors="replace")
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
result["error"] = f"could not verify {command[0]}: {error}"[:512]
|
||||||
|
return result
|
||||||
|
if process.returncode != 0:
|
||||||
|
result["error"] = f"{command[0]} verification failed: {output}"[:512]
|
||||||
|
return result
|
||||||
|
match = re.search(tool["match"], output)
|
||||||
|
if index == 0:
|
||||||
|
if not match:
|
||||||
|
result["error"] = f"could not determine the installed {tool['id']} version"
|
||||||
|
return result
|
||||||
|
result["detected_version"] = match.group(1)
|
||||||
|
if not result["detected_version"].startswith(tool["prefix_match"]):
|
||||||
|
return result
|
||||||
|
elif tool["id"] == "jdk" and (not match or not match.group(1).startswith("21.")):
|
||||||
|
return result
|
||||||
|
result["installed"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class Installer:
|
||||||
|
def __init__(self, username):
|
||||||
|
self.username = username
|
||||||
|
self.log = bytearray()
|
||||||
|
self.truncated = False
|
||||||
|
self.lock = None
|
||||||
|
self.managed = {}
|
||||||
|
|
||||||
|
def append(self, data):
|
||||||
|
self.log.extend(data)
|
||||||
|
if len(self.log) > LOG_LIMIT:
|
||||||
|
del self.log[:-LOG_LIMIT]
|
||||||
|
self.truncated = True
|
||||||
|
|
||||||
|
def run(self, command):
|
||||||
|
self.append(("+ " + shlex.join(map(str, command)) + "\n").encode())
|
||||||
|
environment = {**os.environ, "PATH": PATH, "LC_ALL": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
|
||||||
|
with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, env=environment, pass_fds=(self.lock,)) as process:
|
||||||
|
while True:
|
||||||
|
chunk = process.stdout.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.append(chunk)
|
||||||
|
if process.wait() != 0:
|
||||||
|
raise ToolError(f"{command[0]} failed with exit code {process.returncode}; see the installation output")
|
||||||
|
|
||||||
|
def remember(self, path, value):
|
||||||
|
name = str(path)
|
||||||
|
self.managed[name] = list(dict.fromkeys([*self.managed.get(name, []), value]))[-2:]
|
||||||
|
atomic_write(ROOT / "managed.json", json.dumps(self.managed).encode(), 0o600)
|
||||||
|
|
||||||
|
def write_managed(self, path, data, mode=0o644):
|
||||||
|
safe_directory(path.parent)
|
||||||
|
digest = hashlib.sha256(data).hexdigest()
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
regular_file(path)
|
||||||
|
current = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if current == digest:
|
||||||
|
return
|
||||||
|
if current not in self.managed.get(str(path), []):
|
||||||
|
raise ToolError(f"customized file needs attention before installing: {path}")
|
||||||
|
# Record intent first so an interrupted publication can be retried.
|
||||||
|
self.remember(path, digest)
|
||||||
|
atomic_write(path, data, mode)
|
||||||
|
|
||||||
|
def link(self, name, target):
|
||||||
|
path = BIN_DIR / name
|
||||||
|
safe_directory(path.parent)
|
||||||
|
target = str(target)
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
if path.is_symlink() and os.readlink(path) == target:
|
||||||
|
return
|
||||||
|
if not path.is_symlink() or os.readlink(path) not in self.managed.get(str(path), []):
|
||||||
|
raise ToolError(f"customized executable needs attention before installing: {path}")
|
||||||
|
self.remember(path, target)
|
||||||
|
temporary = path.with_name(".uvm-" + name)
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
temporary.symlink_to(target)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
def apt(self, packages, *, refresh=False):
|
||||||
|
stamp = ROOT / "apt-updated"
|
||||||
|
if refresh or not stamp.exists() or time.time() - stamp.stat().st_mtime > 300:
|
||||||
|
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
|
||||||
|
"-o", "APT::Update::Error-Mode=any", "update"])
|
||||||
|
atomic_write(stamp, b"updated\n", 0o600)
|
||||||
|
pending = ROOT / "apt-pending"
|
||||||
|
if pending.exists():
|
||||||
|
# Complete configuration left by an interrupted tool installation.
|
||||||
|
self.run(["/usr/bin/dpkg", "--configure", "-a"])
|
||||||
|
atomic_write(pending, b"pending\n", 0o600)
|
||||||
|
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
|
||||||
|
"-o", "Dpkg::Options::=--force-confold", "-y", "--no-install-recommends", "install", *packages])
|
||||||
|
pending.unlink()
|
||||||
|
|
||||||
|
def php_repository(self, temporary):
|
||||||
|
self.apt(["ca-certificates", "gnupg"])
|
||||||
|
key = temporary / "php.asc"
|
||||||
|
download(f"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{PHP_KEY}", key, max_bytes=65536)
|
||||||
|
result = subprocess.run(["gpg", "--batch", "--homedir", str(temporary), "--with-colons", "--show-keys", str(key)],
|
||||||
|
capture_output=True, check=True)
|
||||||
|
fingerprints = [line.split(":")[9] for line in result.stdout.decode().splitlines() if line.startswith("fpr:")]
|
||||||
|
if not fingerprints or fingerprints[0] != PHP_KEY:
|
||||||
|
raise ToolError("PHP repository signing key did not match its pinned fingerprint")
|
||||||
|
binary_key = temporary / "php.gpg"
|
||||||
|
self.run(["gpg", "--batch", "--homedir", str(temporary), "--dearmor", "--output", str(binary_key), str(key)])
|
||||||
|
self.write_managed(Path("/etc/apt/keyrings/uvm-php.gpg"), binary_key.read_bytes())
|
||||||
|
self.write_managed(Path("/etc/apt/sources.list.d/uvm-php.list"),
|
||||||
|
b"deb [signed-by=/etc/apt/keyrings/uvm-php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main\n")
|
||||||
|
|
||||||
|
def payload(self, tool, temporary):
|
||||||
|
prefix = ROOT / tool["release"]
|
||||||
|
marker = prefix / ".uvm-tools.json"
|
||||||
|
identity = {"release": tool["release"], "sha256": tool["sha256"]}
|
||||||
|
if prefix.exists() or prefix.is_symlink():
|
||||||
|
safe_directory(prefix)
|
||||||
|
if not marker.exists():
|
||||||
|
raise ToolError(f"installation directory is already in use: {prefix}")
|
||||||
|
regular_file(marker)
|
||||||
|
previous = json.loads(marker.read_bytes())
|
||||||
|
if not isinstance(previous, dict) or any(previous.get(key) != value for key, value in identity.items()):
|
||||||
|
raise ToolError(f"installation directory belongs to a different release: {prefix}")
|
||||||
|
if tool["kind"] != "conda" or previous.get("complete"):
|
||||||
|
return prefix
|
||||||
|
artifact = temporary / "download"
|
||||||
|
self.append(f"Downloading {tool['release']} and verifying SHA-256…\n".encode())
|
||||||
|
download(tool["url"], artifact, tool["sha256"])
|
||||||
|
if tool["kind"] == "archive":
|
||||||
|
extraction = temporary / "extract"
|
||||||
|
extraction.mkdir()
|
||||||
|
extract_archive(artifact, extraction)
|
||||||
|
source = extraction / tool["archive_root"]
|
||||||
|
if not source.is_dir() or source.is_symlink():
|
||||||
|
raise ToolError("tool archive has an unexpected layout")
|
||||||
|
atomic_write(source / ".uvm-tools.json", json.dumps(identity).encode(), 0o600)
|
||||||
|
os.replace(source, prefix)
|
||||||
|
else:
|
||||||
|
prefix.mkdir(exist_ok=True, mode=0o755)
|
||||||
|
atomic_write(marker, json.dumps(identity).encode(), 0o600)
|
||||||
|
if tool["kind"] == "conda":
|
||||||
|
self.run(["/bin/bash", str(artifact), "-b", "-f", "-p", str(prefix)])
|
||||||
|
else:
|
||||||
|
artifact.chmod(0o755)
|
||||||
|
os.replace(artifact, prefix / "micromamba")
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
def configure(self, tool, prefix):
|
||||||
|
for name, target in tool["links"].items():
|
||||||
|
self.link(name, Path(target) if target.startswith("/") else prefix / target)
|
||||||
|
if tool["id"] == "python":
|
||||||
|
executable = prefix / "bin" / f"python{tool['version']}"
|
||||||
|
self.write_managed(BIN_DIR / f"pip{tool['version']}",
|
||||||
|
f'#!/bin/sh\nexec {shlex.quote(str(executable))} -m pip "$@"\n'.encode(), 0o755)
|
||||||
|
elif tool["id"] == "jdk":
|
||||||
|
self.write_managed(PROFILE_DIR / "uvm-java.sh",
|
||||||
|
f"export JAVA_HOME={shlex.quote(tool['java_home'])}\n".encode())
|
||||||
|
elif tool["id"] == "conda":
|
||||||
|
for key, value in (("auto_activate_base", "false"),):
|
||||||
|
self.run([str(prefix / "bin/conda"), "config", "--system", "--set", key, value])
|
||||||
|
for key, value in (("envs_dirs", "~/.conda/envs"), ("pkgs_dirs", "~/.conda/pkgs")):
|
||||||
|
self.run([str(prefix / "bin/conda"), "config", "--system", "--prepend", key, value])
|
||||||
|
self.write_managed(PROFILE_DIR / "uvm-conda.sh",
|
||||||
|
f". {shlex.quote(str(prefix / 'etc/profile.d/conda.sh'))}\n".encode())
|
||||||
|
elif tool["id"] == "micromamba":
|
||||||
|
self.write_managed(PROFILE_DIR / "uvm-micromamba.sh",
|
||||||
|
b'export MAMBA_ROOT_PREFIX="${MAMBA_ROOT_PREFIX:-$HOME/micromamba}"\n'
|
||||||
|
b'eval "$(/usr/local/bin/micromamba shell hook --shell posix)"\n')
|
||||||
|
|
||||||
|
def install(self, tool):
|
||||||
|
safe_directory(LOCK.parent)
|
||||||
|
self.lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(self.lock)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
|
||||||
|
raise ToolError("unsafe guest tool installation lock")
|
||||||
|
try:
|
||||||
|
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
raise ToolError("another installation is still running inside this guest; wait before retrying")
|
||||||
|
existing = probe(tool, self.username)
|
||||||
|
if existing["installed"]:
|
||||||
|
return {"status": "already_installed", "tool": existing}
|
||||||
|
safe_directory(ROOT)
|
||||||
|
manifest = ROOT / "managed.json"
|
||||||
|
if manifest.exists():
|
||||||
|
regular_file(manifest)
|
||||||
|
self.managed = json.loads(manifest.read_bytes())
|
||||||
|
if (not isinstance(self.managed, dict) or any(
|
||||||
|
not isinstance(values, list) or len(values) > 2 or any(not isinstance(value, str) for value in values)
|
||||||
|
for values in self.managed.values())):
|
||||||
|
raise ToolError("invalid managed tool file manifest")
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".install-", dir=ROOT) as name:
|
||||||
|
temporary = Path(name)
|
||||||
|
if tool["kind"] == "php":
|
||||||
|
self.php_repository(temporary)
|
||||||
|
self.apt(tool["packages"], refresh=tool["kind"] == "php")
|
||||||
|
prefix = self.payload(tool, temporary) if "url" in tool else None
|
||||||
|
self.configure(tool, prefix)
|
||||||
|
installed = probe(tool, self.username)
|
||||||
|
if not installed["installed"]:
|
||||||
|
raise ToolError(installed["error"] or f"{tool['key']} did not pass its version check after installation")
|
||||||
|
if prefix is not None:
|
||||||
|
atomic_write(prefix / ".uvm-tools.json", json.dumps({"release": tool["release"],
|
||||||
|
"sha256": tool["sha256"], "complete": True}).encode(), 0o600)
|
||||||
|
return {"status": "installed", "tool": installed}
|
||||||
|
finally:
|
||||||
|
os.close(self.lock)
|
||||||
|
self.lock = None
|
||||||
|
|
||||||
|
|
||||||
|
def main(payload):
|
||||||
|
installer = Installer(payload["username"])
|
||||||
|
info = {}
|
||||||
|
try:
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
raise ToolError("tool installation and inspection require guest root")
|
||||||
|
info = platform_info()
|
||||||
|
info["supported"] = info["supported"] and info["architecture"] == payload["architecture"]
|
||||||
|
if not info["supported"]:
|
||||||
|
raise ToolError("one-click tools require an Ubuntu 22.04 guest on the selected x86_64/ARM64 architecture")
|
||||||
|
if payload.get("boot_id") and payload["boot_id"] != info["boot_id"]:
|
||||||
|
raise ToolError("the guest rebooted during installation; retry on the current boot")
|
||||||
|
if payload["action"] == "probe":
|
||||||
|
result = {**info, "busy": guest_busy(), "tools": [probe(tool, payload["username"]) for tool in payload["tools"]]}
|
||||||
|
elif payload["action"] == "install":
|
||||||
|
result = installer.install(payload["tools"][0])
|
||||||
|
else:
|
||||||
|
raise ToolError("unsupported tool operation")
|
||||||
|
result["ok"] = True
|
||||||
|
except (ToolError, OSError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError) as error:
|
||||||
|
result = {"ok": False, "error": str(error)[:2048], "supported": info.get("supported")}
|
||||||
|
result.update(output=installer.log.decode("utf-8", errors="replace"), truncated=installer.truncated)
|
||||||
|
print(json.dumps(result, ensure_ascii=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(json.loads(sys.argv[1]))
|
||||||
@@ -26,6 +26,8 @@ from .known_hosts import KnownHostsManager
|
|||||||
from .network import NetworkManager
|
from .network import NetworkManager
|
||||||
from .state import StateStore
|
from .state import StateStore
|
||||||
from .system import check_kvm, ensure_data_directories, require_root
|
from .system import check_kvm, ensure_data_directories, require_root
|
||||||
|
from .tool_catalog import normalize_tools
|
||||||
|
from .tool_store import ToolJobStore
|
||||||
from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
|
from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ class LifecycleService:
|
|||||||
self._guest_connector = guest_connector
|
self._guest_connector = guest_connector
|
||||||
self._terminal_connector = terminal_connector
|
self._terminal_connector = terminal_connector
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
|
self._tool_store = ToolJobStore(settings, state_store, clock=clock)
|
||||||
|
|
||||||
def create(self, spec: VmSpec) -> VmRecord:
|
def create(self, spec: VmSpec) -> VmRecord:
|
||||||
require_root()
|
require_root()
|
||||||
@@ -84,6 +87,7 @@ class LifecycleService:
|
|||||||
if spec.name is not None:
|
if spec.name is not None:
|
||||||
parse_name(spec.name)
|
parse_name(spec.name)
|
||||||
ports = self._validated_ports(spec.ports)
|
ports = self._validated_ports(spec.ports)
|
||||||
|
tools = normalize_tools(spec.tools, allow_empty=True)
|
||||||
ensure_data_directories(self._settings)
|
ensure_data_directories(self._settings)
|
||||||
with self._state_store.operation_lock():
|
with self._state_store.operation_lock():
|
||||||
self._check_boot()
|
self._check_boot()
|
||||||
@@ -100,6 +104,8 @@ class LifecycleService:
|
|||||||
self._images.provision_guest_agent(disk, vm.id, vm.username)
|
self._images.provision_guest_agent(disk, vm.id, vm.username)
|
||||||
vm.disk_ready = True
|
vm.disk_ready = True
|
||||||
self._save(vm)
|
self._save(vm)
|
||||||
|
if tools:
|
||||||
|
self._tool_store.enqueue_locked(vm.id, tools, source="create")
|
||||||
self._boot(vm, assets.kernel)
|
self._boot(vm, assets.kernel)
|
||||||
return vm
|
return vm
|
||||||
except BaseException as error:
|
except BaseException as error:
|
||||||
@@ -506,6 +512,8 @@ class LifecycleService:
|
|||||||
def execute(
|
def execute(
|
||||||
self, vm_id: str, command: str, *, sudo: bool = False,
|
self, vm_id: str, command: str, *, sudo: bool = False,
|
||||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
expected_identity: tuple | None = None,
|
||||||
|
on_connect: Callable[[GuestSession], None] | None = None,
|
||||||
) -> ExecResult:
|
) -> ExecResult:
|
||||||
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
|
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
|
||||||
require_root()
|
require_root()
|
||||||
@@ -516,14 +524,52 @@ class LifecycleService:
|
|||||||
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
|
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
|
||||||
or self._process.observe(vm) != "alive"):
|
or self._process.observe(vm) != "alive"):
|
||||||
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
|
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
|
||||||
|
if expected_identity is not None and expected_identity != (vm.pid, vm.process_start_time, vm.boot_id):
|
||||||
|
raise ConflictError("the VM restarted during tool installation; retry on the current boot")
|
||||||
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
|
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
|
||||||
try:
|
try:
|
||||||
|
if on_connect is not None:
|
||||||
|
on_connect(session)
|
||||||
# This session stays attached to that VMM even if the VM is stopped,
|
# This session stays attached to that VMM even if the VM is stopped,
|
||||||
# deleted, or restarted concurrently. Never reconnect/retry a command.
|
# deleted, or restarted concurrently. Never reconnect/retry a command.
|
||||||
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
|
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
def _validate_tool_target(self, vm: VmRecord, *, start_if_stopped: bool, restart: bool) -> str:
|
||||||
|
self._not_deleting(vm)
|
||||||
|
observation = self._process.observe(vm)
|
||||||
|
if vm.operation is not None or vm.pending_resize is not None or not vm.disk_ready:
|
||||||
|
raise ConflictError("finish pending lifecycle operations before installing tools")
|
||||||
|
if vm.status == "running" and observation == "alive":
|
||||||
|
return "running"
|
||||||
|
if vm.status == "stopped" and observation == "gone" and (start_if_stopped or restart):
|
||||||
|
self._validate_resume(vm)
|
||||||
|
return "stopped"
|
||||||
|
raise ConflictError("tool installation requires a running VM; use Start & install for a stopped VM")
|
||||||
|
|
||||||
|
def queue_tools(self, vm_id: str, selections, *, start_if_stopped=False, restart=False, request_id=None) -> dict:
|
||||||
|
require_root()
|
||||||
|
selections = normalize_tools(selections)
|
||||||
|
with self._state_store.operation_lock():
|
||||||
|
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||||
|
self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
|
||||||
|
return self._tool_store.enqueue_locked(vm.id, selections, start_if_stopped=start_if_stopped,
|
||||||
|
restart=restart, request_id=request_id)
|
||||||
|
|
||||||
|
def prepare_tool_install(self, vm_id: str, *, start_if_stopped=False, restart=False) -> VmRecord:
|
||||||
|
"""Perform requested startup/agent refresh atomically before a tool worker connects."""
|
||||||
|
require_root()
|
||||||
|
with self._state_store.operation_lock():
|
||||||
|
vm = self._load_for_operation(vm_id, exact_id=True)
|
||||||
|
status = self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
|
||||||
|
if status == "running" and restart:
|
||||||
|
self._stop_locked(vm)
|
||||||
|
if status == "stopped" or restart:
|
||||||
|
# The normal retained-disk boot also provisions the current agent.
|
||||||
|
vm = self._start_locked(vm)
|
||||||
|
return vm
|
||||||
|
|
||||||
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
|
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
|
||||||
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
|
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
|
||||||
require_root()
|
require_root()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Authenticated tool catalog, installation jobs, and guest inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
|
||||||
|
from ..api_models import ToolCatalogResponse, ToolInstallRequest, ToolJobResponse, VmToolsResponse
|
||||||
|
from ..app import Application
|
||||||
|
from ..errors import UvmError
|
||||||
|
from ..tool_catalog import CATALOG, normalize_tools
|
||||||
|
from .dependencies import get_authorized_application
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def tool_service(application):
|
||||||
|
service = getattr(application, "tools", None)
|
||||||
|
if service is None:
|
||||||
|
raise UvmError("guest tool installation is unavailable in this application")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=list[ToolCatalogResponse])
|
||||||
|
def catalog(application: Application = Depends(get_authorized_application)):
|
||||||
|
return list(CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vms/{vm_id}/tools", response_model=VmToolsResponse)
|
||||||
|
def vm_tools(vm_id: str, refresh: bool = False, application: Application = Depends(get_authorized_application)):
|
||||||
|
"""Return cached observations; refresh=true probes the running guest without installing."""
|
||||||
|
return tool_service(application).snapshot(vm_id, refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vms/{vm_id}/tools/install", response_model=ToolJobResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
def install_tools(vm_id: str, request: ToolInstallRequest,
|
||||||
|
application: Application = Depends(get_authorized_application)):
|
||||||
|
return tool_service(application).submit(
|
||||||
|
vm_id, normalize_tools([item.model_dump() for item in request.tools]),
|
||||||
|
start_if_stopped=request.start_if_stopped, restart=request.restart, request_id=request.request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vms/{vm_id}/tools/jobs/{job_id}", response_model=ToolJobResponse)
|
||||||
|
def tool_job(vm_id: str, job_id: str, application: Application = Depends(get_authorized_application)):
|
||||||
|
return tool_service(application).store.get_job(vm_id, job_id)
|
||||||
@@ -22,6 +22,7 @@ from ..api_models import (
|
|||||||
from ..app import Application
|
from ..app import Application
|
||||||
from ..domain import PortRule, VmRecord, VmSpec
|
from ..domain import PortRule, VmRecord, VmSpec
|
||||||
from ..errors import ValidationError
|
from ..errors import ValidationError
|
||||||
|
from ..tool_catalog import normalize_tools
|
||||||
from ..validation import (
|
from ..validation import (
|
||||||
parse_cpu,
|
parse_cpu,
|
||||||
parse_name,
|
parse_name,
|
||||||
@@ -33,6 +34,7 @@ from ..validation import (
|
|||||||
parse_username,
|
parse_username,
|
||||||
)
|
)
|
||||||
from .dependencies import get_authorized_application
|
from .dependencies import get_authorized_application
|
||||||
|
from .tools import tool_service
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/vms", tags=["vms"])
|
router = APIRouter(prefix="/vms", tags=["vms"])
|
||||||
@@ -73,6 +75,9 @@ def create_vm(
|
|||||||
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()])
|
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()])
|
||||||
if not request.no_default_ports and not any(rule.port == 22 for rule in ports):
|
if not request.no_default_ports and not any(rule.port == 22 for rule in ports):
|
||||||
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
|
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
|
||||||
|
tools = normalize_tools([item.model_dump() for item in request.tools], allow_empty=True)
|
||||||
|
if tools:
|
||||||
|
tool_service(application).start()
|
||||||
vm = application.lifecycle.create(
|
vm = application.lifecycle.create(
|
||||||
VmSpec(
|
VmSpec(
|
||||||
cpu=parse_cpu(str(request.cpu)),
|
cpu=parse_cpu(str(request.cpu)),
|
||||||
@@ -83,8 +88,11 @@ def create_vm(
|
|||||||
password=parse_password(request.password.get_secret_value()),
|
password=parse_password(request.password.get_secret_value()),
|
||||||
name=parse_name(request.name) if request.name is not None else None,
|
name=parse_name(request.name) if request.name is not None else None,
|
||||||
ports=ports,
|
ports=ports,
|
||||||
|
tools=tools,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if tools:
|
||||||
|
tool_service(application).notify()
|
||||||
return vm_response(vm, policy_status=_mutation_policy_status(vm))
|
return vm_response(vm, policy_status=_mutation_policy_status(vm))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import ssl
|
import ssl
|
||||||
@@ -32,6 +33,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
try:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
except ModuleNotFoundError as error:
|
except ModuleNotFoundError as error:
|
||||||
raise UvmError(
|
raise UvmError(
|
||||||
"FastAPI server support is not installed. Install this project with its dependencies."
|
"FastAPI server support is not installed. Install this project with its dependencies."
|
||||||
@@ -42,11 +44,24 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
from .routers.installation import router as installation_router
|
from .routers.installation import router as installation_router
|
||||||
from .routers.vms import router as vms_router
|
from .routers.vms import router as vms_router
|
||||||
from .routers.diagnostics import router as diagnostics_router
|
from .routers.diagnostics import router as diagnostics_router
|
||||||
|
from .routers.tools import router as tools_router
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_api):
|
||||||
|
tools = getattr(resolved_application, "tools", None)
|
||||||
|
if tools is not None:
|
||||||
|
await run_in_threadpool(tools.start)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if tools is not None:
|
||||||
|
await run_in_threadpool(tools.shutdown)
|
||||||
|
|
||||||
api = FastAPI(
|
api = FastAPI(
|
||||||
title="uvm",
|
title="uvm",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
description="Local Firecracker microVM management API.",
|
description="Local Firecracker microVM management API.",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
api.state.uvm_application = resolved_application
|
api.state.uvm_application = resolved_application
|
||||||
|
|
||||||
@@ -65,6 +80,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
api.include_router(installation_router)
|
api.include_router(installation_router)
|
||||||
api.include_router(vms_router)
|
api.include_router(vms_router)
|
||||||
api.include_router(diagnostics_router)
|
api.include_router(diagnostics_router)
|
||||||
|
api.include_router(tools_router)
|
||||||
|
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
|||||||
@@ -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='eval "$(conda shell.posix hook)"; 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='eval "$(micromamba shell hook --shell posix)"; micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv')
|
||||||
|
return result
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Atomic per-VM tool job records, serialized with lifecycle filesystem changes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .errors import ConflictError, NotFoundError, StateError
|
||||||
|
from .state import StateStore
|
||||||
|
from .tool_catalog import normalize_tools
|
||||||
|
|
||||||
|
|
||||||
|
ACTIVE_JOB_STATUSES = frozenset({"queued", "starting", "waiting", "installing"})
|
||||||
|
MAX_RETAINED_JOBS = 10
|
||||||
|
MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ToolJobStore:
|
||||||
|
"""Only short transactions hold operations.lock; guest execution never does."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, state_store: StateStore, *, clock=time.time) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.state = state_store
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
|
def _path(self, vm_id):
|
||||||
|
vm = self.state.get(vm_id) # Exact ID only, including while creating a VM.
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id):
|
||||||
|
raise StateError("invalid VM ID in tool history")
|
||||||
|
directory = self.settings.vm_dir(vm.id)
|
||||||
|
try:
|
||||||
|
metadata = directory.lstat()
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
raise NotFoundError("VM runtime directory is no longer available") from error
|
||||||
|
if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid()
|
||||||
|
or metadata.st_mode & 0o077 or not vm.runtime_owned):
|
||||||
|
raise StateError("tool history requires a private, owned VM runtime directory")
|
||||||
|
return directory / "tools.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _empty():
|
||||||
|
return {"schema_version": 1, "jobs": [], "inventory": [], "checked_at": None,
|
||||||
|
"agent_ready": False, "guest_busy": False, "supported": None, "error": None}
|
||||||
|
|
||||||
|
def _read(self, vm_id):
|
||||||
|
path = self._path(vm_id)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return self._empty()
|
||||||
|
except OSError as error:
|
||||||
|
raise StateError("could not open private tool installation history") from error
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "rb") as stream:
|
||||||
|
metadata = os.fstat(stream.fileno())
|
||||||
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
||||||
|
or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077):
|
||||||
|
raise StateError("unsafe tool history file")
|
||||||
|
raw = stream.read(MAX_DOCUMENT_BYTES + 1)
|
||||||
|
if len(raw) > MAX_DOCUMENT_BYTES:
|
||||||
|
raise StateError("tool history exceeds its size limit")
|
||||||
|
data = json.loads(raw)
|
||||||
|
if (not isinstance(data, dict) or data.get("schema_version") != 1
|
||||||
|
or not isinstance(data.get("jobs"), list) or not isinstance(data.get("inventory"), list)
|
||||||
|
or len(data["jobs"]) > MAX_RETAINED_JOBS):
|
||||||
|
raise StateError("invalid tool history document")
|
||||||
|
for job in data["jobs"]:
|
||||||
|
if (not isinstance(job, dict) or job.get("vm_id") != vm_id
|
||||||
|
or not re.fullmatch(r"tools-[0-9a-f]{32}", str(job.get("id", "")))
|
||||||
|
or not isinstance(job.get("items"), list) or len(job["items"]) > 12):
|
||||||
|
raise StateError("invalid tool installation job")
|
||||||
|
return data
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
raise StateError("could not read tool installation history") from error
|
||||||
|
|
||||||
|
def _write(self, vm_id, data):
|
||||||
|
path = self._path(vm_id) # Never recreate a deleted VM directory.
|
||||||
|
descriptor, name = tempfile.mkstemp(prefix=".tools-", suffix=".json", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||||
|
os.fchmod(stream.fileno(), 0o600)
|
||||||
|
json.dump(data, stream, ensure_ascii=True, allow_nan=False)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(name, path)
|
||||||
|
self.state._fsync_parent(path)
|
||||||
|
except OSError as error:
|
||||||
|
raise StateError("could not save tool installation history") from error
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self, vm_id):
|
||||||
|
with self.state.operation_lock():
|
||||||
|
data = self._read(vm_id)
|
||||||
|
yield data
|
||||||
|
self._write(vm_id, data)
|
||||||
|
|
||||||
|
def read(self, vm_id):
|
||||||
|
with self.state.operation_lock():
|
||||||
|
return self._read(vm_id)
|
||||||
|
|
||||||
|
def enqueue_locked(self, vm_id, selections, *, start_if_stopped=False, restart=False,
|
||||||
|
request_id=None, source="user"):
|
||||||
|
"""Called by lifecycle create/submit with operations.lock already held."""
|
||||||
|
selections = normalize_tools(selections)
|
||||||
|
data = self._read(vm_id)
|
||||||
|
wanted = [item.to_dict() for item in selections]
|
||||||
|
for previous in reversed(data["jobs"]):
|
||||||
|
same = (previous["selections"] == wanted and previous["start_if_stopped"] == start_if_stopped
|
||||||
|
and previous.get("restart", False) == restart)
|
||||||
|
if request_id is not None and previous.get("request_id") == request_id:
|
||||||
|
if not same:
|
||||||
|
raise ConflictError("this installation request ID was already used for different tools")
|
||||||
|
return copy.deepcopy(previous)
|
||||||
|
if previous["status"] in ACTIVE_JOB_STATUSES:
|
||||||
|
if same:
|
||||||
|
return copy.deepcopy(previous)
|
||||||
|
raise ConflictError("a tool installation is already queued or running for this VM")
|
||||||
|
now = self.clock()
|
||||||
|
job = {"id": "tools-" + uuid.uuid4().hex, "vm_id": vm_id, "status": "queued", "source": source,
|
||||||
|
"selections": wanted, "start_if_stopped": start_if_stopped, "restart": restart,
|
||||||
|
"request_id": request_id, "created_at": now, "updated_at": now, "completed_at": None,
|
||||||
|
"error": None, "outcome_unknown": False,
|
||||||
|
"items": [{**item.to_dict(), "key": item.key, "status": "queued", "detected_version": None,
|
||||||
|
"error": None, "output": "", "truncated": False} for item in selections]}
|
||||||
|
data["jobs"] = [*data["jobs"], job][-MAX_RETAINED_JOBS:]
|
||||||
|
self._write(vm_id, data)
|
||||||
|
return copy.deepcopy(job)
|
||||||
|
|
||||||
|
def get_job(self, vm_id, job_id):
|
||||||
|
for job in self.read(vm_id)["jobs"]:
|
||||||
|
if job["id"] == job_id:
|
||||||
|
return job
|
||||||
|
raise NotFoundError("tool installation job not found for this VM")
|
||||||
|
|
||||||
|
def update_job(self, vm_id, job_id, update):
|
||||||
|
with self.transaction(vm_id) as data:
|
||||||
|
for job in data["jobs"]:
|
||||||
|
if job["id"] == job_id:
|
||||||
|
update(job, data)
|
||||||
|
job["updated_at"] = self.clock()
|
||||||
|
return copy.deepcopy(job)
|
||||||
|
raise NotFoundError("tool installation job not found for this VM")
|
||||||
|
|
||||||
|
def claim(self):
|
||||||
|
with self.state.operation_lock():
|
||||||
|
for vm in self.state.load().vms.values():
|
||||||
|
# A failed/interrupted create may not own a runtime directory.
|
||||||
|
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
|
||||||
|
continue
|
||||||
|
data = self._read(vm.id)
|
||||||
|
for job in data["jobs"]:
|
||||||
|
if job["status"] == "queued":
|
||||||
|
job["status"] = "starting" if vm.status == "stopped" or job.get("restart") else "waiting"
|
||||||
|
job["updated_at"] = self.clock()
|
||||||
|
self._write(vm.id, data)
|
||||||
|
return copy.deepcopy(job)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def interrupt(job, message, now, *, outcome_unknown=True):
|
||||||
|
job.update(status="interrupted", error=message, outcome_unknown=outcome_unknown,
|
||||||
|
updated_at=now, completed_at=now)
|
||||||
|
for item in job["items"]:
|
||||||
|
if item["status"] in {"installing", "queued"}:
|
||||||
|
item["status"] = "interrupted" if item["status"] == "installing" else "skipped"
|
||||||
|
item["error"] = message
|
||||||
|
|
||||||
|
def recover(self):
|
||||||
|
"""The worker owner lock proves that previous in-flight host workers exited."""
|
||||||
|
with self.state.operation_lock():
|
||||||
|
for vm in self.state.load().vms.values():
|
||||||
|
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
|
||||||
|
continue
|
||||||
|
data = self._read(vm.id)
|
||||||
|
changed = False
|
||||||
|
for job in data["jobs"]:
|
||||||
|
if job["status"] in ACTIVE_JOB_STATUSES - {"queued"}:
|
||||||
|
self.interrupt(job, "Management server restarted. Refresh tool status before retrying; "
|
||||||
|
"the last installation may have completed inside the guest.", self.clock())
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
data.update(agent_ready=False, guest_busy=False)
|
||||||
|
self._write(vm.id, data)
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
"""Bounded background installation workers using the existing guest exec channel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import guest_tools
|
||||||
|
from .errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError, ValidationError
|
||||||
|
from .guest_exec import validate_exec
|
||||||
|
from .system import require_root
|
||||||
|
from .tool_catalog import all_selections, normalize_tools, recipe
|
||||||
|
from .tool_store import ToolJobStore
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _guest_source():
|
||||||
|
return Path(guest_tools.__file__).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def guest_command(payload: dict) -> str:
|
||||||
|
command = shlex.join(["/usr/bin/python3", "-I", "-c", _guest_source(), json.dumps(payload, ensure_ascii=True)])
|
||||||
|
validate_exec(command, True, 60)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(vm):
|
||||||
|
return vm.pid, vm.process_start_time, vm.boot_id
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value, limit=16384):
|
||||||
|
return str(value or "").encode("utf-8", errors="replace")[-limit:].decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolService:
|
||||||
|
def __init__(self, settings, state_store, lifecycle) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.state = state_store
|
||||||
|
self.lifecycle = lifecycle
|
||||||
|
self.store = ToolJobStore(settings, state_store)
|
||||||
|
self._mutex = threading.RLock()
|
||||||
|
self._wake = threading.Condition()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._threads = []
|
||||||
|
self._sessions = {}
|
||||||
|
self._owner = None
|
||||||
|
self._workers_left = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
with self._mutex:
|
||||||
|
if self._stop.is_set():
|
||||||
|
raise ConflictError("the tool installer is shutting down")
|
||||||
|
if self._owner is not None:
|
||||||
|
return
|
||||||
|
with self.state.operation_lock():
|
||||||
|
descriptor = os.open(self.settings.base / "tool-worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
|
||||||
|
raise UvmError("unsafe tool worker ownership file")
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BaseException as error:
|
||||||
|
os.close(descriptor)
|
||||||
|
if isinstance(error, BlockingIOError):
|
||||||
|
raise ConflictError("another management server is already processing tool installations for this registry") from error
|
||||||
|
raise
|
||||||
|
self._owner = descriptor
|
||||||
|
try:
|
||||||
|
self.store.recover()
|
||||||
|
for index in range(self.settings.tool_workers):
|
||||||
|
thread = threading.Thread(target=self._worker, name=f"uvm-tools-{index}", daemon=True)
|
||||||
|
self._workers_left += 1
|
||||||
|
try:
|
||||||
|
thread.start()
|
||||||
|
except BaseException:
|
||||||
|
self._workers_left -= 1
|
||||||
|
raise
|
||||||
|
self._threads.append(thread)
|
||||||
|
except BaseException:
|
||||||
|
self._stop.set()
|
||||||
|
self.notify()
|
||||||
|
if not self._workers_left:
|
||||||
|
self._release_owner()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _release_owner(self):
|
||||||
|
if self._owner is not None:
|
||||||
|
os.close(self._owner)
|
||||||
|
self._owner = None
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
with self._wake:
|
||||||
|
self._wake.notify_all()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self._stop.set()
|
||||||
|
self.notify()
|
||||||
|
with self._mutex:
|
||||||
|
sessions = list(self._sessions.values())
|
||||||
|
for session in sessions:
|
||||||
|
session.close()
|
||||||
|
deadline = time.monotonic() + 8
|
||||||
|
for thread in self._threads:
|
||||||
|
thread.join(max(0, deadline - time.monotonic()))
|
||||||
|
# A worker finishing a host start keeps ownership until that start ends.
|
||||||
|
# Its guest command is never submitted after shutdown was requested.
|
||||||
|
with self._mutex:
|
||||||
|
if not self._workers_left:
|
||||||
|
self._release_owner()
|
||||||
|
|
||||||
|
def submit(self, vm_id, selections, *, start_if_stopped=False, restart=False, request_id=None):
|
||||||
|
require_root()
|
||||||
|
selections = normalize_tools(selections)
|
||||||
|
if type(start_if_stopped) is not bool or type(restart) is not bool:
|
||||||
|
raise ValidationError("start_if_stopped and restart must be booleans")
|
||||||
|
if request_id is not None and (not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", request_id)):
|
||||||
|
raise ValidationError("request_id must contain 1–80 letters, numbers, underscores, or hyphens")
|
||||||
|
self.start()
|
||||||
|
job = self.lifecycle.queue_tools(vm_id, selections, start_if_stopped=start_if_stopped,
|
||||||
|
restart=restart, request_id=request_id)
|
||||||
|
self.notify()
|
||||||
|
return job
|
||||||
|
|
||||||
|
def _execute(self, vm, payload, timeout):
|
||||||
|
if self._stop.is_set():
|
||||||
|
raise InterruptedError("management server is shutting down")
|
||||||
|
thread_id = threading.get_ident()
|
||||||
|
|
||||||
|
def connected(session):
|
||||||
|
with self._mutex:
|
||||||
|
self._sessions[thread_id] = session
|
||||||
|
if self._stop.is_set():
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.lifecycle.execute(vm.id, guest_command(payload), sudo=True, timeout_seconds=timeout,
|
||||||
|
expected_identity=_identity(vm), on_connect=connected)
|
||||||
|
finally:
|
||||||
|
with self._mutex:
|
||||||
|
self._sessions.pop(thread_id, None)
|
||||||
|
if result.timed_out:
|
||||||
|
return {"ok": False, "error": f"installation/verification timed out after {timeout:g} seconds; retry to finish it",
|
||||||
|
"output": _text(result.stderr), "truncated": result.truncated}
|
||||||
|
try:
|
||||||
|
value = json.loads(result.stdout)
|
||||||
|
if result.exit_code != 0 or result.truncated or not isinstance(value, dict) or type(value.get("ok")) is not bool:
|
||||||
|
raise ValueError("invalid result")
|
||||||
|
return value
|
||||||
|
except (ValueError, TypeError) as error:
|
||||||
|
raise GuestExecutionError("guest returned an incomplete tool result; refresh status before retrying",
|
||||||
|
outcome_unknown=payload["action"] == "install") from error
|
||||||
|
|
||||||
|
def _payload(self, vm, action, selections, *, boot_id=None):
|
||||||
|
return {"action": action, "username": vm.username, "architecture": self.settings.architecture,
|
||||||
|
"boot_id": boot_id, "tools": [recipe(selection, self.settings.architecture) for selection in selections]}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _inventory(result, selections):
|
||||||
|
expected = {item.key: item for item in selections}
|
||||||
|
records = result.get("tools")
|
||||||
|
if not isinstance(records, list) or len(records) != len(expected):
|
||||||
|
raise GuestExecutionError("invalid guest tool inventory")
|
||||||
|
inventory = []
|
||||||
|
for value in records:
|
||||||
|
if (not isinstance(value, dict) or value.get("key") not in expected
|
||||||
|
or type(value.get("installed")) is not bool):
|
||||||
|
raise GuestExecutionError("invalid guest tool inventory")
|
||||||
|
selection = expected.pop(value["key"])
|
||||||
|
inventory.append({**selection.to_dict(), "key": selection.key, "installed": value["installed"],
|
||||||
|
"detected_version": _text(value.get("detected_version"), 120) or None,
|
||||||
|
"error": _text(value.get("error"), 512) or None,
|
||||||
|
"usage": _text(value.get("usage"), 512)})
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
def snapshot(self, vm_id, *, refresh=False):
|
||||||
|
require_root()
|
||||||
|
vm = self.state.get(vm_id)
|
||||||
|
data = self.store.read(vm_id)
|
||||||
|
running = self.lifecycle.inspect(vm.id).observed_status == "running"
|
||||||
|
if refresh and running:
|
||||||
|
supported = data["supported"]
|
||||||
|
try:
|
||||||
|
result = self._execute(vm, self._payload(vm, "probe", all_selections()), 90)
|
||||||
|
if type(result.get("supported")) is bool:
|
||||||
|
supported = result["supported"]
|
||||||
|
if not result["ok"]:
|
||||||
|
raise UvmError(_text(result.get("error"), 2048))
|
||||||
|
inventory = self._inventory(result, all_selections())
|
||||||
|
with self.store.transaction(vm_id) as current:
|
||||||
|
current.update(inventory=inventory, checked_at=time.time(), error=None, agent_ready=True,
|
||||||
|
guest_busy=result.get("busy") is True, supported=result.get("supported") is True)
|
||||||
|
data = self.store.read(vm_id)
|
||||||
|
except UvmError as error:
|
||||||
|
with self.store.transaction(vm_id) as current:
|
||||||
|
current.update(error=_text(error, 2048), agent_ready=not isinstance(error, GuestAgentUnavailableError),
|
||||||
|
supported=supported)
|
||||||
|
data = self.store.read(vm_id)
|
||||||
|
if not running:
|
||||||
|
data.update(agent_ready=False, guest_busy=False)
|
||||||
|
return {"vm_id": vm_id, **{key: data[key] for key in (
|
||||||
|
"inventory", "checked_at", "agent_ready", "guest_busy", "supported", "error")},
|
||||||
|
"stale": not running or data["checked_at"] is None or time.time() - data["checked_at"] > 60,
|
||||||
|
"latest_job": data["jobs"][-1] if data["jobs"] else None,
|
||||||
|
"jobs": [{key: job[key] for key in ("id", "status", "created_at", "completed_at")}
|
||||||
|
for job in reversed(data["jobs"])]}
|
||||||
|
|
||||||
|
def _worker(self):
|
||||||
|
try:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
job = self.store.claim()
|
||||||
|
if job is not None:
|
||||||
|
self._run_job(job)
|
||||||
|
continue
|
||||||
|
except (UvmError, OSError):
|
||||||
|
logging.getLogger(__name__).exception("Could not process a tool installation job")
|
||||||
|
with self._wake:
|
||||||
|
self._wake.wait(timeout=1)
|
||||||
|
finally:
|
||||||
|
with self._mutex:
|
||||||
|
self._workers_left -= 1
|
||||||
|
if self._workers_left == 0 and self._stop.is_set():
|
||||||
|
self._release_owner()
|
||||||
|
|
||||||
|
def _update(self, job, update):
|
||||||
|
return self.store.update_job(job["vm_id"], job["id"], update)
|
||||||
|
|
||||||
|
def _wait_ready(self, vm, selections):
|
||||||
|
deadline = time.monotonic() + self.settings.tool_ready_timeout_s
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
result = self._execute(vm, self._payload(vm, "probe", selections), 90)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise UvmError(_text(result.get("error"), 2048))
|
||||||
|
if result.get("busy"):
|
||||||
|
raise ConflictError("an earlier installation is still running inside this VM; wait and refresh before retrying")
|
||||||
|
if not isinstance(result.get("boot_id"), str) or not result["boot_id"]:
|
||||||
|
raise GuestExecutionError("guest did not report its boot identity")
|
||||||
|
return result
|
||||||
|
except GuestAgentUnavailableError:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise GuestAgentUnavailableError("Guest agent did not become ready. Restart & retry to apply agent setup, "
|
||||||
|
"then check the VM log if startup still fails.")
|
||||||
|
self._stop.wait(min(2, max(0, deadline - time.monotonic())))
|
||||||
|
raise InterruptedError("management server is shutting down")
|
||||||
|
|
||||||
|
def _run_job(self, job):
|
||||||
|
submitted = False
|
||||||
|
try:
|
||||||
|
if self._stop.is_set():
|
||||||
|
raise InterruptedError("management server is shutting down")
|
||||||
|
selections = normalize_tools(job["selections"])
|
||||||
|
vm = self.lifecycle.prepare_tool_install(job["vm_id"], start_if_stopped=job["start_if_stopped"],
|
||||||
|
restart=job.get("restart", False))
|
||||||
|
self._update(job, lambda current, data: current.update(status="waiting"))
|
||||||
|
info = self._wait_ready(vm, selections)
|
||||||
|
initial = self._inventory(info, selections)
|
||||||
|
|
||||||
|
def ready(current, data):
|
||||||
|
observed = {item["key"]: item for item in data["inventory"]}
|
||||||
|
observed.update({item["key"]: item for item in initial})
|
||||||
|
data.update(inventory=list(observed.values()), agent_ready=True,
|
||||||
|
guest_busy=False, supported=True, error=None)
|
||||||
|
self._update(job, ready)
|
||||||
|
for index, selection in enumerate(selections):
|
||||||
|
if self._stop.is_set():
|
||||||
|
raise InterruptedError("management server is shutting down")
|
||||||
|
|
||||||
|
def begin(current, data):
|
||||||
|
current["status"] = "installing"
|
||||||
|
current["items"][index]["status"] = "installing"
|
||||||
|
self._update(job, begin)
|
||||||
|
submitted = True
|
||||||
|
result = self._execute(vm, self._payload(vm, "install", [selection], boot_id=info["boot_id"]),
|
||||||
|
self.settings.tool_install_timeout_s)
|
||||||
|
observed = None
|
||||||
|
if result["ok"]:
|
||||||
|
observed, = self._inventory({"tools": [result.get("tool")]}, [selection])
|
||||||
|
if not observed["installed"] or result.get("status") not in {"installed", "already_installed"}:
|
||||||
|
raise GuestExecutionError("guest installation did not verify the selected tool", outcome_unknown=True)
|
||||||
|
|
||||||
|
def finish_item(current, data):
|
||||||
|
current["items"][index].update(
|
||||||
|
status=result["status"] if result["ok"] else "failed",
|
||||||
|
detected_version=observed["detected_version"] if observed else None,
|
||||||
|
error=None if result["ok"] else _text(result.get("error"), 2048),
|
||||||
|
output=_text(result.get("output")), truncated=result.get("truncated") is True)
|
||||||
|
inventory = {item["key"]: item for item in data["inventory"]}
|
||||||
|
if observed:
|
||||||
|
inventory[selection.key] = observed
|
||||||
|
elif selection.key in inventory:
|
||||||
|
inventory[selection.key].update(installed=False, error="Refresh to verify after the failed installation.")
|
||||||
|
data.update(inventory=list(inventory.values()))
|
||||||
|
self._update(job, finish_item)
|
||||||
|
submitted = False
|
||||||
|
|
||||||
|
def finish(current, data):
|
||||||
|
failures = sum(item["status"] == "failed" for item in current["items"])
|
||||||
|
current.update(status="failed" if failures else "succeeded", completed_at=time.time(),
|
||||||
|
error=f"{failures} tool installation(s) failed; see the per-tool output and retry." if failures else None)
|
||||||
|
self._update(job, finish)
|
||||||
|
except NotFoundError:
|
||||||
|
return # Deletion owns removal of the whole runtime, including history.
|
||||||
|
except (InterruptedError, GuestExecutionError, ConflictError) as error:
|
||||||
|
try:
|
||||||
|
self._update(job, lambda current, data: self.store.interrupt(
|
||||||
|
current, _text(error, 2048), time.time(),
|
||||||
|
outcome_unknown=getattr(error, "outcome_unknown", submitted)))
|
||||||
|
except NotFoundError:
|
||||||
|
pass
|
||||||
|
except (UvmError, OSError, ValueError) as error:
|
||||||
|
def failed(current, data):
|
||||||
|
self.store.interrupt(current, _text(error, 2048), time.time(), outcome_unknown=submitted)
|
||||||
|
current["status"] = "failed" if not submitted else "interrupted"
|
||||||
|
if isinstance(error, GuestAgentUnavailableError):
|
||||||
|
data.update(agent_ready=False, error=_text(error, 2048))
|
||||||
|
try:
|
||||||
|
self._update(job, failed)
|
||||||
|
except NotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("Unexpected tool installation failure")
|
||||||
|
try:
|
||||||
|
self._update(job, lambda current, data: self.store.interrupt(
|
||||||
|
current, "Unexpected installer failure; check the management server log and refresh before retrying.",
|
||||||
|
time.time(), outcome_unknown=submitted))
|
||||||
|
except NotFoundError:
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user