Dev #5
+61
-33
@@ -1,11 +1,12 @@
|
||||
# UVM Developer Guide
|
||||
# Nest Developer Guide
|
||||
|
||||
This document is the developer handoff for the entire UVM codebase. Read it
|
||||
This document is the developer handoff for Nest and its UVM backend. Read it
|
||||
before changing lifecycle behavior, host networking, Firecracker launch
|
||||
semantics, persistent state, or the HTTP API.
|
||||
|
||||
`uvm` is a local, privileged Firecracker microVM manager. It is an
|
||||
application with a CLI and optional FastAPI management server. It is not a
|
||||
Nest owns the workspace dashboard and its server entry point. Its `uvm`
|
||||
component is a local, privileged Firecracker microVM manager with a CLI and
|
||||
standalone FastAPI management API. These are application components, not a
|
||||
general-purpose Python library or a multi-host control plane.
|
||||
|
||||
## 1. Goals and Boundaries
|
||||
@@ -13,7 +14,7 @@ general-purpose Python library or a multi-host control plane.
|
||||
UVM installs host/guest assets, provisions private disks, and manages named
|
||||
VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk
|
||||
start/stop/restart, CPU/RAM/storage resizing, deletion, fixed-target bulk operations, inspection, SSH,
|
||||
logs, doctor, and explicit port edits/sync. The optional API and local dashboard
|
||||
logs, doctor, and explicit port edits/sync. The UVM API and Nest dashboard
|
||||
expose health, installation, VM lifecycle controls, and managed port-policy
|
||||
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
||||
operations. Interactive SSH remains CLI-only.
|
||||
@@ -30,16 +31,18 @@ flag reference, see [USAGE.md](USAGE.md).
|
||||
|
||||
## 2. First Run
|
||||
|
||||
All UVM-specific files live in this directory. Start here:
|
||||
All Nest and UVM files live in this project. Start here:
|
||||
|
||||
```sh
|
||||
python3 -m pip install -e '.[test]'
|
||||
python3 -B -m unittest discover -s tests -v
|
||||
./nest.py --help
|
||||
./uvm.py --help
|
||||
```
|
||||
|
||||
The `uvm` console script is registered by `pyproject.toml`. During source
|
||||
development, `./uvm.py` and `python3 -B -m uvm` are equivalent entry points.
|
||||
The `nest` and `uvm` console scripts are registered by `pyproject.toml`.
|
||||
During source development, `./nest.py` and `python3 -B -m nest` are equivalent
|
||||
Nest entry points; `./uvm.py` and `python3 -B -m uvm` launch UVM.
|
||||
|
||||
Requirements for real host operations:
|
||||
|
||||
@@ -63,7 +66,15 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
||||
├── README.md # Project overview
|
||||
├── USAGE.md # Operator command/deployment guide
|
||||
├── pyproject.toml # Package metadata and dependencies
|
||||
├── uvm.py # Executable source-tree launcher
|
||||
├── nest.py # Executable Nest source-tree launcher
|
||||
├── nest/
|
||||
│ ├── __main__.py # `python -m nest`
|
||||
│ ├── cli.py # Nest workspace serve command
|
||||
│ ├── server.py # Nest composition of the dashboard and UVM API
|
||||
│ └── dashboard/
|
||||
│ ├── router.py # Nest dashboard document route
|
||||
│ └── static/ # Packaged HTML, CSS, JavaScript, and fonts
|
||||
├── uvm.py # Executable UVM source-tree launcher
|
||||
├── uvm/
|
||||
│ ├── __main__.py # `python -m uvm`
|
||||
│ ├── cli.py # argparse, CLI rendering, serve dispatch
|
||||
@@ -83,11 +94,10 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
||||
│ ├── api_models.py # Pydantic HTTP request/response models
|
||||
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
||||
│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS
|
||||
│ ├── routers/
|
||||
│ │ ├── dashboard.py # Dashboard document route
|
||||
│ │ ├── diagnostics.py # Host diagnostic endpoint
|
||||
│ │ ├── dependencies.py # API application/token dependencies
|
||||
│ │ ├── exit.py # Authenticated server shutdown
|
||||
│ │ ├── health.py # Liveness endpoint
|
||||
│ │ ├── installation.py # Installation endpoint
|
||||
│ │ └── vms.py # VM lifecycle endpoints
|
||||
@@ -106,8 +116,10 @@ are intentionally small and operationally focused.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CLI[CLI: uvm] --> APP[app.py composition root]
|
||||
HTTP[FastAPI server] --> APP
|
||||
DASH[Nest dashboard] --> NEST[nest/server.py workspace]
|
||||
NEST --> HTTP[UVM management API]
|
||||
CLI[CLI: uvm] --> APP[uvm/app.py composition root]
|
||||
HTTP --> APP
|
||||
APP --> INSTALL[Installer]
|
||||
APP --> LIFE[LifecycleService]
|
||||
APP --> STATE[StateStore]
|
||||
@@ -125,8 +137,9 @@ flowchart LR
|
||||
FC --> KVM[/dev/kvm]
|
||||
```
|
||||
|
||||
The public entry points are thin adapters. The lifecycle layer is the only
|
||||
place that coordinates multiple host resources for a VM. Lower-level modules
|
||||
The public entry points are thin adapters. Nest composes the UVM API and adds
|
||||
the workspace dashboard; UVM does not import Nest. The lifecycle layer is the
|
||||
only place that coordinates multiple host resources for a VM. Lower-level modules
|
||||
must not import `cli.py`, `server.py`, or `lifecycle.py`.
|
||||
|
||||
Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.py`.
|
||||
@@ -163,6 +176,11 @@ checks, not per-VM repair or guest application probes.
|
||||
|
||||
### HTTP Path
|
||||
|
||||
`nest/server.py` owns the workspace application. Its `create_app()` composes
|
||||
`uvm.server.create_api()` with the dashboard document and static assets from
|
||||
`nest/dashboard/`. The same-origin API paths and `X-UVM-Token` authentication
|
||||
are shared with the standalone UVM API.
|
||||
|
||||
Routers validate the token and Pydantic request, invoke application services,
|
||||
and render whitelisted response models. Lifecycle, managed port-policy,
|
||||
diagnostics, and bounded log-tail operations have explicit HTTP routes for the
|
||||
@@ -171,9 +189,11 @@ SSH has no HTTP endpoint. The public dashboard shell does not bypass token
|
||||
validation: it stores a supplied token only for the active browser session and
|
||||
sends it with management requests.
|
||||
|
||||
Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The
|
||||
`create_api()` factory requires an explicit host specifically to prevent it
|
||||
from being used as an insecure generic Uvicorn factory.
|
||||
Launch the workspace with `nest --serve` or `./nest.py --serve`. The standalone
|
||||
backend uses `uvm --serve` or `./uvm.py --serve`. Both `create_app()` and
|
||||
`create_api()` require an explicit host so validation covers the intended bind.
|
||||
Nest passes its factory to the shared UVM server runner, which enforces token
|
||||
and TLS requirements and wires graceful server shutdown.
|
||||
|
||||
## 6. Application Composition
|
||||
|
||||
@@ -837,8 +857,9 @@ Python's safe tar extraction filter.
|
||||
### Server Startup
|
||||
|
||||
See [USAGE.md](USAGE.md) for dependency installation, loopback deployment,
|
||||
TLS deployment, and authenticated curl examples. Launch through `uvm --serve`
|
||||
using the Python environment with the installed API dependencies. Every server
|
||||
TLS deployment, and authenticated curl examples. Launch the Nest workspace
|
||||
through `nest --serve`, or the standalone backend through `uvm --serve`, using
|
||||
the Python environment with the installed API dependencies. Every server
|
||||
requires `UVM_API_TOKEN`, even at `127.0.0.1`. Non-loopback binds additionally
|
||||
require `UVM_API_TLS_CERT` and `UVM_API_TLS_KEY`; paired certificate/key files
|
||||
are loaded into an `ssl.SSLContext` before Uvicorn starts.
|
||||
@@ -851,10 +872,13 @@ or secret manager when that matters.
|
||||
|
||||
### API Contract
|
||||
|
||||
The root redirect and dashboard document are Nest routes. Both server entry
|
||||
points expose the remaining management routes below.
|
||||
|
||||
| Method | Route | Router | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
||||
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
||||
| `GET` | `/` | `nest/server.py` | Redirect to `/dashboard` (Nest only) |
|
||||
| `GET` | `/dashboard` | `nest/dashboard/router.py` | Public Nest dashboard shell (Nest only) |
|
||||
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
||||
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
|
||||
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
||||
@@ -878,14 +902,14 @@ All management routes use `get_authorized_application()` and require:
|
||||
X-UVM-Token: <UVM_API_TOKEN>
|
||||
```
|
||||
|
||||
`POST /exit` is a management route. `run_server()` creates the Uvicorn server,
|
||||
stores it on the FastAPI application state, and runs it in-process. The route
|
||||
returns `202` before a background task sets `should_exit`, allowing Uvicorn to
|
||||
deliver the response and perform its normal graceful shutdown. It does not
|
||||
stop or delete any managed VM.
|
||||
`POST /exit` is a management route. The shared `uvm.server.run_server()` creates
|
||||
the Uvicorn server, stores it on the FastAPI application state, and runs it
|
||||
in-process. The route returns `202` before a background task sets `should_exit`,
|
||||
allowing Uvicorn to deliver the response and perform its normal graceful
|
||||
shutdown for Nest or UVM. It does not stop or delete any managed VM.
|
||||
|
||||
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
||||
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
||||
JSON errors by `uvm/server.py`; Pydantic request validation uses FastAPI's normal
|
||||
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
||||
and strict boolean `no_default_ports` (default false). Port objects contain a
|
||||
strict integer `port` in 1-65535 and uppercase `protocol` TCP/HTTP/HTTPS (TCP
|
||||
@@ -996,11 +1020,13 @@ Ordinary tests require no root, KVM, Firecracker, or host network mutations.
|
||||
They cover validation, schema migration/locking/permissions, artifact trust,
|
||||
private disks/credentials, network/firewall rendering and readback, conntrack
|
||||
semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting,
|
||||
CLI parsing/output, and server security. Mock/fixture policy checks do not
|
||||
prove the kernel's actual packet behavior.
|
||||
CLI parsing/output, Nest dashboard delivery, and server security. Mock/fixture
|
||||
policy checks do not prove the kernel's actual packet behavior.
|
||||
|
||||
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
||||
are installed. They should be enabled in CI using the `test` extra.
|
||||
`test_nest_server.py` checks the workspace launcher, relocated frontend assets,
|
||||
authenticated machine controls, API documentation, and graceful shutdown.
|
||||
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
|
||||
@@ -1075,8 +1101,10 @@ on an isolated Linux host:
|
||||
`get_authorized_application()`, and register new routers in `create_api()`.
|
||||
Add API tests and update all route tables. New CLI commands do not implicitly
|
||||
authorize new HTTP endpoints; `/health` remains the only unauthenticated
|
||||
operational API route, while `/` and `/dashboard` only serve the public
|
||||
dashboard shell.
|
||||
operational API route. Nest owns the public `/` and `/dashboard` routes.
|
||||
- Dashboard: keep the document route and frontend assets in `nest/dashboard/`.
|
||||
Compose workspace-level routes in `nest/server.py`; machine-management
|
||||
operations continue through the UVM API and its application services.
|
||||
- Host primitives: use the specific network/firewall/install/process module
|
||||
and `CommandRunner` for external commands. Define ownership, idempotence,
|
||||
and compensation before invoking a primitive from lifecycle code. Use fake
|
||||
@@ -1137,7 +1165,7 @@ Before submitting a change:
|
||||
|
||||
1. Run the complete unit suite.
|
||||
2. Run `git diff --check`.
|
||||
3. Confirm a new feature stays inside the `uvm/` project directory.
|
||||
3. Keep workspace/dashboard features in `nest/` and machine-management features in `uvm/`.
|
||||
4. Verify command/API errors use `UvmError` rather than raw tracebacks.
|
||||
5. Verify host mutations have a lock owner and cleanup path.
|
||||
6. Verify persistent state remains readable across upgrades.
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# uvm
|
||||
# Nest
|
||||
|
||||
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
|
||||
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
||||
Nest is a local infrastructure workspace with a browser dashboard for your
|
||||
machines, network, and host health. Launch it with `nest --serve`.
|
||||
|
||||
Its `uvm` component is a small, local Firecracker microVM manager for Ubuntu.
|
||||
UVM creates one Firecracker process per VM, attaches it to a host TAP device,
|
||||
and keeps a private disk and local registry for named VMs. The CLI supports
|
||||
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
|
||||
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
|
||||
|
||||
This directory is a self-contained project. All UVM code, tests, packaging
|
||||
metadata, launcher scripts, and documentation live here.
|
||||
This directory is a self-contained project. Nest owns the dashboard and
|
||||
workspace server; UVM provides the machine-management CLI and backend services.
|
||||
All code, tests, packaging metadata, launchers, and documentation live here.
|
||||
|
||||
For architecture, lifecycle invariants, extension guidance, and contributor
|
||||
workflows, see [DEVELOPER.md](DEVELOPER.md).
|
||||
@@ -24,11 +28,13 @@ feature contract; these guides describe the implementation in the workspace.
|
||||
Run commands from this project root:
|
||||
|
||||
```sh
|
||||
./nest.py --help
|
||||
python3 -B -m nest --help
|
||||
./uvm.py --help
|
||||
python3 -B -m uvm --help
|
||||
```
|
||||
|
||||
The `pyproject.toml` file also defines an installable `uvm` console command.
|
||||
The `pyproject.toml` file defines installable `nest` and `uvm` console commands.
|
||||
The project is an application, not a reusable Python SDK.
|
||||
|
||||
## Requirements
|
||||
@@ -271,7 +277,7 @@ DNS publication, reverse proxies, or guest-service TLS. UDP and IPv6 service pol
|
||||
yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
|
||||
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
|
||||
|
||||
## Management API
|
||||
## Nest Dashboard and Management API
|
||||
|
||||
Install the project dependencies before serving the API:
|
||||
|
||||
@@ -279,28 +285,33 @@ Install the project dependencies before serving the API:
|
||||
python3 -m pip install .
|
||||
```
|
||||
|
||||
Launch the FastAPI management server using the environment where the project
|
||||
Launch the Nest workspace server using the environment where the project
|
||||
dependencies were installed (use the full console-script path if needed):
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
uvm --serve --host 127.0.0.1 --port 8000
|
||||
nest --serve --host 127.0.0.1 --port 8000
|
||||
|
||||
# Equivalent when running the source checkout directly.
|
||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
||||
./nest.py --serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
The default bind address is `127.0.0.1` and the default port is `8000`.
|
||||
OpenAPI documentation is available at `/docs` once the server is running. The
|
||||
root URL redirects to the local dashboard at `/dashboard`; enter the API token
|
||||
root URL redirects to the Nest dashboard at `/dashboard`; enter the API token
|
||||
there for the current browser session.
|
||||
|
||||
The 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, edit or sync its port rules, copy its SSH command,
|
||||
or read recent logs. Select machines in the collection for bulk operations.
|
||||
Nest serves the dashboard and UVM-backed management endpoints together, using
|
||||
the existing `UVM_API_TOKEN` and `UVM_*` host configuration. The standalone UVM
|
||||
API is available through `uvm --serve`, with its reference at `/docs`; the `/`
|
||||
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, 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
|
||||
five seconds while the tab is visible; host checks refresh every thirty seconds
|
||||
and can also be run manually. Charts show observed machine states and allocated
|
||||
@@ -311,10 +322,10 @@ The server exposes these routes:
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | Redirect to the local dashboard |
|
||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
||||
| `GET` | `/` | Redirect to the dashboard (Nest server) |
|
||||
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||
| `GET` | `/health` | Unauthenticated liveness response |
|
||||
| `POST` | `/exit` | Gracefully stop the UVM API server |
|
||||
| `POST` | `/exit` | Gracefully stop the management server |
|
||||
| `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 |
|
||||
@@ -365,7 +376,7 @@ sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||
./uvm.py --serve --host 0.0.0.0 --port 8000
|
||||
./nest.py --serve --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Send the token on management requests:
|
||||
@@ -385,8 +396,8 @@ curl -X POST \
|
||||
```
|
||||
|
||||
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
|
||||
the UVM API process. It is a protected management endpoint; it does not stop or
|
||||
delete managed VMs.
|
||||
the serving Nest or UVM process. It is a protected management endpoint; it does
|
||||
not stop or delete managed VMs.
|
||||
|
||||
Do not expose this server directly to an untrusted network. For remote access,
|
||||
keep TLS enabled and consider placing it behind an additional authenticated
|
||||
@@ -570,7 +581,7 @@ Host and API settings can be overridden through environment variables:
|
||||
| `UVM_ROOTFS_SHA256` | Trusted prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` |
|
||||
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
|
||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development |
|
||||
| `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
|
||||
| `UVM_API_TOKEN` | Required for Nest and standalone UVM servers; sent as `X-UVM-Token` |
|
||||
| `UVM_API_TLS_CERT` | TLS certificate path required for a non-loopback API bind |
|
||||
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
|
||||
|
||||
@@ -584,14 +595,18 @@ Selected files:
|
||||
├── USAGE.md
|
||||
├── DEVELOPER.md
|
||||
├── pyproject.toml
|
||||
├── uvm.py # Executable compatibility launcher
|
||||
├── uvm/ # Private application implementation
|
||||
├── nest.py # Executable Nest workspace launcher
|
||||
├── nest/ # Nest workspace application
|
||||
│ ├── cli.py # nest --serve entry point
|
||||
│ ├── server.py # Workspace server, composing the UVM management API
|
||||
│ └── dashboard/ # Nest document route and local HTML, CSS, JS, and fonts
|
||||
├── uvm.py # Executable UVM compatibility launcher
|
||||
├── uvm/ # Machine-management implementation
|
||||
│ ├── cli.py # argparse, terminal output, SSH execution
|
||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
|
||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
||||
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
|
||||
│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript
|
||||
│ ├── routers/ # FastAPI routes, including the dashboard document
|
||||
│ ├── routers/ # UVM management API routes
|
||||
│ └── firecracker/ # Firecracker config, API, and process adapters
|
||||
└── tests/ # Unit tests and opt-in isolated namespace packet tests
|
||||
```
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# UVM Usage Guide
|
||||
# Nest and UVM Usage Guide
|
||||
|
||||
This guide explains how to install, operate, and troubleshoot UVM as an
|
||||
operator. It covers the command-line tool, the optional FastAPI server, common
|
||||
scenarios, and expected limitations.
|
||||
This guide explains how to install, operate, and troubleshoot Nest and its UVM
|
||||
machine-management backend. It covers the Nest dashboard, UVM command-line
|
||||
tool, FastAPI server, common scenarios, and expected limitations.
|
||||
|
||||
For the project architecture and contributor guidance, see
|
||||
[DEVELOPER.md](DEVELOPER.md). For a concise overview, see [README.md](README.md).
|
||||
@@ -68,20 +68,23 @@ lscpu | grep -i virtualization
|
||||
From this project root:
|
||||
|
||||
```sh
|
||||
./nest.py --help
|
||||
./uvm.py --help
|
||||
python3 -B -m uvm --help
|
||||
```
|
||||
|
||||
To install the optional HTTP API dependencies and the console command:
|
||||
To install the HTTP server dependencies and console commands:
|
||||
|
||||
```sh
|
||||
python3 -m pip install .
|
||||
nest --help
|
||||
uvm --help
|
||||
```
|
||||
|
||||
The rest of this guide uses `./uvm.py` so it works from a source checkout. If
|
||||
you installed the package, replace `./uvm.py` with `uvm`. For the API, use the
|
||||
Python environment containing the installed dependencies; under `sudo`, use
|
||||
you installed the package, replace `./uvm.py` with `uvm` and `./nest.py` with
|
||||
`nest`. For the server, use the Python environment containing the installed
|
||||
dependencies; under `sudo`, use
|
||||
the full path to that environment's console script when necessary.
|
||||
|
||||
### Privilege Rules
|
||||
@@ -107,6 +110,7 @@ Examples elsewhere are separate scenarios; substitute your VM identifiers
|
||||
and do not run every destructive example as a single script.
|
||||
|
||||
```text
|
||||
nest --serve [--host HOST] [--port PORT]
|
||||
uvm install [--force]
|
||||
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
|
||||
[--username USER] [--password PASSWORD | --password-stdin]
|
||||
@@ -144,7 +148,8 @@ not disable confirmations, and errors may still be plain text on stderr.
|
||||
### Stop the Management API
|
||||
|
||||
The API can be stopped remotely through its authenticated shutdown endpoint.
|
||||
This stops only the UVM API process; it does not stop or delete any VM:
|
||||
This stops the serving Nest or standalone UVM process; it does not stop or
|
||||
delete any VM:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
@@ -766,31 +771,38 @@ UVM will not blindly signal that PID or delete a possibly in-use disk. Listing
|
||||
does not perform adoption. Inspect such failures rather than manually
|
||||
inventing identity fields in `state.json`.
|
||||
|
||||
## 10. Use the FastAPI Server
|
||||
## 10. Use the Nest Workspace and FastAPI Server
|
||||
|
||||
The API uses the same local lifecycle services and exposes health, installation,
|
||||
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
|
||||
controls. The local dashboard at `/dashboard` uses those same endpoints. There
|
||||
API also supports fixed-target bulk actions; interactive SSH remains CLI-only.
|
||||
controls. Nest owns the local dashboard at `/dashboard` and serves it alongside
|
||||
these UVM-backed endpoints. The API also supports fixed-target bulk actions;
|
||||
interactive SSH remains CLI-only.
|
||||
The server does not autostart VMs or reconcile rules, and is not safe to expose
|
||||
directly to the public internet.
|
||||
|
||||
### Start a Local Server
|
||||
|
||||
After installing the project dependencies, start the server as root:
|
||||
After installing the project dependencies, start the Nest workspace as root:
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
||||
|
||||
sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
||||
./nest.py --serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at
|
||||
`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at
|
||||
`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`.
|
||||
|
||||
The installed command is `nest --serve`; `python3 -B -m nest --serve` also
|
||||
works from the source checkout. Nest uses `UVM_API_TOKEN` and the `UVM_*` host
|
||||
settings for its machine-management backend. To launch the standalone UVM API,
|
||||
use `./uvm.py --serve` with the same host/port options and configuration. That
|
||||
server exposes the management endpoints and `/docs`; Nest serves the dashboard.
|
||||
|
||||
The API token is required even for a loopback server. All VM, diagnostics, and
|
||||
installation routes require it. The dashboard shell, liveness route, and FastAPI's
|
||||
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
|
||||
@@ -814,7 +826,7 @@ sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||
./uvm.py --serve --host 0.0.0.0 --port 8443
|
||||
./nest.py --serve --host 0.0.0.0 --port 8443
|
||||
```
|
||||
|
||||
For real remote use, use a certificate trusted by clients and restrict network
|
||||
@@ -845,8 +857,8 @@ curl --cacert /path/to/ca.pem \
|
||||
|
||||
| Method | Path | Operation |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | Redirect to `/dashboard` |
|
||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
||||
| `GET` | `/` | Redirect to `/dashboard` (Nest server) |
|
||||
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||
| `GET` | `/health` | Unauthenticated liveness |
|
||||
| `GET` | `/diagnostics` | Run host readiness checks |
|
||||
| `POST` | `/install` | Install/refresh with a `force` boolean |
|
||||
@@ -1318,7 +1330,7 @@ matching checksum with the required drivers instead. For an explicitly
|
||||
unverified development installation, retain the existing
|
||||
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` setting as described in the installation section.
|
||||
|
||||
Restart a running UVM API server to load the updated code before using dashboard
|
||||
Restart a running Nest server to load the updated code before using dashboard
|
||||
start/restart actions. Look for `Started OpenBSD Secure Shell server` in the new
|
||||
boot log and confirm an actual SSH connection. `start` on an already-running
|
||||
VM only refreshes networking; use a stopped VM or `restart` to attach the device.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-tree launcher for the Nest workspace."""
|
||||
|
||||
from nest.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Nest workspace application."""
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Run the Nest workspace with ``python -m nest``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""Command-line entry point for the Nest workspace server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from uvm.app import build_application
|
||||
from uvm.errors import UvmError
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="nest",
|
||||
description="Nest dashboard and local machine management workspace.",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("--serve", action="store_true", help="launch the Nest workspace server")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not args.serve:
|
||||
parser.error("--serve is required to launch the Nest workspace")
|
||||
if not 1 <= args.port <= 65535:
|
||||
parser.error("--port must be between 1 and 65535")
|
||||
|
||||
try:
|
||||
from .server import run_server
|
||||
|
||||
application = build_application(emit=lambda message: print(message, file=sys.stderr))
|
||||
run_server(application, host=args.host, port=args.port)
|
||||
return 0
|
||||
except UvmError as error:
|
||||
print(f"nest: error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print("nest: interrupted", file=sys.stderr)
|
||||
return 130
|
||||
@@ -0,0 +1 @@
|
||||
"""Packaged frontend assets for the Nest workspace."""
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Dashboard frontend routes."""
|
||||
"""Nest dashboard document route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
|
||||
_DASHBOARD_HTML = Path(__file__).parent / "static" / "index.html"
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -5,11 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#e8e7ed" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>UVM Control</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 11v7a7 7 0 0 0 14 0v-7' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||||
<title>Nest</title>
|
||||
<meta name="description" content="Nest — a local workspace for your machines, network, and infrastructure." />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 22V10l14 12V10' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=2" />
|
||||
<script src="/dashboard/static/js/dashboard.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
|
||||
<script src="/dashboard/static/js/dashboard.js?v=3" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
@@ -47,8 +48,8 @@
|
||||
<section id="auth-screen" class="auth-screen">
|
||||
<div class="auth-window">
|
||||
<div class="auth-story">
|
||||
<a href="#overview" class="brand" aria-label="UVM Control"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your compute.<br />A little calmer.</h1><p>A considered workspace for your machines,<br />your network, and everything in between.</p></div>
|
||||
<a href="#overview" class="brand" aria-label="Nest workspace"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your workspace.<br />Right at home.</h1><p>A home for your machines,<br />your network, and your next idea.</p></div>
|
||||
<div class="auth-sculpture" aria-hidden="true">
|
||||
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
|
||||
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
|
||||
@@ -56,13 +57,13 @@
|
||||
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
|
||||
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
|
||||
</div>
|
||||
<span class="auth-story-footer">MicroVMs, thoughtfully managed.</span>
|
||||
<span class="auth-story-footer">Your infrastructure, thoughtfully managed.</span>
|
||||
</div>
|
||||
<main class="auth-panel">
|
||||
<div class="auth-panel-inner">
|
||||
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
|
||||
<span class="subtle-label">Your local workspace</span>
|
||||
<h2>Welcome to UVM</h2>
|
||||
<h2>Welcome to Nest</h2>
|
||||
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
|
||||
<form id="auth-form">
|
||||
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
|
||||
@@ -80,8 +81,8 @@
|
||||
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
|
||||
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
|
||||
<div class="sidebar">
|
||||
<a class="brand" href="#overview" aria-label="UVM Control overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Management</span><span id="sidebar-subtitle">Overview</span></h2></div>
|
||||
<a class="brand" href="#overview" aria-label="Nest workspace overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
|
||||
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Workspace</span><span id="sidebar-subtitle">Overview</span></h2></div>
|
||||
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
|
||||
<nav class="primary-nav" aria-label="Primary">
|
||||
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
|
||||
@@ -92,7 +93,7 @@
|
||||
</nav>
|
||||
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="workspace-identity"><span class="workspace-avatar">u</span><div><strong>Local workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
|
||||
<div class="workspace-identity"><span class="workspace-avatar">n</span><div><strong>Local Nest workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
|
||||
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +122,7 @@
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
|
||||
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Workspace</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
|
||||
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Nest</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
|
||||
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
|
||||
<div class="global-search">
|
||||
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
|
||||
@@ -134,7 +135,7 @@
|
||||
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
|
||||
|
||||
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
|
||||
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Workspace overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
|
||||
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Nest overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
|
||||
<div class="overview-metrics">
|
||||
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
|
||||
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
|
||||
@@ -161,7 +162,7 @@
|
||||
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
|
||||
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
|
||||
<div id="network-summary" class="compact-metrics"></div>
|
||||
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>UVM control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
|
||||
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>Nest control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
|
||||
</section>
|
||||
|
||||
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
|
||||
@@ -211,6 +212,6 @@
|
||||
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
|
||||
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
|
||||
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
|
||||
<noscript><p class="noscript-message">Enable JavaScript to connect to your UVM workspace.</p></noscript>
|
||||
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||
const renderCache = new WeakMap();
|
||||
const pages = {
|
||||
overview: { label: 'Overview', title: 'Management', subtitle: 'Overview', icon: 'grid' },
|
||||
overview: { label: 'Overview', title: 'Workspace', subtitle: 'Overview', icon: 'grid' },
|
||||
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
|
||||
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
||||
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
||||
@@ -594,7 +594,7 @@
|
||||
text('#detail-heading', machineName(vm));
|
||||
text('#detail-id', vm.id);
|
||||
html('#detail-status', badge(runtimeStatus(vm)));
|
||||
document.title = `${machineName(vm)} · UVM Control`;
|
||||
document.title = `${machineName(vm)} · Nest`;
|
||||
const busy = state.busy.has(vm.id);
|
||||
const running = runtimeStatus(vm) === 'running';
|
||||
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
|
||||
@@ -1146,7 +1146,7 @@
|
||||
text('#sidebar-title', pages[value].title);
|
||||
text('#sidebar-subtitle', pages[value].subtitle);
|
||||
text('#breadcrumb', pages[value].label);
|
||||
document.title = `${pages[value].label} · UVM Control`;
|
||||
document.title = `${pages[value].label} · Nest`;
|
||||
$('#main-content').scrollTop = 0;
|
||||
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
if (value === 'vm-detail') {
|
||||
@@ -1163,8 +1163,8 @@
|
||||
|
||||
function storeToken(token) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem('uvm_token', token);
|
||||
else sessionStorage.removeItem('uvm_token');
|
||||
if (token) sessionStorage.setItem('nest_token', token);
|
||||
else sessionStorage.removeItem('nest_token');
|
||||
} catch { /* A restricted browser can still use an in-memory session. */ }
|
||||
}
|
||||
|
||||
@@ -1268,7 +1268,7 @@
|
||||
$('#auth-screen').hidden = false;
|
||||
$('#refresh-btn').classList.remove('is-loading');
|
||||
$('#toast-container').replaceChildren();
|
||||
document.title = 'UVM Control';
|
||||
document.title = 'Nest';
|
||||
$('#token-input').focus();
|
||||
}
|
||||
|
||||
@@ -1427,7 +1427,7 @@
|
||||
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
||||
updateCreateSummary();
|
||||
let stored = '';
|
||||
try { stored = sessionStorage.getItem('uvm_token') || ''; } catch { /* Session storage is optional. */ }
|
||||
try { stored = sessionStorage.getItem('nest_token') || ''; } catch { /* Session storage is optional. */ }
|
||||
if (stored) connect(stored);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Compose the Nest dashboard with its local UVM management backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from uvm.app import Application
|
||||
from uvm.errors import UvmError
|
||||
from uvm.server import create_api as create_uvm_api, run_server as run_api_server
|
||||
|
||||
|
||||
def create_app(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||
"""Build the Nest workspace after validating the intended server binding."""
|
||||
if host is None:
|
||||
raise UvmError("create_app requires an explicit host. Start the workspace with nest --serve.")
|
||||
|
||||
api = create_uvm_api(application, host=host)
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .dashboard.router import router as dashboard_router
|
||||
|
||||
api.title = "Nest"
|
||||
api.description = "Nest workspace API, with local Firecracker machine management powered by UVM."
|
||||
static_dir = Path(__file__).parent / "dashboard" / "static"
|
||||
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
|
||||
api.include_router(dashboard_router, prefix="/dashboard")
|
||||
|
||||
@api.get("/")
|
||||
async def root() -> Any:
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
return api
|
||||
|
||||
|
||||
def run_server(application: Application, *, host: str, port: int) -> None:
|
||||
"""Launch Nest using the shared authenticated, TLS-aware server runner."""
|
||||
run_api_server(application, host=host, port=port, api_factory=create_app)
|
||||
+5
-3
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "uvm-cli"
|
||||
version = "0.1.0"
|
||||
description = "Local Firecracker microVM command-line manager"
|
||||
description = "Nest workspace and local Firecracker microVM management"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
@@ -17,10 +17,12 @@ dependencies = [
|
||||
test = ["httpx>=0.27,<1"]
|
||||
|
||||
[project.scripts]
|
||||
nest = "nest.cli:main"
|
||||
uvm = "uvm.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"]
|
||||
packages = ["nest", "nest.dashboard", "uvm", "uvm.firecracker", "uvm.routers"]
|
||||
include-package-data = false
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
|
||||
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from html.parser import HTMLParser
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from nest.cli import main
|
||||
from nest.server import create_app, run_server
|
||||
from tests.test_server import FASTAPI_AVAILABLE, FakeLifecycle, fake_application
|
||||
from uvm.errors import UvmError
|
||||
|
||||
|
||||
if FASTAPI_AVAILABLE:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class NestCliTests(unittest.TestCase):
|
||||
def test_serve_launches_the_nest_workspace(self) -> None:
|
||||
application = fake_application(api_token="test-token")
|
||||
with (
|
||||
patch("nest.cli.build_application", return_value=application),
|
||||
patch("nest.server.run_server") as launch,
|
||||
):
|
||||
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
launch.assert_called_once_with(application, host="127.0.0.1", port=8123)
|
||||
|
||||
def test_invalid_invocations_do_not_build_host_services(self) -> None:
|
||||
for arguments in (
|
||||
[], ["--serve", "list"], ["--serve", "--port", "0"],
|
||||
["--serve", "--port", "65536"], ["--serve", "--port", "1.5"],
|
||||
):
|
||||
with self.subTest(arguments=arguments), redirect_stderr(io.StringIO()):
|
||||
with patch("nest.cli.build_application") as build:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
main(arguments)
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
build.assert_not_called()
|
||||
|
||||
def test_startup_errors_are_reported_as_nest_errors(self) -> None:
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch("nest.cli.build_application", side_effect=UvmError("invalid settings")),
|
||||
redirect_stderr(output),
|
||||
):
|
||||
result = main(["--serve"])
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(output.getvalue(), "nest: error: invalid settings\n")
|
||||
|
||||
def test_factory_requires_an_explicit_host(self) -> None:
|
||||
with self.assertRaisesRegex(UvmError, "nest --serve"):
|
||||
create_app(fake_application(api_token="test-token"))
|
||||
|
||||
def test_workspace_enforces_backend_token_and_tls_requirements(self) -> None:
|
||||
for token, host in ((None, "127.0.0.1"), ("test-token", "0.0.0.0")):
|
||||
for launch in (create_app, run_server):
|
||||
with self.subTest(token=token, host=host, launch=launch.__name__):
|
||||
options = {"port": 8000} if launch is run_server else {}
|
||||
with self.assertRaises(UvmError):
|
||||
launch(fake_application(api_token=token), host=host, **options)
|
||||
|
||||
|
||||
class _DashboardAssets(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.urls: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag, attrs) -> None:
|
||||
attributes = dict(attrs)
|
||||
if tag == "link" and attributes.get("rel") in {"preload", "stylesheet"}:
|
||||
self.urls.append(attributes["href"])
|
||||
if tag == "script" and attributes.get("src"):
|
||||
self.urls.append(attributes["src"])
|
||||
|
||||
|
||||
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for workspace tests")
|
||||
class NestWorkspaceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.application = fake_application(api_token="test-token")
|
||||
self.application.lifecycle = FakeLifecycle()
|
||||
self.application.installer = SimpleNamespace(install=Mock())
|
||||
self.headers = {"X-UVM-Token": "test-token"}
|
||||
self.api = create_app(self.application, host="127.0.0.1")
|
||||
self.client = TestClient(self.api)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def test_nest_dashboard_and_its_linked_assets_are_public(self) -> None:
|
||||
root = self.client.get("/", follow_redirects=False)
|
||||
self.assertEqual(root.status_code, 307)
|
||||
self.assertEqual(root.headers["location"], "/dashboard")
|
||||
dashboard = self.client.get(root.headers["location"])
|
||||
self.assertEqual(dashboard.status_code, 200)
|
||||
self.assertIn("<title>Nest</title>", dashboard.text)
|
||||
self.assertIn("Welcome to Nest", dashboard.text)
|
||||
self.assertNotIn("test-token", dashboard.text)
|
||||
|
||||
assets = _DashboardAssets()
|
||||
assets.feed(dashboard.text)
|
||||
self.assertTrue(assets.urls)
|
||||
for url in assets.urls:
|
||||
with self.subTest(url=url):
|
||||
self.assertTrue(url.startswith("/dashboard/static/"))
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.content)
|
||||
self.assertNotIn("text/html", response.headers["content-type"])
|
||||
|
||||
font = self.client.get("/dashboard/static/fonts/manrope-latin.woff2")
|
||||
self.assertTrue(font.content.startswith(b"wOF2"))
|
||||
license_response = self.client.get("/dashboard/static/fonts/OFL.txt")
|
||||
self.assertEqual(license_response.status_code, 200)
|
||||
self.assertIn("SIL OPEN FONT LICENSE", license_response.text)
|
||||
self.assertEqual(self.client.get("/dashboard/static/missing.js").status_code, 404)
|
||||
|
||||
def test_machine_controls_use_the_authenticated_backend(self) -> None:
|
||||
for method, path in (
|
||||
("GET", "/vms"), ("GET", "/diagnostics"),
|
||||
("POST", "/vms/vm-test/stop"), ("POST", "/install"), ("POST", "/exit"),
|
||||
):
|
||||
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
|
||||
with self.subTest(method=method, path=path, headers=headers):
|
||||
response = self.client.request(method, path, headers=headers, json={})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.application.installer.install.assert_not_called()
|
||||
self.assertEqual(self.application.lifecycle.vm.status, "running")
|
||||
|
||||
listed = self.client.get("/vms", headers=self.headers)
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
self.assertEqual(listed.json()[0]["id"], "vm-test")
|
||||
self.assertNotIn("password", listed.json()[0])
|
||||
stopped = self.client.post("/vms/vm-test/stop", headers=self.headers)
|
||||
self.assertEqual(stopped.status_code, 200)
|
||||
self.assertEqual(stopped.json()["status"], "stopped")
|
||||
self.assertEqual(self.application.lifecycle.vm.status, "stopped")
|
||||
|
||||
def test_workspace_preserves_api_errors_and_liveness(self) -> None:
|
||||
missing = self.client.get("/vms/missing", headers=self.headers)
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
self.assertEqual(missing.json(), {"error": {"message": "VM not found: missing"}})
|
||||
health = self.client.get("/health")
|
||||
self.assertEqual(health.status_code, 200)
|
||||
self.assertEqual(health.json(), {"status": "ok"})
|
||||
|
||||
def test_workspace_api_reference_includes_the_dashboard_and_machine_routes(self) -> None:
|
||||
schema = self.client.get("/openapi.json").json()
|
||||
self.assertEqual(schema["info"]["title"], "Nest")
|
||||
for path in ("/", "/dashboard", "/vms", "/diagnostics", "/vms/{vm_id}/resize"):
|
||||
self.assertIn(path, schema["paths"])
|
||||
|
||||
def test_launcher_serves_nest_and_wires_graceful_shutdown(self) -> None:
|
||||
server = SimpleNamespace(run=Mock(), should_exit=False)
|
||||
config = object()
|
||||
fake_uvicorn = SimpleNamespace(Config=Mock(return_value=config), Server=Mock(return_value=server))
|
||||
with patch.dict(sys.modules, {"uvicorn": fake_uvicorn}):
|
||||
run_server(self.application, host="127.0.0.1", port=8123)
|
||||
|
||||
api = fake_uvicorn.Config.call_args.args[0]
|
||||
fake_uvicorn.Config.assert_called_once_with(api, host="127.0.0.1", port=8123)
|
||||
fake_uvicorn.Server.assert_called_once_with(config)
|
||||
server.run.assert_called_once_with()
|
||||
self.assertIs(api.state.uvicorn_server, server)
|
||||
with TestClient(api) as client:
|
||||
self.assertIn("<title>Nest</title>", client.get("/dashboard").text)
|
||||
response = client.post("/exit", headers=self.headers)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(response.json(), {"status": "stopping"})
|
||||
self.assertTrue(server.should_exit)
|
||||
+8
-18
@@ -322,20 +322,10 @@ class ApiRouterTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
|
||||
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
|
||||
root = self.client.get("/", follow_redirects=False)
|
||||
dashboard = self.client.get("/dashboard")
|
||||
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
|
||||
script = self.client.get("/dashboard/static/js/dashboard.js")
|
||||
|
||||
self.assertEqual(root.status_code, 307)
|
||||
self.assertEqual(root.headers["location"], "/dashboard")
|
||||
self.assertEqual(dashboard.status_code, 200)
|
||||
self.assertIn("UVM Control", dashboard.text)
|
||||
self.assertEqual(stylesheet.status_code, 200)
|
||||
self.assertIn("--acid", stylesheet.text)
|
||||
self.assertEqual(script.status_code, 200)
|
||||
self.assertIn("sessionStorage", script.text)
|
||||
def test_standalone_uvm_api_does_not_serve_the_nest_dashboard(self) -> None:
|
||||
for path in ("/", "/dashboard", "/dashboard/static/js/dashboard.js"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.get(path).status_code, 404)
|
||||
|
||||
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
||||
for method, path, payload in (
|
||||
@@ -381,7 +371,9 @@ class ApiRouterTests(unittest.TestCase):
|
||||
response = self.client.post("/exit", headers=self.headers)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json(), {"detail": "server shutdown is unavailable outside uvm --serve"})
|
||||
self.assertEqual(response.json(), {
|
||||
"detail": "server shutdown is unavailable outside nest --serve or uvm --serve",
|
||||
})
|
||||
|
||||
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||
created = self.client.post(
|
||||
@@ -773,14 +765,12 @@ class ApiRouterTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
|
||||
|
||||
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
|
||||
def test_api_schema_exposes_management_operations(self) -> None:
|
||||
schema = self.client.get("/openapi.json").json()
|
||||
|
||||
self.assertEqual(
|
||||
{(path, method) for path, methods in schema["paths"].items() for method in methods},
|
||||
{
|
||||
("/", "get"),
|
||||
("/dashboard", "get"),
|
||||
("/health", "get"),
|
||||
("/exit", "post"),
|
||||
("/install", "post"),
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
"""The sole public interface of this project: the local uvm CLI."""
|
||||
"""Command-line interface for UVM's local machine management services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -56,7 +56,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--serve",
|
||||
action="store_true",
|
||||
help="launch the FastAPI management server instead of running a CLI command",
|
||||
help="launch the UVM management API; use nest --serve for the workspace dashboard",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# UVM Dashboard static assets package.
|
||||
+1
-1
@@ -25,7 +25,7 @@ def exit_server(
|
||||
if server is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="server shutdown is unavailable outside uvm --serve",
|
||||
detail="server shutdown is unavailable outside nest --serve or uvm --serve",
|
||||
)
|
||||
background_tasks.add_task(_request_server_exit, server)
|
||||
return ServerExitResponse()
|
||||
|
||||
+10
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from ipaddress import ip_address
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
@@ -27,8 +28,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
except ModuleNotFoundError as error:
|
||||
raise UvmError(
|
||||
"FastAPI server support is not installed. Install this project with its dependencies."
|
||||
@@ -39,7 +39,6 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
||||
from .routers.installation import router as installation_router
|
||||
from .routers.vms import router as vms_router
|
||||
from .routers.diagnostics import router as diagnostics_router
|
||||
from .routers.dashboard import router as dashboard_router
|
||||
|
||||
api = FastAPI(
|
||||
title="uvm",
|
||||
@@ -61,18 +60,16 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
||||
api.include_router(vms_router)
|
||||
api.include_router(diagnostics_router)
|
||||
|
||||
static_dir = Path(__file__).parent / "dashboard" / "static"
|
||||
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
|
||||
api.include_router(dashboard_router, prefix="/dashboard")
|
||||
|
||||
@api.get("/")
|
||||
async def root() -> Any:
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
return api
|
||||
|
||||
|
||||
def run_server(application: Application, *, host: str, port: int) -> None:
|
||||
def run_server(
|
||||
application: Application,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
api_factory: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
"""Run Uvicorn after enforcing the local-management security boundary."""
|
||||
|
||||
settings = application.settings
|
||||
@@ -93,7 +90,7 @@ def run_server(application: Application, *, host: str, port: int) -> None:
|
||||
if settings.api_tls_cert is not None:
|
||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
||||
api = create_api(application, host=host)
|
||||
api = (api_factory or create_api)(application, host=host)
|
||||
server = uvicorn.Server(uvicorn.Config(api, **options))
|
||||
api.state.uvicorn_server = server
|
||||
server.run()
|
||||
|
||||
Reference in New Issue
Block a user