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:
2026-09-25 13:45:57 +05:30
parent 660ba9efee
commit dca7f78dae
22 changed files with 456 additions and 138 deletions
+61 -33
View File
@@ -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.
+41 -26
View File
@@ -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
``` ```
+29 -17
View File
@@ -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.
Executable
+8
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Nest workspace application."""
+7
View File
@@ -0,0 +1,7 @@
"""Run the Nest workspace with ``python -m nest``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+44
View File
@@ -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
+1
View File
@@ -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);
} }
+40
View File
@@ -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
View File
@@ -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"]
+173
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
# UVM Dashboard static assets package.
+1 -1
View File
@@ -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
View File
@@ -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()