From 3e17e3e72f48bf290412b88b2d4ba7de250ee07f Mon Sep 17 00:00:00 2001 From: kstyagi Date: Fri, 25 Sep 2026 23:52:21 +0530 Subject: [PATCH] feat(tools): Enhance guest tools installation and management - Added new API endpoints for tool catalog and installation management. - Updated usage documentation to include new tool installation features. - Implemented asynchronous tool installation with job tracking. - Introduced integration tests for tool installation in disposable VMs. - Enhanced dashboard UI to reflect tool installation status and usage instructions. - Improved error handling and logging for tool installation processes. - Added support for multiple Python versions and Conda environments. - Refactored tool management logic to ensure better state handling and job persistence. --- DEVELOPER.md | 114 ++++++++++++++++++++-- README.md | 50 +++++++++- USAGE.md | 121 +++++++++++++++++++++++- nest/dashboard/static/js/tools.js | 6 +- tests/browser_tool_server.py | 130 ++++++++++++++++++++++++++ tests/browser_tools.cjs | 106 +++++++++++++++++++++ tests/integration/test_guest_tools.py | 96 +++++++++++++++++++ tests/test_tool_api.py | 1 - tests/test_tool_catalog.py | 5 +- tests/test_tool_flow.py | 73 +++++++++++++++ tests/test_tool_jobs.py | 2 - uvm/config.py | 4 +- uvm/guest_tools.py | 12 ++- uvm/tool_catalog.py | 4 +- uvm/tool_store.py | 2 + uvm/tools.py | 37 ++++++-- 16 files changed, 729 insertions(+), 34 deletions(-) create mode 100644 tests/browser_tool_server.py create mode 100644 tests/browser_tools.cjs create mode 100644 tests/integration/test_guest_tools.py create mode 100644 tests/test_tool_flow.py diff --git a/DEVELOPER.md b/DEVELOPER.md index e589aa5..69120ec 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -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 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 filtering is not public-port publishing or a hardened multi-tenant boundary; 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_exec.py # Host-side guest sessions and execution results │ ├── 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 │ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup │ ├── 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 │ │ ├── health.py # Liveness endpoint │ │ ├── installation.py # Installation endpoint +│ │ ├── tools.py # Catalog, inventory, and installation job endpoints │ │ └── vms.py # VM lifecycle endpoints │ └── firecracker/ │ ├── config.py # Firecracker JSON and boot arguments @@ -218,6 +223,8 @@ process: | FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control | | LifecycleService | `LifecycleService` | Ordered VM resource changes and rollback | | 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 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 │ ├── firecracker.log │ ├── firecracker.sock +│ ├── tools.json │ └── vsock.sock ├── state.json ├── state.lock ├── operations.lock +├── tool-worker.lock └── integrity.json ``` @@ -272,6 +281,8 @@ state file. `VmSpec` is the validated create request. `VmRecord` is the persisted local 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//tools.json`; the VM registry remains schema 3. | 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`. 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 - 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**. 6. Apply/read back the nftables bridge policy, then activate the TAP. 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 or API configuration; clear the pending-spawn guard. 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 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 @@ -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), or `interrupted`; ordinary failures do not undo other successes or stop later 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 @@ -792,7 +808,7 @@ do not import its unimplemented topology checks into current documentation. ## 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. `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 | | `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `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 | | `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP | | `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk | @@ -905,6 +921,10 @@ 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}/exec` | `vms.py` | Exact ID; `command`, strict `sudo`, bounded `timeout_seconds` | | `POST` | `/vms/{vm_id}/agent/install` | `vms.py` | Exact ID; install/upgrade the agent on a stopped disk | +| `GET` | `/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/{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) | @@ -1030,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 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 owns session HTTP/WebSocket routes and history. UVM owns the process-pinned @@ -1198,6 +1290,16 @@ This checks launch/input, reload/reconnect, session switching, per-VM isolation, 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 final command/option so parsing is checked without executing any mutation. These checks supplement fixture-based readback and cleanup tests; they do not diff --git a/README.md b/README.md index e7a657b..0e51d5d 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ and `/dashboard` routes belong to the Nest server. The Nest dashboard includes a fleet overview, searchable machine collection, private network and port-policy view, host diagnostics, and machine provisioning. -Open a machine to manage its lifecycle, 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 collection for bulk operations. 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 | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `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 | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | | `POST` | `/vms/{identifier}/start` | Boot a retained private disk | @@ -342,6 +342,10 @@ 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}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges | | `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk | +| `GET` | `/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/{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) | @@ -365,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 `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 -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 diff --git a/USAGE.md b/USAGE.md index 5983edb..058e9df 100644 --- a/USAGE.md +++ b/USAGE.md @@ -920,6 +920,10 @@ curl --cacert /path/to/ca.pem \ | `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}/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/{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) | @@ -973,7 +977,7 @@ curl -X POST \ ``` 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. Each object has a strict integer `port` from 1 to 65535 and an optional, case-sensitive `protocol` of `TCP` (default), `HTTP`, or `HTTPS`. @@ -988,6 +992,106 @@ The response never includes the password. Omitting credentials still uses labels return HTTP `422`. Example passwords above are placeholders; real 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='' +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='' +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 ```sh @@ -1597,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 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='' \ + 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 ```sh diff --git a/nest/dashboard/static/js/tools.js b/nest/dashboard/static/js/tools.js index d01edaf..67b36ee 100644 --- a/nest/dashboard/static/js/tools.js +++ b/nest/dashboard/static/js/tools.js @@ -212,7 +212,7 @@ 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); + && (status !== 'stopped' || this.vm.ports !== null) && this.data?.supported !== false; } render() { @@ -239,7 +239,7 @@

${this.esc(tool.description)}

${this.esc(tool.source)} - ${installed && found.usage ? `
Get started${this.esc(found.usage)}Open a new session for shell activation commands.
` : ''}`; + ${installed && found.usage ? `
Get started${this.esc(found.usage)}Run these commands in this machine’s terminal.
` : ''}`; }).join('')); if (focused) document.querySelector(`[data-tool-select="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true }); $('#tools-selected-count').textContent = `${this.selected.size} selected`; @@ -252,7 +252,7 @@ $('#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' : ''}. Open a new session after installing to load shell integration.` + : 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(); } diff --git a/tests/browser_tool_server.py b/tests/browser_tool_server.py new file mode 100644 index 0000000..54ad90f --- /dev/null +++ b/tests/browser_tool_server.py @@ -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": "\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() diff --git a/tests/browser_tools.cjs b/tests/browser_tools.cjs new file mode 100644 index 0000000..ced067c --- /dev/null +++ b/tests/browser_tools.cjs @@ -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(), /