feat: introduce Nest workspace for local Firecracker management
- Added a new server module for the Nest workspace, integrating with UVM's API. - Updated project description to reflect the new Nest functionality. - Created tests for the Nest server and CLI, ensuring proper functionality and error handling. - Refactored existing UVM server code to separate Nest and UVM functionalities. - Removed the old UVM dashboard assets package as it is now part of the Nest workspace. - Enhanced error messages for clarity regarding server shutdown commands.
This commit is contained in:
+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
|
before changing lifecycle behavior, host networking, Firecracker launch
|
||||||
semantics, persistent state, or the HTTP API.
|
semantics, persistent state, or the HTTP API.
|
||||||
|
|
||||||
`uvm` is a local, privileged Firecracker microVM manager. It is an
|
Nest owns the workspace dashboard and its server entry point. Its `uvm`
|
||||||
application with a CLI and optional FastAPI management server. It is not a
|
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.
|
general-purpose Python library or a multi-host control plane.
|
||||||
|
|
||||||
## 1. Goals and Boundaries
|
## 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
|
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
|
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,
|
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
|
expose health, installation, VM lifecycle controls, and managed port-policy
|
||||||
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
|
||||||
operations. Interactive SSH remains CLI-only.
|
operations. Interactive SSH remains CLI-only.
|
||||||
@@ -30,16 +31,18 @@ flag reference, see [USAGE.md](USAGE.md).
|
|||||||
|
|
||||||
## 2. First Run
|
## 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
|
```sh
|
||||||
python3 -m pip install -e '.[test]'
|
python3 -m pip install -e '.[test]'
|
||||||
python3 -B -m unittest discover -s tests -v
|
python3 -B -m unittest discover -s tests -v
|
||||||
|
./nest.py --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The `uvm` console script is registered by `pyproject.toml`. During source
|
The `nest` and `uvm` console scripts are registered by `pyproject.toml`.
|
||||||
development, `./uvm.py` and `python3 -B -m uvm` are equivalent entry points.
|
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:
|
Requirements for real host operations:
|
||||||
|
|
||||||
@@ -63,7 +66,15 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
├── README.md # Project overview
|
├── README.md # Project overview
|
||||||
├── USAGE.md # Operator command/deployment guide
|
├── USAGE.md # Operator command/deployment guide
|
||||||
├── pyproject.toml # Package metadata and dependencies
|
├── 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/
|
├── uvm/
|
||||||
│ ├── __main__.py # `python -m uvm`
|
│ ├── __main__.py # `python -m uvm`
|
||||||
│ ├── cli.py # argparse, CLI rendering, serve dispatch
|
│ ├── 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
|
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
|
||||||
│ ├── api_models.py # Pydantic HTTP request/response models
|
│ ├── api_models.py # Pydantic HTTP request/response models
|
||||||
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
||||||
│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS
|
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
│ │ ├── dashboard.py # Dashboard document route
|
|
||||||
│ │ ├── diagnostics.py # Host diagnostic endpoint
|
│ │ ├── diagnostics.py # Host diagnostic endpoint
|
||||||
│ │ ├── dependencies.py # API application/token dependencies
|
│ │ ├── dependencies.py # API application/token dependencies
|
||||||
|
│ │ ├── exit.py # Authenticated server shutdown
|
||||||
│ │ ├── health.py # Liveness endpoint
|
│ │ ├── health.py # Liveness endpoint
|
||||||
│ │ ├── installation.py # Installation endpoint
|
│ │ ├── installation.py # Installation endpoint
|
||||||
│ │ └── vms.py # VM lifecycle endpoints
|
│ │ └── vms.py # VM lifecycle endpoints
|
||||||
@@ -106,8 +116,10 @@ are intentionally small and operationally focused.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
CLI[CLI: uvm] --> APP[app.py composition root]
|
DASH[Nest dashboard] --> NEST[nest/server.py workspace]
|
||||||
HTTP[FastAPI server] --> APP
|
NEST --> HTTP[UVM management API]
|
||||||
|
CLI[CLI: uvm] --> APP[uvm/app.py composition root]
|
||||||
|
HTTP --> APP
|
||||||
APP --> INSTALL[Installer]
|
APP --> INSTALL[Installer]
|
||||||
APP --> LIFE[LifecycleService]
|
APP --> LIFE[LifecycleService]
|
||||||
APP --> STATE[StateStore]
|
APP --> STATE[StateStore]
|
||||||
@@ -125,8 +137,9 @@ flowchart LR
|
|||||||
FC --> KVM[/dev/kvm]
|
FC --> KVM[/dev/kvm]
|
||||||
```
|
```
|
||||||
|
|
||||||
The public entry points are thin adapters. The lifecycle layer is the only
|
The public entry points are thin adapters. Nest composes the UVM API and adds
|
||||||
place that coordinates multiple host resources for a VM. Lower-level modules
|
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`.
|
must not import `cli.py`, `server.py`, or `lifecycle.py`.
|
||||||
|
|
||||||
Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.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
|
### 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,
|
Routers validate the token and Pydantic request, invoke application services,
|
||||||
and render whitelisted response models. Lifecycle, managed port-policy,
|
and render whitelisted response models. Lifecycle, managed port-policy,
|
||||||
diagnostics, and bounded log-tail operations have explicit HTTP routes for the
|
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
|
validation: it stores a supplied token only for the active browser session and
|
||||||
sends it with management requests.
|
sends it with management requests.
|
||||||
|
|
||||||
Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The
|
Launch the workspace with `nest --serve` or `./nest.py --serve`. The standalone
|
||||||
`create_api()` factory requires an explicit host specifically to prevent it
|
backend uses `uvm --serve` or `./uvm.py --serve`. Both `create_app()` and
|
||||||
from being used as an insecure generic Uvicorn factory.
|
`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
|
## 6. Application Composition
|
||||||
|
|
||||||
@@ -837,8 +857,9 @@ Python's safe tar extraction filter.
|
|||||||
### Server Startup
|
### Server Startup
|
||||||
|
|
||||||
See [USAGE.md](USAGE.md) for dependency installation, loopback deployment,
|
See [USAGE.md](USAGE.md) for dependency installation, loopback deployment,
|
||||||
TLS deployment, and authenticated curl examples. Launch through `uvm --serve`
|
TLS deployment, and authenticated curl examples. Launch the Nest workspace
|
||||||
using the Python environment with the installed API dependencies. Every server
|
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
|
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
|
require `UVM_API_TLS_CERT` and `UVM_API_TLS_KEY`; paired certificate/key files
|
||||||
are loaded into an `ssl.SSLContext` before Uvicorn starts.
|
are loaded into an `ssl.SSLContext` before Uvicorn starts.
|
||||||
@@ -851,10 +872,13 @@ or secret manager when that matters.
|
|||||||
|
|
||||||
### API Contract
|
### 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 |
|
| Method | Route | Router | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
|
| `GET` | `/` | `nest/server.py` | Redirect to `/dashboard` (Nest only) |
|
||||||
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
|
| `GET` | `/dashboard` | `nest/dashboard/router.py` | Public Nest dashboard shell (Nest only) |
|
||||||
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
||||||
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
|
| `POST` | `/exit` | `exit.py` | Request graceful Uvicorn shutdown |
|
||||||
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
|
| `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>
|
X-UVM-Token: <UVM_API_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /exit` is a management route. `run_server()` creates the Uvicorn server,
|
`POST /exit` is a management route. The shared `uvm.server.run_server()` creates
|
||||||
stores it on the FastAPI application state, and runs it in-process. The route
|
the Uvicorn server, stores it on the FastAPI application state, and runs it
|
||||||
returns `202` before a background task sets `should_exit`, allowing Uvicorn to
|
in-process. The route returns `202` before a background task sets `should_exit`,
|
||||||
deliver the response and perform its normal graceful shutdown. It does not
|
allowing Uvicorn to deliver the response and perform its normal graceful
|
||||||
stop or delete any managed VM.
|
shutdown for Nest or UVM. It does not stop or delete any managed VM.
|
||||||
|
|
||||||
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
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`,
|
422 response format. Create accepts `name: str | null`, `ports: list | null`,
|
||||||
and strict boolean `no_default_ports` (default false). Port objects contain a
|
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
|
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,
|
They cover validation, schema migration/locking/permissions, artifact trust,
|
||||||
private disks/credentials, network/firewall rendering and readback, conntrack
|
private disks/credentials, network/firewall rendering and readback, conntrack
|
||||||
semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting,
|
semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting,
|
||||||
CLI parsing/output, and server security. Mock/fixture policy checks do not
|
CLI parsing/output, Nest dashboard delivery, and server security. Mock/fixture
|
||||||
prove the kernel's actual packet behavior.
|
policy checks do not prove the kernel's actual packet behavior.
|
||||||
|
|
||||||
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
||||||
are installed. They should be enabled in CI using the `test` extra.
|
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
|
When `nft` and `conntrack` are installed, command-parser tests append an invalid
|
||||||
final command/option so parsing is checked without executing any mutation.
|
final command/option so parsing is checked without executing any mutation.
|
||||||
These checks supplement fixture-based readback and cleanup tests; they do not
|
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()`.
|
`get_authorized_application()`, and register new routers in `create_api()`.
|
||||||
Add API tests and update all route tables. New CLI commands do not implicitly
|
Add API tests and update all route tables. New CLI commands do not implicitly
|
||||||
authorize new HTTP endpoints; `/health` remains the only unauthenticated
|
authorize new HTTP endpoints; `/health` remains the only unauthenticated
|
||||||
operational API route, while `/` and `/dashboard` only serve the public
|
operational API route. Nest owns the public `/` and `/dashboard` routes.
|
||||||
dashboard shell.
|
- 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
|
- Host primitives: use the specific network/firewall/install/process module
|
||||||
and `CommandRunner` for external commands. Define ownership, idempotence,
|
and `CommandRunner` for external commands. Define ownership, idempotence,
|
||||||
and compensation before invoking a primitive from lifecycle code. Use fake
|
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.
|
1. Run the complete unit suite.
|
||||||
2. Run `git diff --check`.
|
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.
|
4. Verify command/API errors use `UvmError` rather than raw tracebacks.
|
||||||
5. Verify host mutations have a lock owner and cleanup path.
|
5. Verify host mutations have a lock owner and cleanup path.
|
||||||
6. Verify persistent state remains readable across upgrades.
|
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.
|
Nest is a local infrastructure workspace with a browser dashboard for your
|
||||||
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
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
|
and keeps a private disk and local registry for named VMs. The CLI supports
|
||||||
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
|
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
|
||||||
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
|
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
|
||||||
|
|
||||||
This directory is a self-contained project. All UVM code, tests, packaging
|
This directory is a self-contained project. Nest owns the dashboard and
|
||||||
metadata, launcher scripts, and documentation live here.
|
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
|
For architecture, lifecycle invariants, extension guidance, and contributor
|
||||||
workflows, see [DEVELOPER.md](DEVELOPER.md).
|
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:
|
Run commands from this project root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
./nest.py --help
|
||||||
|
python3 -B -m nest --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
python3 -B -m uvm --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.
|
The project is an application, not a reusable Python SDK.
|
||||||
|
|
||||||
## Requirements
|
## 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
|
yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
|
||||||
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
|
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
|
||||||
|
|
||||||
## Management API
|
## Nest Dashboard and Management API
|
||||||
|
|
||||||
Install the project dependencies before serving the 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 .
|
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):
|
dependencies were installed (use the full console-script path if needed):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
||||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
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.
|
# Equivalent when running the source checkout directly.
|
||||||
sudo env UVM_API_TOKEN="$UVM_API_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
|
||||||
```
|
```
|
||||||
|
|
||||||
The default bind address is `127.0.0.1` and the default port is `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
|
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.
|
there for the current browser session.
|
||||||
|
|
||||||
The dashboard includes a fleet overview, searchable machine collection, private
|
Nest serves the dashboard and UVM-backed management endpoints together, using
|
||||||
network and port-policy view, host diagnostics, and machine provisioning. Open a
|
the existing `UVM_API_TOKEN` and `UVM_*` host configuration. The standalone UVM
|
||||||
machine to manage its lifecycle, edit or sync its port rules, copy its SSH command,
|
API is available through `uvm --serve`, with its reference at `/docs`; the `/`
|
||||||
or read recent logs. Select machines in the collection for bulk operations.
|
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
|
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
|
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
|
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 |
|
| Method | Route | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/` | Redirect to the local dashboard |
|
| `GET` | `/` | Redirect to the dashboard (Nest server) |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||||
| `GET` | `/health` | Unauthenticated liveness response |
|
| `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 |
|
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
||||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||||
| `GET` | `/vms` | List VM records and observed status |
|
| `GET` | `/vms` | List VM records and observed status |
|
||||||
@@ -365,7 +376,7 @@ sudo env \
|
|||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
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:
|
Send the token on management requests:
|
||||||
@@ -385,8 +396,8 @@ curl -X POST \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
|
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
|
the serving Nest or UVM process. It is a protected management endpoint; it does
|
||||||
delete managed VMs.
|
not stop or delete managed VMs.
|
||||||
|
|
||||||
Do not expose this server directly to an untrusted network. For remote access,
|
Do not expose this server directly to an untrusted network. For remote access,
|
||||||
keep TLS enabled and consider placing it behind an additional authenticated
|
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_ROOTFS_SHA256` | Trusted prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` |
|
||||||
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
|
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
|
||||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development |
|
| `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_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 |
|
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
|
||||||
|
|
||||||
@@ -584,14 +595,18 @@ Selected files:
|
|||||||
├── USAGE.md
|
├── USAGE.md
|
||||||
├── DEVELOPER.md
|
├── DEVELOPER.md
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── uvm.py # Executable compatibility launcher
|
├── nest.py # Executable Nest workspace launcher
|
||||||
├── uvm/ # Private application implementation
|
├── 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
|
│ ├── cli.py # argparse, terminal output, SSH execution
|
||||||
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
|
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
|
||||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
||||||
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
|
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
|
||||||
│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript
|
│ ├── routers/ # UVM management API routes
|
||||||
│ ├── routers/ # FastAPI routes, including the dashboard document
|
|
||||||
│ └── firecracker/ # Firecracker config, API, and process adapters
|
│ └── firecracker/ # Firecracker config, API, and process adapters
|
||||||
└── tests/ # Unit tests and opt-in isolated namespace packet tests
|
└── 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
|
This guide explains how to install, operate, and troubleshoot Nest and its UVM
|
||||||
operator. It covers the command-line tool, the optional FastAPI server, common
|
machine-management backend. It covers the Nest dashboard, UVM command-line
|
||||||
scenarios, and expected limitations.
|
tool, FastAPI server, common scenarios, and expected limitations.
|
||||||
|
|
||||||
For the project architecture and contributor guidance, see
|
For the project architecture and contributor guidance, see
|
||||||
[DEVELOPER.md](DEVELOPER.md). For a concise overview, see [README.md](README.md).
|
[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:
|
From this project root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
./nest.py --help
|
||||||
./uvm.py --help
|
./uvm.py --help
|
||||||
python3 -B -m uvm --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
|
```sh
|
||||||
python3 -m pip install .
|
python3 -m pip install .
|
||||||
|
nest --help
|
||||||
uvm --help
|
uvm --help
|
||||||
```
|
```
|
||||||
|
|
||||||
The rest of this guide uses `./uvm.py` so it works from a source checkout. If
|
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
|
you installed the package, replace `./uvm.py` with `uvm` and `./nest.py` with
|
||||||
Python environment containing the installed dependencies; under `sudo`, use
|
`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.
|
the full path to that environment's console script when necessary.
|
||||||
|
|
||||||
### Privilege Rules
|
### 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.
|
and do not run every destructive example as a single script.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
nest --serve [--host HOST] [--port PORT]
|
||||||
uvm install [--force]
|
uvm install [--force]
|
||||||
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
|
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
|
||||||
[--username USER] [--password PASSWORD | --password-stdin]
|
[--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
|
### Stop the Management API
|
||||||
|
|
||||||
The API can be stopped remotely through its authenticated shutdown endpoint.
|
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
|
```sh
|
||||||
curl -X POST \
|
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
|
does not perform adoption. Inspect such failures rather than manually
|
||||||
inventing identity fields in `state.json`.
|
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,
|
The API uses the same local lifecycle services and exposes health, installation,
|
||||||
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
|
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
|
||||||
controls. The local dashboard at `/dashboard` uses those same endpoints. There
|
controls. Nest owns the local dashboard at `/dashboard` and serves it alongside
|
||||||
API also supports fixed-target bulk actions; interactive SSH remains CLI-only.
|
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
|
The server does not autostart VMs or reconcile rules, and is not safe to expose
|
||||||
directly to the public internet.
|
directly to the public internet.
|
||||||
|
|
||||||
### Start a Local Server
|
### 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
|
```sh
|
||||||
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
||||||
|
|
||||||
sudo env \
|
sudo env \
|
||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
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
|
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/dashboard`; OpenAPI docs are at
|
||||||
`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`.
|
`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
|
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
|
installation routes require it. The dashboard shell, liveness route, and FastAPI's
|
||||||
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
|
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
|
||||||
@@ -814,7 +826,7 @@ sudo env \
|
|||||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
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
|
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 |
|
| Method | Path | Operation |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/` | Redirect to `/dashboard` |
|
| `GET` | `/` | Redirect to `/dashboard` (Nest server) |
|
||||||
| `GET` | `/dashboard` | Serve the local management dashboard |
|
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
|
||||||
| `GET` | `/health` | Unauthenticated liveness |
|
| `GET` | `/health` | Unauthenticated liveness |
|
||||||
| `GET` | `/diagnostics` | Run host readiness checks |
|
| `GET` | `/diagnostics` | Run host readiness checks |
|
||||||
| `POST` | `/install` | Install/refresh with a `force` boolean |
|
| `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
|
unverified development installation, retain the existing
|
||||||
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` setting as described in the installation section.
|
`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
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#e8e7ed" />
|
<meta name="theme-color" content="#e8e7ed" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>UVM Control</title>
|
<title>Nest</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" />
|
<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="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" />
|
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
|
||||||
<script src="/dashboard/static/js/dashboard.js?v=2" defer></script>
|
<script src="/dashboard/static/js/dashboard.js?v=3" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
@@ -47,8 +48,8 @@
|
|||||||
<section id="auth-screen" class="auth-screen">
|
<section id="auth-screen" class="auth-screen">
|
||||||
<div class="auth-window">
|
<div class="auth-window">
|
||||||
<div class="auth-story">
|
<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>
|
<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 compute.<br />A little calmer.</h1><p>A considered workspace for your machines,<br />your network, and everything in between.</p></div>
|
<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="auth-sculpture" aria-hidden="true">
|
||||||
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
|
<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>
|
<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-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 class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="auth-story-footer">MicroVMs, thoughtfully managed.</span>
|
<span class="auth-story-footer">Your infrastructure, thoughtfully managed.</span>
|
||||||
</div>
|
</div>
|
||||||
<main class="auth-panel">
|
<main class="auth-panel">
|
||||||
<div class="auth-panel-inner">
|
<div class="auth-panel-inner">
|
||||||
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
|
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
|
||||||
<span class="subtle-label">Your local workspace</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>
|
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
|
||||||
<form id="auth-form">
|
<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>
|
<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>
|
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
|
||||||
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
|
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
|
||||||
<div class="sidebar">
|
<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>
|
<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">Management</span><span id="sidebar-subtitle">Overview</span></h2></div>
|
<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>
|
<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">
|
<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>
|
<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>
|
</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-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="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>
|
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header class="topbar">
|
<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>
|
<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>
|
<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">
|
<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>
|
<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>
|
<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>
|
<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">
|
<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 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>
|
<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>
|
<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 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 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>
|
||||||
|
|
||||||
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
|
<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="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
|
||||||
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
|
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
|
||||||
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
|
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||||
const renderCache = new WeakMap();
|
const renderCache = new WeakMap();
|
||||||
const pages = {
|
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' },
|
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
|
||||||
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
||||||
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
||||||
@@ -594,7 +594,7 @@
|
|||||||
text('#detail-heading', machineName(vm));
|
text('#detail-heading', machineName(vm));
|
||||||
text('#detail-id', vm.id);
|
text('#detail-id', vm.id);
|
||||||
html('#detail-status', badge(runtimeStatus(vm)));
|
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 busy = state.busy.has(vm.id);
|
||||||
const running = runtimeStatus(vm) === 'running';
|
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>`;
|
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-title', pages[value].title);
|
||||||
text('#sidebar-subtitle', pages[value].subtitle);
|
text('#sidebar-subtitle', pages[value].subtitle);
|
||||||
text('#breadcrumb', pages[value].label);
|
text('#breadcrumb', pages[value].label);
|
||||||
document.title = `${pages[value].label} · UVM Control`;
|
document.title = `${pages[value].label} · Nest`;
|
||||||
$('#main-content').scrollTop = 0;
|
$('#main-content').scrollTop = 0;
|
||||||
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
if (value === 'vm-detail') {
|
if (value === 'vm-detail') {
|
||||||
@@ -1163,8 +1163,8 @@
|
|||||||
|
|
||||||
function storeToken(token) {
|
function storeToken(token) {
|
||||||
try {
|
try {
|
||||||
if (token) sessionStorage.setItem('uvm_token', token);
|
if (token) sessionStorage.setItem('nest_token', token);
|
||||||
else sessionStorage.removeItem('uvm_token');
|
else sessionStorage.removeItem('nest_token');
|
||||||
} catch { /* A restricted browser can still use an in-memory session. */ }
|
} catch { /* A restricted browser can still use an in-memory session. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,7 +1268,7 @@
|
|||||||
$('#auth-screen').hidden = false;
|
$('#auth-screen').hidden = false;
|
||||||
$('#refresh-btn').classList.remove('is-loading');
|
$('#refresh-btn').classList.remove('is-loading');
|
||||||
$('#toast-container').replaceChildren();
|
$('#toast-container').replaceChildren();
|
||||||
document.title = 'UVM Control';
|
document.title = 'Nest';
|
||||||
$('#token-input').focus();
|
$('#token-input').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,7 +1427,7 @@
|
|||||||
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
||||||
updateCreateSummary();
|
updateCreateSummary();
|
||||||
let stored = '';
|
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);
|
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]
|
[project]
|
||||||
name = "uvm-cli"
|
name = "uvm-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Local Firecracker microVM command-line manager"
|
description = "Nest workspace and local Firecracker microVM management"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1",
|
"fastapi>=0.115,<1",
|
||||||
@@ -17,10 +17,12 @@ dependencies = [
|
|||||||
test = ["httpx>=0.27,<1"]
|
test = ["httpx>=0.27,<1"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
nest = "nest.cli:main"
|
||||||
uvm = "uvm.cli:main"
|
uvm = "uvm.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[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]
|
[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.status_code, 200)
|
||||||
self.assertEqual(response.json(), {"status": "ok"})
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
|
|
||||||
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
|
def test_standalone_uvm_api_does_not_serve_the_nest_dashboard(self) -> None:
|
||||||
root = self.client.get("/", follow_redirects=False)
|
for path in ("/", "/dashboard", "/dashboard/static/js/dashboard.js"):
|
||||||
dashboard = self.client.get("/dashboard")
|
with self.subTest(path=path):
|
||||||
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
|
self.assertEqual(self.client.get(path).status_code, 404)
|
||||||
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_vm_routes_require_and_accept_the_api_token(self) -> None:
|
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
||||||
for method, path, payload in (
|
for method, path, payload in (
|
||||||
@@ -381,7 +371,9 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
response = self.client.post("/exit", headers=self.headers)
|
response = self.client.post("/exit", headers=self.headers)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 503)
|
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:
|
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
@@ -773,14 +765,12 @@ class ApiRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
|
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()
|
schema = self.client.get("/openapi.json").json()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{(path, method) for path, methods in schema["paths"].items() for method in methods},
|
{(path, method) for path, methods in schema["paths"].items() for method in methods},
|
||||||
{
|
{
|
||||||
("/", "get"),
|
|
||||||
("/dashboard", "get"),
|
|
||||||
("/health", "get"),
|
("/health", "get"),
|
||||||
("/exit", "post"),
|
("/exit", "post"),
|
||||||
("/install", "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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--serve",
|
"--serve",
|
||||||
action="store_true",
|
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(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# UVM Dashboard static assets package.
|
|
||||||
+1
-1
@@ -25,7 +25,7 @@ def exit_server(
|
|||||||
if server is None:
|
if server is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
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)
|
background_tasks.add_task(_request_server_exit, server)
|
||||||
return ServerExitResponse()
|
return ServerExitResponse()
|
||||||
|
|||||||
+10
-13
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import ssl
|
import ssl
|
||||||
@@ -27,8 +28,7 @@ def create_api(application: Application | None = None, *, host: str | None = Non
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
except ModuleNotFoundError as error:
|
except ModuleNotFoundError as error:
|
||||||
raise UvmError(
|
raise UvmError(
|
||||||
"FastAPI server support is not installed. Install this project with its dependencies."
|
"FastAPI server support is not installed. Install this project with its dependencies."
|
||||||
@@ -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.installation import router as installation_router
|
||||||
from .routers.vms import router as vms_router
|
from .routers.vms import router as vms_router
|
||||||
from .routers.diagnostics import router as diagnostics_router
|
from .routers.diagnostics import router as diagnostics_router
|
||||||
from .routers.dashboard import router as dashboard_router
|
|
||||||
|
|
||||||
api = FastAPI(
|
api = FastAPI(
|
||||||
title="uvm",
|
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(vms_router)
|
||||||
api.include_router(diagnostics_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
|
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."""
|
"""Run Uvicorn after enforcing the local-management security boundary."""
|
||||||
|
|
||||||
settings = application.settings
|
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:
|
if settings.api_tls_cert is not None:
|
||||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
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))
|
server = uvicorn.Server(uvicorn.Config(api, **options))
|
||||||
api.state.uvicorn_server = server
|
api.state.uvicorn_server = server
|
||||||
server.run()
|
server.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user