Compare commits

..
1 Commits
Author SHA1 Message Date
kstyagi 99f9ffa57d Merge pull request 'Dev' (#1) from dev into main
Reviewed-on: #1
2026-09-20 03:41:01 +00:00
94 changed files with 604 additions and 12663 deletions
-5
View File
@@ -1,5 +0,0 @@
.pytest_cache/
.venv/
*.egg-info/
__pycache__/
**.pyc
+87 -637
View File
File diff suppressed because it is too large Load Diff
+60 -375
View File
@@ -1,17 +1,13 @@
# Nest
# uvm
Nest is a local infrastructure workspace with a browser dashboard for your
machines, network, and host health. Launch it with `nest --serve`.
Its `uvm` component is a small, local Firecracker microVM manager for Ubuntu.
UVM creates one Firecracker process per VM, attaches it to a host TAP device,
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
It creates one Firecracker process per VM, attaches it to a host TAP device,
and keeps a private disk and local registry for named VMs. The CLI supports
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
create, start, stop, restart, delete, inspection, SSH, logs, diagnostics, and
enforced private-IPv4 TCP port allowlists.
This directory is a self-contained project. Nest owns the dashboard and
workspace server; UVM provides the machine-management CLI and backend services.
All code, tests, packaging metadata, launchers, and documentation live here.
This directory is a self-contained project. All UVM code, tests, packaging
metadata, launcher scripts, and documentation live here.
For architecture, lifecycle invariants, extension guidance, and contributor
workflows, see [DEVELOPER.md](DEVELOPER.md).
@@ -28,13 +24,11 @@ feature contract; these guides describe the implementation in the workspace.
Run commands from this project root:
```sh
./nest.py --help
python3 -B -m nest --help
./uvm.py --help
python3 -B -m uvm --help
```
The `pyproject.toml` file defines installable `nest` and `uvm` console commands.
The `pyproject.toml` file also defines an installable `uvm` console command.
The project is an application, not a reusable Python SDK.
## Requirements
@@ -44,9 +38,9 @@ The project is an application, not a reusable Python SDK.
- Root access for UVM commands that read or mutate the protected VM registry
- KVM enabled and accessible at `/dev/kvm`
- Linux 5.3 or newer with native bridge conntrack and safe pidfd support
- An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
- An x86_64 host for the default guest assets
- Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`,
`coreutils`, `e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
`e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
- An STP-disabled software bridge; no offload path that bypasses bridge hooks
`uvm install` installs the required host packages. It validates `/dev/kvm`
@@ -54,71 +48,30 @@ before downloading VM artifacts. Boot operations additionally check that the
native nftables bridge policy is supported; installing packages alone is not
a connectivity or isolation test.
Firecracker and the guest kernel are selected automatically for the host
architecture. The default rootfs is built locally from Ubuntu 22.04 (Jammy)
packages with OpenSSH, sudo, and a complete apt/dpkg database. On NVIDIA DGX
Spark, UVM uses ARM64 assets and packages.
`arm64` and `amd64` architecture names are normalized to `aarch64` and
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
must point to assets compatible with the host architecture.
The default guest kernel is Linux 6.1.155 from Firecracker's published `v1.15`
CI artifacts, with built-in `virtio-rng` support. UVM attaches a host-backed
entropy device before each guest boot so services such as SSH can initialize
their cryptographic random generator. Custom kernels must include
`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` for this device.
Older installs used a Linux 4.14 ARM demo kernel and supplied no entropy
device. If SSH stays at **Starting OpenBSD Secure Shell server** while ping
works, see [SSH startup and guest entropy](USAGE.md#ssh-startup-and-guest-entropy)
for the kernel-refresh and retained-VM recovery steps.
## Trusted Installation
Installation requires trusted SHA-256 digests by default. Supply digests from
an independently trusted source for the selected architecture before running
the privileged installer:
an independently trusted source before running the privileged installer:
```sh
sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install
```
The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures,
and native packages. It bootstraps the base release first, then uses apt to
apply updates/security fixes and install the guest packages. UVM installs the
builder prerequisites and records the built ext4 image's SHA-256 in the
integrity manifest. Building requires root
with mount/PID namespace support and internet access to Ubuntu mirrors.
Use `--force` to refresh downloads and rebuild the Ubuntu template:
Use `--force` to redownload Firecracker and guest assets when replacing an
image or refreshing artifacts:
```sh
sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install --force
```
To use a prebuilt ext4 image instead, set both `UVM_ROOTFS_URL` and its trusted
`UVM_ROOTFS_SHA256`. Leave both unset for the default Ubuntu build.
### Upgrade from the Stripped Bionic Demo Image
Run `install --force` with the updated code and with `UVM_ROOTFS_URL` and
`UVM_ROOTFS_SHA256` unset, then create a new VM. The replacement template
includes sudo and the package database that the old demo image lacked.
Existing VM disks retain their original OS and credentials; installation
updates the template used for future VMs. Restart a running API server to load
the updated provisioner before creating VMs through the dashboard.
If the build fails, its detailed bootstrap/package output is retained in
`/var/lib/uvm/images/ubuntu-build.log` (mode `0600`). Correct the build failure
and rerun installation before creating a custom-user VM; a failed build leaves
the previous template installed.
For local experimentation only, the old unverified quick-start behavior is
available through an explicit opt-out:
@@ -129,7 +82,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install
An unverified manifest is not accepted as publisher trust in a later strict
run. Reinstall with trusted digests to establish a verified local manifest.
To boot VMs from an unverified local-development install, retain
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create`, `start`, `restart`, and `resize`
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create`, `start`, and `restart`
commands and on the API server process.
## Commands
@@ -140,12 +93,12 @@ These are separate operation examples, not a script to run from top to bottom.
# Install after supplying the trusted checksum variables shown above.
# A bare `install` command fails under the default strict integrity policy.
# Create a named VM: 1 CPU, 512 MiB RAM, 30 GiB storage, root/root, and private TCP/22.
# Create a named VM: 1 CPU, 512 MiB RAM, root/root, and private TCP/22.
# Use a unique non-default password for anything beyond a throwaway VM.
sudo ./uvm.py create --name web
# Choose resources, a private guest IP, and additional service ports.
sudo ./uvm.py create --name app --cpu 1 --ram 1G --storage 50G \
sudo ./uvm.py create --name app --cpu 1 --ram 1G \
--guest-ip 10.42.0.10 --ports 80:HTTP,443:HTTPS
# Read a password from one line of stdin, rather than a --password argument.
@@ -173,10 +126,6 @@ sudo ./uvm.py stop web --timeout 5
sudo ./uvm.py start web
sudo ./uvm.py restart web app --timeout 10
# Stop, change resources, and boot the same VM with its existing identity/data.
sudo ./uvm.py resize app --cpu 2 --ram 4G --storage 80G
sudo ./uvm.py resize app --ram 2G
# Preview bulk operations before confirming them.
sudo ./uvm.py stop --all --dry-run --json
sudo ./uvm.py stop --all -y
@@ -194,28 +143,6 @@ sudo ./uvm.py delete web -y
start. It is a finite non-negative SIGTERM wait in seconds, default `5`, not
a total command deadline or a guest shutdown timeout.
### Storage and Resizing
New VMs default to a **30 GiB private disk**, which is also the minimum.
`--storage` accepts values such as `30G`, `50G`, or `1T`; bare numbers use GiB.
As with RAM, `G`/`GB`/`GiB` are binary units. Capacities round up to whole MiB
and are returned as `storage_mib`. Disks are sparse: the configured capacity
does not immediately reserve that much physical host space.
`resize VM` accepts any combination of `--cpu`, `--ram`, and `--storage`, plus
`--timeout` and `--json`. It stops the VM, resizes the ext4 disk when needed,
updates the resource settings, and boots it. A previously stopped VM also boots
at the end. VM ID, IP/MAC, username/password, port rules, guest files, and SSH
host keys are retained. The dashboard exposes the same operation under
**Resize resources**; the API uses `POST /vms/{identifier}/resize`.
Storage can grow or shrink, provided ext4 can fit its existing data into the
requested capacity. Disk changes are prepared on a temporary sparse copy and
published after verification. Failed resizes retain a `pending_resize` target
for retry; `start` on the stopped VM completes that target before booting.
Older VMs report `storage_mib: null` until their first resize, which retains
their current capacity or grows it to the 30 GiB minimum when storage is omitted.
Stop sends SIGTERM and, if needed, SIGKILL to the Firecracker VMM. It is **not
a guest-coordinated shutdown**. Start boots the retained disk without resetting
guest data, SSH host keys, or credentials; it does not restore guest memory.
@@ -232,15 +159,6 @@ append a remote command after a literal `--`. `--insecure-host-key` disables
verification for one connection. SSH does not automatically supply the stored
password or open a closed port.
Deletion removes matching local SSH `known_hosts` entries for the guest IP and
UVM's VM-ID host-key alias before releasing the IP for reuse. It checks the
effective user's files and, under `sudo`, the invoking user's files, including
hashed entries. Unrelated hosts and file ownership/permissions are preserved.
For API services or custom SSH cache locations, set `UVM_KNOWN_HOSTS_FILES` to a
colon-separated list of absolute paths on the UVM host. See the SSH section in
[USAGE.md](USAGE.md#7-connect-with-ssh) for configuration and one-time cleanup of
entries left by older versions.
### Private Ports
```sh
@@ -272,12 +190,12 @@ well as new connections. This is packet dropping, not a guaranteed TCP RST or
immediate application disconnect.
These rules govern access to the **guest private IP**. They do not start a
guest service or provide public inbound connectivity, host-port mappings,
DNS publication, reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not
guest service or provide public connectivity, host-port mappings, DNS,
reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not
yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required
ARP/ICMP traffic remain allowed; managed TAPs block IPv6.
## Nest Dashboard and Management API
## Management API
Install the project dependencies before serving the API:
@@ -285,34 +203,28 @@ Install the project dependencies before serving the API:
python3 -m pip install .
```
Launch the Nest workspace server using the environment where the project
Launch the FastAPI management server using the environment where the project
dependencies were installed (use the full console-script path if needed):
```sh
export UVM_API_TOKEN='replace-with-a-long-random-secret'
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
nest --serve --host 127.0.0.1 --port 8000
uvm --serve --host 127.0.0.1 --port 8000
# Equivalent when running the source checkout directly.
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
./nest.py --serve --host 127.0.0.1 --port 8000
./uvm.py --serve --host 127.0.0.1 --port 8000
```
The default bind address is `127.0.0.1` and the default port is `8000`.
OpenAPI documentation is available at `/docs` once the server is running. The
root URL redirects to the Nest dashboard at `/dashboard`; enter the API token
root URL redirects to the local dashboard at `/dashboard`; enter the API token
there for the current browser session.
Nest serves the dashboard and UVM-backed management endpoints together, using
the existing `UVM_API_TOKEN` and `UVM_*` host configuration. The standalone UVM
API is available through `uvm --serve`, with its reference at `/docs`; the `/`
and `/dashboard` routes belong to the Nest server.
The Nest dashboard includes a fleet overview, searchable machine collection,
private network and port-policy view, host diagnostics, and machine provisioning.
Open a machine to manage its lifecycle, install developer tools, launch terminal sessions, edit or sync
its port rules, copy its SSH command, or read recent logs. Select machines in the
collection for bulk operations.
The dashboard includes a fleet overview, searchable machine collection, private
network and port-policy view, host diagnostics, and machine provisioning. Open a
machine to manage its lifecycle, edit or sync its port rules, copy its SSH command,
or read recent logs. Select machines in the collection for bulk operations.
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
five seconds while the tab is visible; host checks refresh every thirty seconds
and can also be run manually. Charts show observed machine states and allocated
@@ -323,42 +235,25 @@ The server exposes these routes:
| Method | Route | Purpose |
|---|---|---|
| `GET` | `/` | Redirect to the dashboard (Nest server) |
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
| `GET` | `/` | Redirect to the local dashboard |
| `GET` | `/dashboard` | Serve the local management dashboard |
| `GET` | `/health` | Unauthenticated liveness response |
| `POST` | `/exit` | Gracefully stop the management server |
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
| `GET` | `/vms` | List VM records and observed status |
| `POST` | `/vms` | Create; supports `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports`, `tools` |
| `POST` | `/vms` | Create; supports `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions |
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/start` | Boot a retained private disk |
| `POST` | `/vms/{identifier}/restart` | Stop then boot a VM; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/resize` | Stop, resize CPU/RAM/storage, and boot; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID |
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
| `GET` | `/tools` | Guest tool catalog and supported Python versions |
| `GET` | `/vms/{vm_id}/tools` | Tool inventory and installation history; `refresh=true` probes the running guest |
| `POST` | `/vms/{vm_id}/tools/install` | Queue selected guest tools; HTTP 202 with a job ID |
| `GET` | `/vms/{vm_id}/tools/jobs/{job_id}` | Per-tool progress, results, and bounded installation output |
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
| `POST` | `/vms/{vm_id}/sessions` | Launch a guest terminal; body: `{}` or `{"rows":24,"cols":80}` (Nest) |
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Session metadata and status (Nest) |
| `PATCH` | `/vms/{vm_id}/sessions/{session_id}` | Rename a session; body: `{"name":"Deployment"}` (Nest) |
| `DELETE` | `/vms/{vm_id}/sessions/{session_id}` | Close the session and delete its saved history; returns 204 (Nest) |
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End the shell and retain its history (Nest) |
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated terminal input, output, resize, and replay (Nest) |
API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`;
`no_default_ports` is a boolean, default `false`. Create also accepts `storage`,
defaulting to `"30G"`; resize accepts optional `cpu`, `ram`, and `storage` fields.
Port changes use
`no_default_ports` is a boolean, default `false`. Port changes use
`{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be
stopped and use `set` to establish a managed policy. Responses include `name`,
`ports`, `policy_status`, and `operation`, but never passwords. See
@@ -369,129 +264,7 @@ Log reads use an optional `tail` query parameter from `0` to `1000`, default
Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or
`ports-sync`) plus either `identifiers` or `all_vms: true`; they return a result
for each fixed target and do not prompt for confirmation. Interactive SSH
remains CLI-only. VM lifecycle changes are explicit; accepted tool installation
jobs are processed in the background.
### One-click Guest Tools
Choose tools under **New machine → Pick your tools** to install them automatically
after boot. For an existing VM, open **Machines → select a VM → Tools** and click
**Install**, or select several tools and click **Install selected**. A stopped VM
offers **Start & install**. Installation works over vsock with SSH disabled.
The initial catalog targets Ubuntu 22.04 guests on x86_64 and ARM64:
| Tool | Default |
|---|---|
| Git, wget, curl, htop | Ubuntu packages |
| Node.js | 24 LTS (24.21.0), including npm and npx |
| Python | 3.11, 3.12, and 3.13 with pip and venv; versions can coexist |
| JDK | OpenJDK 21 LTS, including `java` and `javac` |
| PHP | 8.5 CLI from the signed Ondřej Surý PHP PPA |
| Conda | Miniforge 26.5.3-0, using conda-forge |
| micromamba | 2.9.0 |
Node, Python, Miniforge, and micromamba downloads use pinned release checksums.
Python uses Astral's standalone distributions, exposed as `python3.11`,
`python3.12`, and `python3.13`. The guest's OS Python continues serving the agent.
Downloaded tools live under `/opt/uvm-tools`, with executable links in
`/usr/local/bin`. Conda environments and caches are writable by the configured
guest user. **Get started** shows commands, including shell initialization for
Conda/micromamba activation in Nest's non-login terminals.
Jobs run independently of the browser and show progress and captured output per
tool. Already available tools are verified and skipped. A failed tool can be
retried on the created VM. Guest internet access is required for downloads.
Restart the management server to load the feature; older running VMs may need
**Restart & install** to apply the current guest-agent setup.
The most recent ten jobs and cached inventory are stored in the VM's private
`tools.json` file. Queued requests resume when the server starts. An interrupted
in-flight installation has an uncertain outcome and may finish inside the guest;
use **Check installed tools** before retrying. See
[API examples and recovery](USAGE.md#install-guest-tools) for details.
### MicroVM Terminal Sessions
Open **Machines → select a microVM → Sessions**, then click **Launch session**.
Each session opens an interactive guest shell in the dashboard, with terminal
colors, keyboard input, Ctrl+C, resizing, and persistent shell state such as the
working directory. Sessions run as the VM's configured guest user; normal guest
`sudo` commands can request that user's password in the terminal.
The history list belongs to the selected VM. Reopen an active session to
continue using the same shell, or select an ended session to view its saved
output. Navigation, browser reloads, and signing out leave the shell running.
**End session** closes the shell and its processes. Stopping/restarting the VM
or the Nest server ends live sessions; their saved history remains available.
Use **Rename** in a history row or the terminal toolbar to give any session a
custom name. Names are trimmed and accept 1–80 printable characters; they persist
across reloads and server restarts. The session keeps its identity and shell.
**Delete** removes a session and its saved terminal output after confirmation,
closing its shell first if it is active. Both actions are scoped to the session's
microVM.
Nest stores session metadata and the latest **8 MiB of output per session** in
`$UVM_BASE/nest/sessions.sqlite3` (default `/var/lib/uvm/nest/sessions.sqlite3`).
The viewer indicates truncated output. Raw keyboard input is not logged
separately; the transcript contains what the guest terminal outputs. History
files are private to the host account running Nest. One Nest server owns
sessions for a given VM registry at a time, with up to 32 active sessions; the
guest agent shares its eight connection slots between terminals and exec calls.
**Vsock and the guest agent are configured by default for every microVM.**
Creation installs and enables the agent on every compatible image, including
cached older templates and custom images. Each stopped-disk boot also installs
or updates it automatically; an already-current installation is a read-only
check. Existing running VMs receive the setup on their next restart.
The **Sessions → Guest terminal support → Repair / update terminal support**
control is available for manual repair on a stopped VM. The guest-agent/kernel
requirements described below apply to terminals.
Terminal support adds the Python `websockets` dependency. In your micromamba
environment, install the current project and launch with its interpreter:
```sh
micromamba activate nest
python -m pip install -e '.[test]'
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
"$CONDA_PREFIX/bin/python" -m nest --serve
```
### Execute Commands Without SSH
Use an exact VM ID from create/list:
```sh
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"command":"whoami","sudo":true,"timeout_seconds":60}' \
"http://127.0.0.1:8000/vms/<vm-id>/exec"
```
The response contains `exit_code`, `stdout`, `stderr`, `timed_out`, and
`truncated`. Commands and multiline scripts run through `/bin/sh` inside the
guest. `sudo: false` (the default) uses the VM's configured username; `true`
uses guest root without a password prompt. A VM configured with username `root`
still runs as root when `sudo` is false.
Execution uses a guest systemd agent over Firecracker vsock. It works with SSH
disabled and with an empty TCP allowlist. Every Firecracker boot attaches the
vsock device, and creation/start/restart/resize ensure the guest service is
installed and enabled before boot. Restart the management server to load this
behavior, then restart older VMs once to apply it automatically.
Installation stages and verifies a private disk copy when changes are needed,
preserving guest data and credentials. If Python is missing, the installed
bootstrap service acquires it inside the guest on boot; this one-time step needs
guest internet access. The default Ubuntu template already includes Python.
The guest needs Python 3.10+, systemd with cgroup v2, and a vsock-capable Linux
5.14+ kernel; UVM's default Linux 6.1 kernel provides the required drivers.
See [execution examples and limits](USAGE.md#execute-commands-without-ssh) for
programmatic calls, local script submission, timeout behavior, and upgrade steps.
remains CLI-only. The API and dashboard are not background reconcilers.
### API Security
@@ -512,7 +285,7 @@ sudo env \
UVM_API_TOKEN="$UVM_API_TOKEN" \
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
./nest.py --serve --host 0.0.0.0 --port 8000
./uvm.py --serve --host 0.0.0.0 --port 8000
```
Send the token on management requests:
@@ -523,18 +296,6 @@ curl \
http://127.0.0.1:8000/vms
```
Stop the API server without stopping its managed VMs:
```sh
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
http://127.0.0.1:8000/exit
```
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
the serving Nest or UVM process. It is a protected management endpoint; it does
not stop or delete managed VMs.
Do not expose this server directly to an untrusted network. For remote access,
keep TLS enabled and consider placing it behind an additional authenticated
reverse proxy.
@@ -545,51 +306,15 @@ The installed kernel and rootfs are templates. `create` copies the rootfs to
the VM runtime directory before Firecracker opens it read-write, so VMs do not
share a mutable root disk.
During creation, UVM updates the private ext4 disk with a password for the
requested guest account and enables OpenSSH password authentication. Missing
custom usernames are created with an unused regular-user UID, a primary group,
a private `/home/USERNAME` directory, and `/bin/sh` as their login shell. The
During creation, UVM updates the private ext4 disk with a password for an
existing guest account and enables OpenSSH password authentication. The
defaults are username `root` and password `root`. A custom image must provide:
- `sshd`
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
- `/etc/group` and `/bin/sh` when creating a new user
- A root-owned, setuid `/usr/bin/sudo` and `/etc/sudoers` for custom login users
- systemd and apt for the automatically installed vsock guest agent (Ubuntu 22.04+)
- The requested user account; UVM does not create missing users
Custom login users receive password-authenticated sudo access. Inside a new VM:
```sh
sudo -i # Enter the same password used for this user's SSH login.
sudo apt update
sudo apt install htop
```
`su -` asks for root's separate password; creating a custom login does not set
root's password. Use `sudo -i` for an administrative shell. The default Ubuntu
template retains apt's package metadata and enables `main` and `universe`,
including updates/security repositories. Guest DNS defaults to `1.1.1.1` and
`8.8.8.8` in `/etc/resolv.conf`.
UVM specializes the Ubuntu guest's `/etc/systemd/network/10-uvm.network` with
its assigned `Address=` and `Gateway=` before boot. `KeepConfiguration=yes`
also retains the kernel-created default route while systemd-networkd starts.
Older templates used `KeepConfiguration=static`, which could retain the guest
IP but remove the default route, producing `ping: connect: Network is unreachable`.
The updated CLI repairs an older VM's original, unedited UVM profile on its
next stopped-disk boot, including `restart`. The repair is prepared and checked
on a sparse copy before publication; guest-edited profiles and custom images
are preserved. New VMs also receive the fix when using a cached older Ubuntu
template. See [guest route recovery](USAGE.md#guest-reports-network-is-unreachable)
for immediate recovery inside a running guest.
Existing accounts retain their UID, groups, home directory, and shell. A new
user reuses a same-named group if present; otherwise UVM creates one with an
unused GID. `/etc/gshadow` is updated when present. The default `root` account
must already exist in the image.
The known public demo key bundled in Firecracker's older Bionic image is
The known public demo key bundled in Firecracker's default bionic image is
removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
and Ed25519 SSH host keys in every private disk so cloned VMs have distinct
host identities. Never rely on SSH private keys baked into a shared template.
@@ -605,35 +330,13 @@ Bridge: uvm0
TAPs: uvm-<unique suffix>
```
VMs share the host's IPv4 internet connection by default. When the host has
internet access, guests can initiate outbound connections through it. When
the host loses connectivity, guest internet access is lost too; the private
VM network remains usable. Reconnecting the host restores guest egress.
It creates the bridge when absent, validates an existing bridge before using
it, enables IPv4 forwarding, adds an egress masquerade rule, and adds matching
forward rules for return traffic. Guest IPs, gateway, bridge name, and guest
boot netmask derive from the configured CIDR.
UVM creates the bridge when absent, validates an existing bridge before using
it, enables IPv4 forwarding, and installs subnet-scoped `iptables` masquerade
and forward/return rules. These rules are installed even while the host is
offline and follow its current routing table, so switching uplinks does not
require restarting VMs. Existing connections may need application retries
after an outage or upstream address change. Guest IPs, gateway, bridge name,
and guest boot netmask derive from the configured CIDR.
Forwarding exceptions are inserted ahead of existing host-chain rules and
allow guest-originated traffic and its replies. Masquerading excludes traffic
back onto the UVM bridge, preserving private peer addresses.
To apply updated sharing rules to an existing running VM, use:
```sh
sudo ./uvm.py start web
```
On a running VM, `start` refreshes its private policy and shared host networking
without rebooting it. New VM boots set up the same networking automatically.
For an older guest missing its default route, use `restart` once to apply the
offline guest-profile repair, or follow the guest route recovery steps above.
Private VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`,
The existing `iptables` egress NAT/forwarding behavior is unchanged. Private
VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`,
`172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible
gateway, subnet, or bridge changes within the same registry. Private
port enforcement uses separate per-VM native nftables `bridge` tables, not
@@ -650,9 +353,8 @@ firewalld, Docker, or other external firewall managers remains deferred.
Do not offload traffic around the software bridge hooks.
There is no autostart service or daemon reconciliation. After externally lost
or changed private policy, inspect its status and explicitly run `ports sync`.
Use `start` to restore shared bridge/NAT/forwarding rules as well. Read-only
commands do not repair rules; port sync does not boot stopped VMs.
or changed UVM rules, inspect policy status and explicitly run `ports sync`.
Read-only commands do not repair rules; sync does not boot stopped VMs.
Use one authoritative registry per bridge/subnet. Independent `UVM_BASE`
directories do not coordinate IP or MAC allocation with each other.
@@ -670,7 +372,7 @@ The default data directory is `/var/lib/uvm`:
└── state.json # Root-only VM inventory and guest credentials
```
The default registry is `/var/lib/uvm/state.json`, schema version `3`. Writes
The default registry is `/var/lib/uvm/state.json`, schema version `2`. Writes
are locked and atomic. Lifecycle, port-policy, and installation mutations
share an operation lock; bulk operations use a fixed snapshot of VM IDs.
@@ -679,7 +381,7 @@ is mode `0600`. Treat it as a secret, do not include it in bug reports, and
replace the default `root` password immediately.
Credential provisioning applies only to VMs created by this version. Existing
guest accounts, passwords, and SSH host keys are retained on subsequent boots.
VM disks are not modified automatically.
Older registries are read and migrated on the next write. A missing/null
`ports` policy remains `None`/`unmanaged`, not an empty allowlist or an
@@ -695,11 +397,6 @@ time, running binary, exact API-socket command line, and protected paths all
match. Missing or ambiguous evidence fails closed: UVM will not guess which
process to signal or release a disk while process exit is unconfirmed.
Schema 3 adds disk capacity and durable resize targets. Older registries migrate
on the next write; a recorded pending resize is completed before a stopped VM
can boot. A boot failure after a successful resize retains the new allocation
for a later start.
## Configuration
Host and API settings can be overridden through environment variables:
@@ -710,15 +407,14 @@ Host and API settings can be overridden through environment variables:
| `UVM_NETWORK` | Guest private IPv4 CIDR; defaults to `10.42.0.0/24` |
| `UVM_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` |
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
| `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
| `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
| `UVM_ROOTFS_URL` | Optional prebuilt ext4 image URL; unset to build Ubuntu 22.04 locally |
| `UVM_KERNEL_URL` | Guest kernel download URL |
| `UVM_ROOTFS_URL` | Guest rootfs download URL |
| `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
| `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 |
| `UVM_ROOTFS_SHA256` | Trusted prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` |
| `UVM_ROOTFS_SHA256` | Trusted guest rootfs 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_API_TOKEN` | Required for Nest and standalone UVM servers; sent as `X-UVM-Token` |
| `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
| `UVM_API_TLS_CERT` | TLS certificate path required for a non-loopback API bind |
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
@@ -732,18 +428,14 @@ Selected files:
├── USAGE.md
├── DEVELOPER.md
├── pyproject.toml
├── nest.py # Executable Nest workspace launcher
├── nest/ # Nest workspace application
│ ├── cli.py # nest --serve entry point
│ ├── server.py # Workspace server, composing the UVM management API
│ └── dashboard/ # Nest document route and local HTML, CSS, JS, and fonts
├── uvm.py # Executable UVM compatibility launcher
├── uvm/ # Machine-management implementation
├── uvm.py # Executable compatibility launcher
├── uvm/ # Private application implementation
│ ├── cli.py # argparse, terminal output, SSH execution
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup
│ ├── routers/ # UVM management API routes
│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript
│ ├── routers/ # FastAPI routes, including the dashboard document
│ └── firecracker/ # Firecracker config, API, and process adapters
└── tests/ # Unit tests and opt-in isolated namespace packet tests
```
@@ -766,11 +458,6 @@ retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery,
and API routes. API route tests run when the FastAPI and `httpx` test
dependencies are installed.
When `nft` and `conntrack` are installed, the suite also checks their command
parsers using deliberately invalid final commands/options that prevent mutations.
With e2fsprogs installed, offline disk tests grow and shrink temporary sparse ext4
images and check that data and credential/key fixtures survive; no VM is booted.
Opt-in packet checks run **only in disposable isolated user/network
namespaces**, which may require privileges and the installed network tools:
@@ -780,10 +467,8 @@ UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration
The packet test was skipped in this workspace because namespace creation was
denied (`Operation not permitted`). A skip is not packet-path or real-host
validation. The harness uses veths in place of TAPs and simulated upstream
networks to check NAT, offline startup, reconnects, and uplink changes alongside
private port policies. It does not boot a real VM; validate KVM, TAP, SSH, and
guest connectivity on an isolated Linux host.
validation. The harness uses veths in place of TAPs and does not boot a real VM;
validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host.
## Current Boundaries
@@ -791,7 +476,7 @@ guest connectivity on an isolated Linux host.
- No quotas or cgroup CPU, memory, I/O, or process limits are applied.
- Firecracker is launched directly by this local CLI. Jailer/cgroup hardening
is not implemented by the current command set.
- No VM autostart, background reconciliation, guest application health probing,
- No VM autostart, background reconciliation, guest readiness probing,
snapshots, DHCP, public-port publishing, or multi-host scheduling.
- The API uses one shared token, not users, roles, or tenants. UVM is not a
hardened multi-tenant platform.
+60 -702
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
#!/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
@@ -1 +0,0 @@
"""Nest workspace application."""
-7
View File
@@ -1,7 +0,0 @@
"""Run the Nest workspace with ``python -m nest``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
-44
View File
@@ -1,44 +0,0 @@
"""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
@@ -1 +0,0 @@
"""Packaged frontend assets for the Nest workspace."""
-60
View File
@@ -1,60 +0,0 @@
.machine-tabs { display: flex; gap: 8px; margin: 0 0 24px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.machine-tab { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 22px; color: var(--muted); font-size: 11px; }
.machine-tab.active { background: var(--white); color: var(--ink); box-shadow: var(--shadow-card); }
.machine-tab .icon { width: 14px; height: 14px; }
.session-section-heading { margin-bottom: 14px; }
.session-workspace { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: 16px; align-items: stretch; }
.session-history { padding: 14px 10px; min-width: 0; display: flex; flex-direction: column; }
.session-history > header { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 10px; }
.session-history h3 { font-size: 11px; }
.session-history > .panel-footnote { margin-top: auto; padding: 15px 8px 0; }
.session-list { display: grid; gap: 6px; max-height: 510px; overflow-y: auto; }
.session-list-empty { padding: 24px 9px; color: var(--muted); font-size: 10px; line-height: 1.8; }
.session-item { display: grid; gap: 9px; min-width: 0; padding: 12px 10px; border: 1px solid transparent; border-radius: 12px; color: var(--ink-soft); }
.session-link { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.session-item-actions { display: flex; gap: 15px; padding-top: 8px; border-top: 1px solid var(--line); }
.session-item-actions .quiet-link { min-height: 24px; font-size: 9px; }
.session-item:hover { background: #fff8; }
.session-item.active { background: var(--white); border-color: #d9d0e4; }
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
.session-item small { grid-column: 1 / -1; color: var(--muted); font-size: 9px; }
.session-state { align-self: center; width: 6px; height: 6px; border-radius: 50%; background: var(--lilac); }
.session-state.active { background: var(--sage); box-shadow: 0 0 0 3px var(--sage-soft); }
.session-state.failed, .session-state.interrupted { background: var(--peach); }
.terminal-panel { min-width: 0; overflow: hidden; border-radius: 17px; background: #25222d; color: #e8e3ef; box-shadow: var(--shadow-card); }
.terminal-toolbar { min-height: 56px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #ffffff12; background: #302c39; }
.terminal-toolbar > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; }
.terminal-toolbar strong { font-size: 11px; overflow-wrap: anywhere; }
.terminal-status { color: #b8afc6; font-size: 9px; }
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
.terminal-toolbar .button:hover { background: #ffffff19; }
#session-delete { color: #e5b4aa; }
.session-rename-dialog .field { margin-top: 22px; }
#session-rename-error { color: var(--red); }
.terminal-lights { display: inline-flex; gap: 4px; margin-right: 3px; }
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
.terminal-lights i:nth-child(2) { background: var(--yellow); }
.terminal-lights i:nth-child(3) { background: var(--sage); }
.session-terminal { height: 480px; padding: 14px 12px 10px; overflow: hidden; }
.session-terminal .xterm { height: 100%; }
.session-terminal .xterm-viewport { scrollbar-width: thin; scrollbar-color: #766a88 #25222d; }
.terminal-blank { min-height: 480px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 17px; text-align: center; padding: 24px; }
.terminal-blank > .icon { width: 37px; height: 37px; color: #b3a5ca; }
.terminal-blank h3 { font-size: 20px; font-weight: 450; }
.terminal-blank p { color: #b2a9bf; font-size: 11px; line-height: 1.8; }
.terminal-footer { min-height: 43px; padding: 11px 16px; color: #b5abc4; font-size: 9px; border-top: 1px solid #ffffff0d; overflow-wrap: anywhere; }
.terminal-support { margin-top: 18px; padding: 15px 19px; font-size: 10px; color: var(--muted); }
.terminal-support summary { cursor: pointer; color: var(--ink-soft); }
.terminal-support p { margin: 12px 0; max-width: 680px; line-height: 1.8; }
.terminal-support .muted { margin-left: 12px; }
@media (max-width: 1100px) {
.session-workspace { grid-template-columns: minmax(0, 1fr); }
.session-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); max-height: 190px; }
.session-history > .panel-footnote { display: none; }
}
@media (max-width: 700px) {
.session-terminal { height: 400px; }
.terminal-blank { min-height: 330px; }
.session-section-heading { flex-wrap: wrap; }
.terminal-support .muted { display: block; margin: 10px 0 0; }
}
-60
View File
@@ -1,60 +0,0 @@
.tool-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.tool-choice { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: #ffffff75; cursor: pointer; }
.tool-choice:hover { background: #fff; }
.tool-choice.is-selected { background: var(--yellow-soft); border-color: #ded09a; }
.tool-choice > span { display: grid; gap: 2px; min-width: 0; }
.tool-choice strong { font-size: 11px; }
.tool-choice small { color: var(--muted); font-size: 9px; }
.tool-choice input, .tool-card input { flex: 0 0 auto; }
.tools-section-heading { margin-block: 7px 15px; }
.tools-section-heading .button-row { justify-content: flex-end; }
.tools-section-heading .button-row > span { font-size: 10px; }
.tools-note { min-height: 22px; margin-bottom: 12px; }
.tools-error { margin-bottom: 12px; }
.tools-error:empty { display: none; }
.tools-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.tool-card { display: flex; flex-direction: column; min-width: 0; padding: 18px; border-radius: 18px; border: 1px solid #ffffffbd; background: #faf9fcbf; box-shadow: var(--shadow-card); }
.tool-card-heading { display: flex; gap: 11px; align-items: center; cursor: pointer; }
.tool-card-heading > span { min-width: 0; display: grid; gap: 4px; }
.tool-card-heading strong { font-size: 14px; letter-spacing: -.02em; }
.tool-card-heading small { font-size: 9px; color: var(--muted); }
.tool-card > p { margin: 12px 0 5px; color: var(--muted); font-size: 10px; }
.tool-source { font-size: 8px; color: var(--faint); margin-bottom: 17px; }
.tool-card-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: auto; }
.tool-installed { border-color: #b8c6ae70; }
.tool-installed button:disabled { cursor: default; background: var(--sage-soft); color: #64705c; opacity: 1; }
.tool-state { font-size: 9px; color: var(--muted); }
.tool-state-good { color: #68765c; }
.tool-state-error { color: #a45f57; }
.tool-state-active { color: #827037; }
.tool-state-active::before { content: ''; display: inline-block; width: 5px; height: 5px; margin-right: 5px; border-radius: 50%; background: var(--yellow); animation: tool-pulse 1.5s ease-in-out infinite; }
.tool-usage { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); font-size: 9px; color: var(--muted); }
.tool-usage summary { cursor: pointer; }
.tool-usage code { display: block; margin: 9px 0; font-family: var(--mono); font-size: 9px; overflow-wrap: anywhere; }
.tool-job { margin-top: 22px; padding: 22px; }
.tool-job .section-heading { flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
.tool-job .select-wrap { max-width: 100%; }
.tool-job select { max-width: 100%; }
.tool-job-message { color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
.tool-progress { display: block; width: 100%; height: 5px; margin: 15px 0; border: none; border-radius: 10px; background: #e7e2eb; accent-color: #b8ad69; }
.tool-progress::-webkit-progress-bar { background: #e7e2eb; border-radius: 10px; }
.tool-progress::-webkit-progress-value { background: #c3b875; border-radius: 10px; }
.tool-progress::-moz-progress-bar { background: #c3b875; border-radius: 10px; }
.tool-job-items { padding: 0; margin: 0; list-style: none; }
.tool-job-items > li { padding: 10px 0; border-top: 1px solid var(--line); }
.tool-job-items summary { cursor: pointer; font-size: 11px; }
.tool-job-items summary > .tool-state { float: right; margin-left: 12px; }
.tool-job-items .form-error { margin-top: 10px; }
.tool-output { max-height: 240px; overflow: auto; margin: 10px 0 0; padding: 15px; border-radius: 12px; background: #34313c; color: #ede8f1; white-space: pre-wrap; overflow-wrap: anywhere; font: 10px/1.7 var(--mono); }
.tools-recovery { margin-top: 16px; }
@keyframes tool-pulse { 50% { opacity: .35; } }
@media (max-width: 1250px) { .tools-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) {
.tools-section-heading { align-items: flex-start; flex-direction: column; }
.tools-section-heading .button-row { justify-content: flex-start; }
.tools-grid { grid-template-columns: 1fr; }
.tool-card { padding: 16px; }
.tool-job { padding: 16px; }
.tool-choice { padding: 10px; gap: 8px; }
}
@media (prefers-reduced-motion: reduce) { .tool-state-active::before { animation: none; } }
-521
View File
@@ -1,521 +0,0 @@
(function () {
'use strict';
const $ = (selector) => document.querySelector(selector);
const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`;
const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`;
const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
const encoded = (data) => btoa(String.fromCharCode(...data));
window.NestSessions = class {
constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) {
Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction });
this.vm = null;
this.records = [];
this.current = null;
this.selectedId = null;
this.visible = false;
this.sequence = 0;
this.generation = 0;
this.launching = new Set();
this.upgrading = new Set();
this.mutating = new Map();
this.offset = 0;
this.renderPending = Promise.resolve();
$('#sessions-refresh').addEventListener('click', () => this.refresh());
$('#session-end').addEventListener('click', () => this.end());
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
$('#session-install-agent').addEventListener('click', () => this.installAgent());
$('#session-rename').addEventListener('click', () => this.rename(this.current));
$('#session-delete').addEventListener('click', () => this.deleteSession(this.current));
$('#session-list').addEventListener('click', (event) => {
const button = event.target.closest('[data-session-action]');
if (!button || button.disabled) return;
const record = this.records.find((item) => item.id === button.dataset.sessionId);
if (button.dataset.sessionAction === 'rename') this.rename(record);
if (button.dataset.sessionAction === 'delete') this.deleteSession(record);
});
$('#session-rename-form').addEventListener('submit', (event) => { event.preventDefault(); this.saveName(); });
$('#session-rename-cancel').addEventListener('click', () => $('#session-rename-dialog').close());
$('#session-rename-dialog').addEventListener('close', () => {
if (!$('#session-rename-dialog').open) this.renameTarget = null;
});
$('#session-rename-dialog').addEventListener('cancel', (event) => {
if (this.isBusy(this.renameTarget?.id)) event.preventDefault();
});
$('#session-name-input').addEventListener('input', () => { $('#session-rename-error').textContent = ''; });
}
sync(vm, selectedId, visible) {
if (this.vm?.id !== vm.id) {
this.leave();
this.vm = vm;
$('#vm-session-count').textContent = '—';
}
this.vm = vm;
if (this.selectedId !== selectedId || this.visible !== visible) {
this.detachTerminal();
this.showError('');
}
this.selectedId = selectedId;
this.visible = visible;
this.updateControls();
if (this.pendingError?.vmId === vm.id && visible) {
this.showError(this.pendingError.message);
$('#terminal-support').open = this.pendingError.setup;
this.pendingError = null;
}
this.renderList();
this.openSelected();
if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh();
}
showError(message) {
$('#sessions-error').textContent = message;
}
updateControls() {
const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running'
&& !this.vm.operation && !this.vm.pending_resize;
document.querySelectorAll('[data-action="launch-session"]').forEach((button) => {
button.disabled = !running || this.launching.has(this.vm?.id);
button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session';
button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session';
});
const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
&& !this.vm.operation && !this.vm.pending_resize;
$('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id);
$('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Repair / update terminal support';
$('#session-install-hint').textContent = stopped ? 'Setup runs automatically at startup. Manual repair is available now.' : 'Vsock is set up automatically on boot. Restart to apply updates.';
this.renderStatus();
}
renderList() {
const markup = this.records.length ? this.records.map((record) => `
<div class="session-item${record.id === this.selectedId ? ' active' : ''}" data-session-id="${this.esc(record.id)}">
<a class="session-link" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
</a>
<div class="session-item-actions">
<button class="quiet-link" type="button" data-session-action="rename" data-session-id="${this.esc(record.id)}" aria-label="Rename ${this.esc(record.name)}" ${this.isBusy(record.id) ? 'disabled' : ''}>Rename</button>
<button class="quiet-link danger-text" type="button" data-session-action="delete" data-session-id="${this.esc(record.id)}" aria-label="Delete ${this.esc(record.name)}" ${this.isBusy(record.id) ? 'disabled' : ''}>${this.mutating.get(record.id) === 'delete' ? 'Deleting…' : 'Delete'}</button>
</div>
</div>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
}
async refresh() {
if (!this.vm) return;
const vmId = this.vm.id;
const sequence = ++this.sequence;
this.loading = true;
$('#sessions-refresh').disabled = true;
try {
const records = await this.api(path(vmId));
if (sequence !== this.sequence || this.vm?.id !== vmId) return;
if (!Array.isArray(records)) throw new Error('The session history response was not recognized.');
this.records = records;
if (this.current) {
const latest = records.find((record) => record.id === this.current.id);
if (!latest) {
const localDelete = this.mutating.get(this.current.id) === 'delete';
this.removeRecord(vmId, this.current.id);
if (!localDelete) this.toast('This session was deleted.');
return;
}
if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
}
this.lastRefresh = Date.now();
$('#vm-session-count').textContent = records.length;
this.openSelected();
this.renderStatus();
} catch (error) {
if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message);
} finally {
if (sequence === this.sequence) {
this.loading = false;
$('#sessions-refresh').disabled = false;
this.renderList();
}
}
}
async launch(vm) {
if (!vm || this.launching.has(vm.id)) return;
const token = this.getToken();
this.launching.add(vm.id);
this.showError('');
this.updateControls();
try {
const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } });
if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id);
} catch (error) {
if (token === this.getToken() && error.name !== 'AbortError') {
this.toast(error.message, true);
if (this.isSelectedVm(vm.id)) {
this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 };
location.hash = href(vm.id);
if (this.visible) this.sync(vm, null, true);
}
}
} finally {
this.launching.delete(vm.id);
this.lastRefresh = 0;
this.updateControls();
}
}
async installAgent() {
const vm = this.vm;
if (!vm || this.upgrading.has(vm.id)) return;
this.upgrading.add(vm.id);
this.showError('');
this.updateControls();
try {
await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' });
if (this.vm?.id === vm.id) {
this.toast('Terminal support updated. Start this machine, then launch a session.');
$('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.';
}
} catch (error) {
if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message);
} finally {
this.upgrading.delete(vm.id);
this.updateControls();
}
}
openSelected() {
if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return;
const record = this.records.find((item) => item.id === this.selectedId);
if (!record) {
if (this.lastRefresh) this.showError('This session was not found for the selected machine.');
return;
}
this.showError('');
this.detachTerminal();
this.current = record;
$('#session-blank').hidden = true;
$('#session-terminal').hidden = false;
this.term = new window.Terminal({
cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
scrollback: 10000, disableStdin: true,
theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' },
});
this.fit = new window.FitAddon.FitAddon();
this.term.loadAddon(this.fit);
this.term.open($('#session-terminal'));
this.term.onData((text) => this.input(new TextEncoder().encode(text)));
this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0))));
this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols));
this.observer = new ResizeObserver(() => this.fitTerminal());
this.observer.observe($('#session-terminal'));
this.fitTerminal();
this.connectSocket();
}
fitTerminal() {
if (this.term && this.visible && $('#session-terminal').clientWidth > 0) {
this.fit.fit();
}
}
input(data) {
if (!this.canType()) return;
if (data.length > 65536 || this.socket.bufferedAmount > 65536) {
this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.');
return;
}
for (let offset = 0; offset < data.length; offset += 8192) {
this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) }));
}
}
canType() {
return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp
&& this.ending !== this.current.id && this.mutating.get(this.current.id) !== 'delete';
}
sendSize(rows, cols) {
if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) }));
}
async connectSocket() {
if (!this.current || !this.visible || !this.getToken()) return;
const generation = this.generation;
await this.renderPending;
if (generation !== this.generation || !this.current || !this.visible) return;
window.clearTimeout(this.reconnectTimer);
const previous = this.socket;
this.socket = null;
previous?.close();
this.caughtUp = false;
this.replayEnd = null;
this.connection = 'Connecting…';
const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin);
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(url);
this.socket = socket;
let rejected = false;
const current = () => generation === this.generation && this.socket === socket;
this.renderStatus();
socket.onopen = () => {
if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset }));
};
socket.onmessage = (event) => {
if (!current()) return;
try {
const message = JSON.parse(event.data);
if (message.type === 'session') {
const wasReady = this.caughtUp;
if (message.session.updated_at >= this.current.updated_at) this.current = message.session;
if (message.offset > this.offset) {
this.term.reset();
this.offset = message.offset;
}
if (this.replayEnd == null) this.replayEnd = message.session.output_end;
this.caughtUp = this.offset >= this.replayEnd;
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
const index = this.records.findIndex((record) => record.id === message.session.id);
if (index >= 0) this.records[index] = this.current;
this.renderList();
this.renderStatus();
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
} else if (message.type === 'output') {
const data = bytes(message.data);
if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.');
this.renderPending = new Promise((resolve) => {
this.finishRender = resolve;
this.term.write(data, () => {
if (generation === this.generation) {
this.offset += data.length;
const wasReady = this.caughtUp;
this.caughtUp = this.offset >= this.replayEnd;
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset }));
this.renderStatus();
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
this.finishRender = null;
}
resolve();
});
});
} else if (message.type === 'deleted') {
const vmId = this.vm.id;
const sessionId = this.current.id;
const localDelete = this.mutating.get(sessionId) === 'delete';
this.removeRecord(vmId, sessionId);
if (!localDelete) this.toast('This session was deleted.');
} else if (message.type === 'error') {
rejected = true;
this.showError(message.message);
}
} catch (error) {
rejected = true;
this.showError(error.message);
socket.close();
}
};
socket.onclose = (event) => {
if (!current()) return;
this.connection = 'Disconnected';
this.caughtUp = false;
this.renderStatus();
if (event.code === 4401) { this.onExpired(); return; }
if (!rejected && ['active', 'starting'].includes(this.current?.status)) {
this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000);
}
};
}
renderStatus() {
const record = this.current;
const active = record?.status === 'active';
$('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
$('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session';
$('#session-end').disabled = !active || this.isBusy(record?.id);
$('#session-rename').disabled = !record || this.isBusy(record.id);
$('#session-delete').disabled = !record || this.isBusy(record.id);
$('#session-delete').textContent = record && this.mutating.get(record.id) === 'delete' ? 'Deleting…' : 'Delete';
$('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
if (this.term) this.term.options.disableStdin = !this.canType();
$('#session-meta').textContent = record
? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}`
: 'Your sessions and terminal output are saved on the host.';
}
async end() {
const record = this.current;
if (!record || this.isBusy(record.id)) return;
const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session');
if (!confirmed) return;
this.ending = record.id;
this.renderList();
this.renderStatus();
try {
const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
if (this.current?.id === record.id) this.current = ended;
if (this.vm?.id === record.vm_id) await this.refresh();
} catch (error) {
if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
} finally {
this.ending = null;
this.renderList();
this.renderStatus();
}
}
isBusy(id) {
return Boolean(id && (this.mutating.has(id) || this.ending === id));
}
invalidateRefresh() {
this.sequence += 1;
this.loading = false;
this.lastRefresh = 0;
$('#sessions-refresh').disabled = false;
}
rename(record) {
if (!record || this.isBusy(record.id) || document.querySelector('dialog[open]')) return;
this.renameTarget = record;
$('#session-name-input').value = record.name;
$('#session-name-input').disabled = false;
$('#session-rename-save').disabled = false;
$('#session-rename-save').textContent = 'Save name';
$('#session-rename-cancel').disabled = false;
$('#session-rename-error').textContent = '';
$('#session-rename-dialog').showModal();
$('#session-name-input').focus();
$('#session-name-input').select();
}
async saveName() {
const target = this.renameTarget;
if (!target || this.isBusy(target.id)) return;
const name = $('#session-name-input').value.trim();
if (!name) {
$('#session-rename-error').textContent = 'Enter a session name.';
$('#session-name-input').focus();
return;
}
const token = this.getToken();
this.mutating.set(target.id, 'rename');
$('#session-name-input').disabled = true;
$('#session-rename-save').disabled = true;
$('#session-rename-save').textContent = 'Saving…';
$('#session-rename-cancel').disabled = true;
this.renderList();
this.renderStatus();
try {
const renamed = await this.api(path(target.vm_id, `/${encodeURIComponent(target.id)}`), { method: 'PATCH', body: { name } });
if (this.renameTarget === target) $('#session-rename-dialog').close();
if (token === this.getToken() && this.vm?.id === target.vm_id) {
this.invalidateRefresh();
const index = this.records.findIndex((item) => item.id === target.id);
if (index >= 0 && renamed.updated_at >= this.records[index].updated_at) this.records[index] = renamed;
if (this.current?.id === target.id && renamed.updated_at >= this.current.updated_at) this.current = renamed;
this.toast('Session renamed.');
}
} catch (error) {
if (error.name !== 'AbortError' && this.renameTarget === target) $('#session-rename-error').textContent = error.message;
} finally {
this.mutating.delete(target.id);
if (this.renameTarget === target) {
$('#session-name-input').disabled = false;
$('#session-rename-save').disabled = false;
$('#session-rename-save').textContent = 'Save name';
$('#session-rename-cancel').disabled = false;
}
this.renderList();
this.renderStatus();
}
}
async deleteSession(record) {
if (!record || this.isBusy(record.id) || document.querySelector('dialog[open]')) return;
const live = ['active', 'starting'].includes(record.status);
const confirmed = await this.confirmAction(`Delete ${record.name}?`,
live ? 'End this shell and permanently delete the session and its saved terminal output.'
: 'Permanently delete this session and its saved terminal output.', 'Delete session', true);
if (!confirmed) return;
const token = this.getToken();
this.mutating.set(record.id, 'delete');
this.showError('');
this.renderList();
this.renderStatus();
try {
await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}`), { method: 'DELETE' });
if (token === this.getToken() && this.vm?.id === record.vm_id) {
this.removeRecord(record.vm_id, record.id);
this.toast('Session deleted.');
}
} catch (error) {
if (error.name !== 'AbortError' && token === this.getToken() && this.vm?.id === record.vm_id) this.showError(error.message);
} finally {
this.mutating.delete(record.id);
this.renderList();
this.renderStatus();
}
}
removeRecord(vmId, sessionId) {
if (this.vm?.id !== vmId) return;
this.invalidateRefresh();
this.records = this.records.filter((record) => record.id !== sessionId);
$('#vm-session-count').textContent = this.records.length;
if (this.renameTarget?.id === sessionId) {
$('#session-rename-dialog').close();
this.renameTarget = null;
}
if (this.selectedId === sessionId || this.current?.id === sessionId) {
this.detachTerminal();
this.selectedId = null;
if (this.visible && this.isSelectedVm(vmId)) location.hash = href(vmId);
}
this.showError('');
this.renderList();
this.renderStatus();
}
detachTerminal() {
this.generation += 1;
window.clearTimeout(this.reconnectTimer);
this.observer?.disconnect();
this.observer = null;
const socket = this.socket;
this.socket = null;
socket?.close();
this.finishRender?.();
this.finishRender = null;
this.renderPending = Promise.resolve();
this.term?.dispose();
this.term = null;
this.current = null;
this.offset = 0;
this.caughtUp = false;
$('#session-terminal').replaceChildren();
$('#session-terminal').hidden = true;
$('#session-blank').hidden = false;
this.renderStatus();
}
leave() {
if ($('#session-rename-dialog').open) $('#session-rename-dialog').close();
this.renameTarget = null;
this.sequence += 1;
this.loading = false;
this.lastRefresh = 0;
this.detachTerminal();
this.visible = false;
this.vm = null;
this.records = [];
this.selectedId = null;
this.showError('');
$('#terminal-support').open = false;
}
};
})();
-331
View File
@@ -1,331 +0,0 @@
(function () {
'use strict';
const $ = (selector) => document.querySelector(selector);
const active = (job) => ['queued', 'starting', 'waiting', 'installing'].includes(job?.status);
const retryable = (item) => ['failed', 'interrupted', 'skipped'].includes(item.status);
const labels = { queued: 'Queued', starting: 'Starting machine', waiting: 'Waiting for guest', installing: 'Installing',
installed: 'Installed', already_installed: 'Already installed', succeeded: 'Complete', failed: 'Failed',
interrupted: 'Interrupted', skipped: 'Not attempted' };
window.NestTools = class NestTools {
constructor({ api, esc, toast, formatDate, onSelectionChange, onMutation }) {
Object.assign(this, { api, esc, toast, formatDate, onSelectionChange, onMutation });
this.catalog = null;
this.catalogPromise = null;
this.epoch = 0;
this.generation = 0;
this.createSelected = new Set();
this.selected = new Set();
this.cache = new WeakMap();
this.createBusy = false;
this.vm = null;
this.data = null;
this.timer = null;
this.loading = false;
this.submitting = false;
this.revision = 0;
this.probed = false;
this.lastProbe = 0;
this.viewing = null;
this.historyJob = null;
this.pendingRequest = null;
$('#create-tools-list').addEventListener('change', (event) => {
const input = event.target.closest('[data-create-tool]');
if (!input) return;
if (input.checked) this.createSelected.add(input.dataset.createTool);
else this.createSelected.delete(input.dataset.createTool);
input.closest('label').classList.toggle('is-selected', input.checked);
this.onSelectionChange();
});
$('#create-tools-refresh').addEventListener('click', () => this.mountCreate());
$('#tools-refresh').addEventListener('click', () => this.load(true));
$('#tools-install-selected').addEventListener('click', () => this.submit([...this.selected]));
$('#tools-retry').addEventListener('click', () => this.submit(this.retryKeys()));
$('#tools-restart').addEventListener('click', () => this.submit(this.selected.size ? [...this.selected] : this.retryKeys(), true));
$('#tools-job-select').addEventListener('change', (event) => this.viewJob(event.target.value));
$('#tools-list').addEventListener('change', (event) => {
const input = event.target.closest('[data-tool-select]');
if (!input) return;
if (input.checked) this.selected.add(input.dataset.toolSelect);
else this.selected.delete(input.dataset.toolSelect);
this.render();
});
$('#tools-list').addEventListener('click', (event) => {
const button = event.target.closest('[data-install-tool]');
if (button && !button.disabled) this.submit([button.dataset.installTool]);
});
}
html(selector, value) {
const element = $(selector);
if (this.cache.get(element) !== value) {
element.innerHTML = value;
this.cache.set(element, value);
}
}
variants() {
return (this.catalog || []).flatMap((tool) => (tool.versions.length ? tool.versions : [null]).map((version) => ({
...tool, version, key: version ? `${tool.id}-${version}` : tool.id,
title: version ? `${tool.name} ${version}` : tool.name,
})));
}
async loadCatalog() {
if (this.catalog) return this.catalog;
if (this.catalogPromise) return this.catalogPromise;
const epoch = this.epoch;
const request = this.api('/tools').then((catalog) => {
if (!Array.isArray(catalog) || catalog.some((tool) => !tool.id || !Array.isArray(tool.versions))) {
throw new Error('The tool catalog response was not recognized.');
}
if (epoch === this.epoch) this.catalog = catalog;
return catalog;
}).finally(() => { if (this.catalogPromise === request) this.catalogPromise = null; });
this.catalogPromise = request;
return request;
}
async mountCreate() {
const epoch = this.epoch;
$('#create-tools-error').textContent = '';
try {
await this.loadCatalog();
if (epoch !== this.epoch) return;
this.html('#create-tools-list', this.variants().map((tool) => `<label class="tool-choice${this.createSelected.has(tool.key) ? ' is-selected' : ''}">
<input type="checkbox" data-create-tool="${this.esc(tool.key)}" ${this.createSelected.has(tool.key) ? 'checked' : ''} ${this.createBusy ? 'disabled' : ''} />
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? 'pip + venv' : tool.version_label)}</small></span></label>`).join(''));
} catch (error) {
if (epoch === this.epoch && error.name !== 'AbortError') $('#create-tools-error').textContent = error.message;
}
}
createSelections() {
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map(({ id, version }) => ({ id, version }));
}
createSummary() {
return this.variants().filter((tool) => this.createSelected.has(tool.key)).map((tool) => tool.title).join(', ') || 'None selected';
}
resetCreate() {
this.createSelected.clear();
document.querySelectorAll('[data-create-tool]').forEach((input) => {
input.checked = false;
input.closest('label').classList.remove('is-selected');
});
}
setCreateBusy(busy) {
this.createBusy = busy;
document.querySelectorAll('[data-create-tool]').forEach((input) => { input.disabled = busy; });
}
sync(vm, visible) {
if (!visible) return;
if (this.vm?.id !== vm.id) {
this.leave();
this.vm = vm;
this.html('#tools-list', '<div class="loading-state">Bringing your tools into view…</div>');
$('#tools-job').hidden = true;
$('#tools-error').textContent = '';
this.load();
} else {
const started = this.vm.observed_status !== 'running' && vm.observed_status === 'running';
this.vm = vm;
this.render();
if (started && !this.loading) this.load(true);
}
}
leave() {
this.generation += 1;
window.clearTimeout(this.timer);
this.timer = null;
this.vm = null;
this.data = null;
this.loading = false;
this.submitting = false;
this.probed = false;
this.selected.clear();
this.viewing = null;
this.historyJob = null;
this.pendingRequest = null;
}
reset() {
this.leave();
this.epoch += 1;
this.catalog = null;
this.catalogPromise = null;
this.resetCreate();
}
schedule() {
window.clearTimeout(this.timer);
if (!this.vm) return;
this.timer = window.setTimeout(() => {
if (document.hidden) { this.schedule(); return; }
this.load(Boolean(this.data?.guest_busy && Date.now() - this.lastProbe > 20000));
}, active(this.data?.latest_job) ? 1500 : 5000);
}
async load(refresh = false) {
if (!this.vm || this.loading) return;
const id = this.vm.id;
const generation = this.generation;
const revision = this.revision;
const wasActive = active(this.data?.latest_job);
this.loading = true;
if (refresh) { this.probed = true; this.lastProbe = Date.now(); }
$('#tools-refresh').disabled = true;
if (refresh) $('#tools-note').textContent = 'Checking installed tools inside this machine…';
let probeNext = false;
try {
const [, data] = await Promise.all([this.loadCatalog(), this.api(`/vms/${encodeURIComponent(id)}/tools${refresh ? '?refresh=true' : ''}`, { timeout: 100000 })]);
if (generation !== this.generation || revision !== this.revision) return;
this.data = data;
if (!data?.vm_id || data.vm_id !== id) throw new Error('The tool status response was not recognized.');
if (this.viewing && !data.jobs.some((job) => job.id === this.viewing)) { this.viewing = null; this.historyJob = null; }
this.selected = new Set([...this.selected].filter((key) => !data.inventory.some((item) => item.key === key && item.installed)));
$('#tools-error').textContent = data.error || '';
probeNext = !active(data.latest_job) && this.vm.observed_status === 'running'
&& ((wasActive && !refresh) || (!this.probed && data.stale));
this.render();
} catch (error) {
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
} finally {
if (generation === this.generation) {
this.loading = false;
$('#tools-refresh').disabled = false;
if (probeNext) this.load(true);
else this.schedule();
}
}
}
retryKeys() {
return (this.data?.latest_job?.items || []).filter(retryable).map((item) => item.key);
}
canInstall() {
const status = this.vm?.observed_status || this.vm?.status;
return ['running', 'stopped'].includes(status) && !this.vm.operation && !this.vm.pending_resize
&& (status !== 'stopped' || this.vm.ports !== null) && this.data?.supported !== false;
}
render() {
if (!this.vm || !this.catalog) return;
const job = this.data?.latest_job;
const occupied = this.submitting || active(job) || this.data?.guest_busy;
const disabled = occupied || !this.canInstall();
const stopped = (this.vm.observed_status || this.vm.status) === 'stopped';
const focused = document.activeElement?.dataset.toolSelect;
const inventory = new Map((this.data?.inventory || []).map((item) => [item.key, item]));
const items = new Map((job?.items || []).map((item) => [item.key, item]));
this.html('#tools-list', this.variants().map((tool) => {
const found = inventory.get(tool.key);
const item = items.get(tool.key);
const installing = active(job) && item && ['queued', 'installing'].includes(item.status);
const installed = found?.installed && !installing;
const failed = item && retryable(item) && !installed && !installing;
const state = installing ? labels[item.status] : installed ? `Installed · ${found.detected_version}` : failed ? labels[item.status]
: found?.detected_version ? `Found ${found.detected_version}` : found ? 'Available' : 'Not checked yet';
const caption = stopped ? 'Start & install' : failed ? 'Retry' : 'Install';
return `<article class="tool-card${installed ? ' tool-installed' : ''}">
<label class="tool-card-heading"><input type="checkbox" data-tool-select="${this.esc(tool.key)}" aria-label="Select ${this.esc(tool.title)}" ${this.selected.has(tool.key) ? 'checked' : ''} ${disabled || installed ? 'disabled' : ''} />
<span><strong>${this.esc(tool.title)}</strong><small>${this.esc(tool.version ? `Python ${tool.version} · pip + venv` : tool.version_label)}</small></span></label>
<p>${this.esc(tool.description)}</p><small class="tool-source">${this.esc(tool.source)}</small>
<div class="tool-card-footer"><span class="tool-state ${installed ? 'tool-state-good' : failed ? 'tool-state-error' : installing ? 'tool-state-active' : ''}">${this.esc(state)}</span>
<button class="button button-small" type="button" data-install-tool="${this.esc(tool.key)}" ${disabled || installed ? 'disabled' : ''}>${installed ? 'Installed' : this.esc(caption)}</button></div>
${installed && found.usage ? `<details class="tool-usage"><summary>Get started</summary><code>${this.esc(found.usage)}</code><small>Run these commands in this machine’s terminal.</small></details>` : ''}</article>`;
}).join(''));
if (focused) document.querySelector(`[data-tool-select="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true });
$('#tools-selected-count').textContent = `${this.selected.size} selected`;
$('#tools-install-selected').textContent = this.submitting ? 'Queuing…' : stopped ? 'Start & install selected' : 'Install selected';
$('#tools-install-selected').disabled = disabled || !this.selected.size;
$('#tools-retry').hidden = !this.retryKeys().length || active(job);
$('#tools-retry').disabled = disabled;
$('#tools-restart').hidden = stopped || !this.data?.error || this.data.agent_ready || active(job);
$('#tools-restart').disabled = disabled || (!this.selected.size && !this.retryKeys().length);
$('#tools-note').textContent = this.data?.guest_busy
? 'An installation is still running inside the guest. Its status will be checked again shortly.'
: stopped ? 'Start & install boots this machine and installs your selected tools. Versions shown are last observed.'
: this.data?.checked_at ? `Last checked ${this.formatDate(this.data.checked_at)}${this.data.stale ? ' · cached observations' : ''}. Use Get started for commands and shell initialization.`
: 'Tools are installed directly inside this VM. Guest internet access is required for downloads.';
this.renderJob();
}
renderJob() {
const job = this.historyJob || this.data?.latest_job;
$('#tools-job').hidden = !job;
if (!job) return;
$('#tools-job-title').textContent = labels[job.status] || job.status;
$('#tools-job-message').textContent = job.error || (active(job) ? 'You can leave this page. Installation continues in the background.' : 'Tool installation results are saved with this machine.');
const complete = job.items.filter((item) => !['queued', 'installing'].includes(item.status)).length;
$('#tools-progress').max = job.items.length;
$('#tools-progress').value = complete;
$('#tools-progress').setAttribute('aria-label', `${complete} of ${job.items.length} tools processed`);
const names = new Map(this.variants().map((tool) => [tool.key, tool.title]));
const open = new Set([...document.querySelectorAll('#tools-job-items details[open]')].map((item) => item.dataset.output));
this.html('#tools-job-items', job.items.map((item) => `<li><details data-output="${this.esc(item.key)}" ${open.has(item.key) ? 'open' : ''}>
<summary><strong>${this.esc(names.get(item.key) || item.key)}</strong><span class="tool-state ${retryable(item) ? 'tool-state-error' : ''}">${this.esc(labels[item.status] || item.status)}</span></summary>
${item.error ? `<p class="form-error">${this.esc(item.error)}</p>` : ''}<pre class="tool-output" tabindex="0">${this.esc(item.output || 'No output recorded.')}</pre>
${item.truncated ? '<p class="panel-footnote">Showing the bounded tail of installation output.</p>' : ''}</details></li>`).join(''));
this.html('#tools-job-select', (this.data.jobs || []).map((item) => `<option value="${this.esc(item.id)}">${this.esc(this.formatDate(item.created_at))} · ${this.esc(labels[item.status] || item.status)}</option>`).join(''));
$('#tools-job-select').value = job.id;
}
async viewJob(id) {
if (!this.vm) return;
const generation = this.generation;
this.viewing = id === this.data?.latest_job?.id ? null : id;
this.historyJob = null;
if (!this.viewing) { this.renderJob(); return; }
try {
const job = await this.api(`/vms/${encodeURIComponent(this.vm.id)}/tools/jobs/${encodeURIComponent(id)}`);
if (generation !== this.generation || this.viewing !== id) return;
this.historyJob = job;
this.renderJob();
} catch (error) {
if (generation === this.generation && error.name !== 'AbortError') this.toast(error.message, true);
}
}
async submit(keys, restart = false) {
if (!this.vm || this.submitting || active(this.data?.latest_job) || !this.canInstall()) return;
const tools = this.variants().filter((tool) => keys.includes(tool.key)).map(({ id, version }) => ({ id, version }));
if (!tools.length) return;
const id = this.vm.id;
const generation = this.generation;
const payload = { tools, start_if_stopped: true, restart };
const signature = JSON.stringify(payload);
if (this.pendingRequest?.signature !== signature) this.pendingRequest = { signature, id: crypto.randomUUID() };
payload.request_id = this.pendingRequest.id;
this.submitting = true;
this.revision += 1;
$('#tools-error').textContent = '';
this.render();
try {
const job = await this.api(`/vms/${encodeURIComponent(id)}/tools/install`, { method: 'POST', body: payload });
if (generation !== this.generation) return;
this.pendingRequest = null;
this.selected.clear();
this.viewing = null;
this.historyJob = null;
if (this.data) { this.data.latest_job = job; this.data.jobs = [{ id: job.id, status: job.status, created_at: job.created_at }, ...this.data.jobs.filter((item) => item.id !== job.id)]; }
this.toast('Tool installation queued. You can follow its progress here.');
this.onMutation(id);
} catch (error) {
if (generation === this.generation && error.name !== 'AbortError') $('#tools-error').textContent = error.message;
} finally {
if (generation === this.generation) {
this.submitting = false;
this.render();
this.load();
}
}
}
};
})();
-9
View File
@@ -1,9 +0,0 @@
# Local terminal assets
Runtime files from the published npm packages:
- `@xterm/xterm` **5.5.0** — `xterm/lib/xterm.js`, `xterm/css/xterm.css`
- `@xterm/addon-fit` **0.10.0** — `xterm-fit/lib/addon-fit.js`
Both use the MIT license; the original `LICENSE` is included in each directory.
Assets are served locally. Running Nest requires no npm install or frontend build.
-19
View File
@@ -1,19 +0,0 @@
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
-21
View File
@@ -1,21 +0,0 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-218
View File
@@ -1,218 +0,0 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
-58
View File
@@ -1,58 +0,0 @@
"""Compose the Nest dashboard with its local UVM management backend."""
from __future__ import annotations
from contextlib import asynccontextmanager
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
from .session_routes import router as session_router
from .sessions import SessionManager
sessions = SessionManager(api.state.uvm_application)
api.state.nest_sessions = sessions
previous_lifespan = api.router.lifespan_context
@asynccontextmanager
async def lifespan(app):
async with previous_lifespan(app):
try:
yield
finally:
await sessions.shutdown()
api.router.lifespan_context = lifespan
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.include_router(session_router)
@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)
-196
View File
@@ -1,196 +0,0 @@
"""VM-scoped session history, creation, and authenticated browser terminal streams."""
from __future__ import annotations
import asyncio
import base64
import hmac
import json
from contextlib import suppress
from typing import Literal
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Request, Response, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from uvm.app import Application
from uvm.errors import NotFoundError, UvmError
from uvm.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data
from uvm.routers.dependencies import get_authorized_application
from .sessions import SessionManager
from .session_store import MAX_SESSION_NAME_LENGTH
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
class SessionCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
rows: StrictInt = Field(default=24, ge=2, le=200)
cols: StrictInt = Field(default=80, ge=2, le=400)
class SessionResponse(BaseModel):
id: str
vm_id: str
name: str
username: str
status: Literal["starting", "active", "closed", "interrupted", "failed"]
created_at: float
updated_at: float
ended_at: float | None
rows: int
cols: int
exit_code: int | None
error: str | None
output_start: int
output_end: int
truncated: bool
class SessionRename(BaseModel):
model_config = ConfigDict(extra="forbid")
name: StrictStr = Field(min_length=1, max_length=MAX_SESSION_NAME_LENGTH)
def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager:
return request.app.state.nest_sessions
@router.get("", response_model=list[SessionResponse])
async def list_sessions(vm_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.list(vm_id)
@router.post("", response_model=SessionResponse, status_code=201)
async def create_session(vm_id: str, body: SessionCreate, sessions: SessionManager = Depends(manager)):
return await sessions.create(vm_id, body.rows, body.cols)
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.get(vm_id, session_id)
@router.post("/{session_id}/close", response_model=SessionResponse)
async def close_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.end(vm_id, session_id)
@router.patch("/{session_id}", response_model=SessionResponse)
async def rename_session(vm_id: str, session_id: str, body: SessionRename, sessions: SessionManager = Depends(manager)):
return await sessions.rename(vm_id, session_id, body.name)
@router.delete("/{session_id}", status_code=204)
async def delete_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)) -> Response:
await sessions.delete(vm_id, session_id)
return Response(status_code=204)
def _same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin")
if origin is None:
return True # Non-browser API clients must still authenticate.
try:
parsed = urlsplit(origin)
except ValueError:
return False
scheme = "https" if websocket.url.scheme == "wss" else "http"
return parsed.scheme == scheme and parsed.netloc.lower() == websocket.headers.get("host", "").lower() and not parsed.path
async def _message(websocket: WebSocket, *, limit: int = MAX_TERMINAL_FRAME) -> dict:
text = await websocket.receive_text()
if len(text) > limit:
raise ProtocolError("terminal message exceeds its size limit")
message = json.loads(text)
if not isinstance(message, dict):
raise ProtocolError("terminal message must be an object")
return message
@router.websocket("/{session_id}/terminal")
async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
if not _same_origin(websocket):
await websocket.close(code=1008)
return
await websocket.accept()
tasks = []
attached = False
try:
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
# frame so tokens never appear in URLs, access logs, or session history.
auth = await asyncio.wait_for(_message(websocket, limit=4096), timeout=10)
token = auth.get("token")
expected = websocket.app.state.uvm_application.settings.api_token
if (auth.get("type") != "auth" or not isinstance(token, str) or not token.isascii()
or not expected or not hmac.compare_digest(token, expected)):
await websocket.close(code=4401)
return
sessions = websocket.app.state.nest_sessions
await sessions.get(vm_id, session_id)
attached = True
offset = auth.get("offset", 0)
acknowledgements = asyncio.Queue(maxsize=1)
async def output():
cursor = offset
previous = None
while True:
record, start, data = await sessions.read(vm_id, session_id, cursor)
if previous != record or start != cursor:
await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start})
previous = record
cursor = start
if data:
await websocket.send_json({"type": "output", "offset": cursor, "data": base64.b64encode(data).decode("ascii")})
cursor += len(data)
# Only one output chunk may be waiting for the terminal renderer.
acknowledged = await asyncio.wait_for(acknowledgements.get(), timeout=30)
if type(acknowledged) is not int or acknowledged != cursor:
raise ProtocolError("invalid terminal output acknowledgement")
continue
if record["status"] not in {"active", "starting"} and not sessions.is_deleting(session_id):
return
await sessions.wait(session_id)
async def controls():
while True:
message = await _message(websocket)
if set(message) == {"type", "data"} and message["type"] == "input":
await sessions.write(vm_id, session_id, terminal_data(message["data"]))
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
await sessions.resize(vm_id, session_id, message["rows"], message["cols"])
elif set(message) == {"type", "offset"} and message["type"] == "ack":
if acknowledgements.full():
raise ProtocolError("unexpected terminal output acknowledgement")
acknowledgements.put_nowait(message["offset"])
else:
raise ProtocolError("invalid terminal control message")
tasks = [asyncio.create_task(output()), asyncio.create_task(controls())]
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
except WebSocketDisconnect:
pass
except NotFoundError as error:
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json(
{"type": "deleted", "session_id": session_id} if attached
else {"type": "error", "message": str(error)}
)
except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError):
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
except UvmError as error:
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": str(error)})
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.close()
-203
View File
@@ -1,203 +0,0 @@
"""Private, durable session metadata and bounded PTY transcripts, separate from VM state."""
from __future__ import annotations
import fcntl
import os
import sqlite3
import stat
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
MAX_SESSION_NAME_LENGTH = 80
def validate_session_name(value: object) -> str:
if not isinstance(value, str):
raise ValidationError("Session name must be text.")
name = value.strip()
if not 1 <= len(name) <= MAX_SESSION_NAME_LENGTH or not name.isprintable():
raise ValidationError(f"Session name must contain 1–{MAX_SESSION_NAME_LENGTH} printable characters.")
return name
class SessionStore:
def __init__(self, path: Path, *, max_output: int = MAX_TRANSCRIPT_BYTES) -> None:
self.path = path
self.max_output = max_output
self._mutex = threading.RLock()
self._owner = None
try:
path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
if path.parent.is_symlink():
raise StateError("session history directory must not be a symlink")
path.parent.chmod(0o700)
self._owner = self._private_file(path.with_suffix(".lock"))
try:
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise ConflictError("Another Nest server is already managing sessions for this VM registry.") from error
descriptor = self._private_file(path)
os.close(descriptor)
with self._db() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, vm_id TEXT NOT NULL, name TEXT NOT NULL,
username TEXT NOT NULL, status TEXT NOT NULL,
created_at REAL NOT NULL, updated_at REAL NOT NULL, ended_at REAL,
rows INTEGER NOT NULL, cols INTEGER NOT NULL, exit_code INTEGER, error TEXT,
output_start INTEGER NOT NULL DEFAULT 0, output_end INTEGER NOT NULL DEFAULT 0,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS sessions_vm ON sessions(vm_id, created_at);
CREATE TABLE IF NOT EXISTS output (
session_id TEXT NOT NULL REFERENCES sessions(id), offset INTEGER NOT NULL,
data BLOB NOT NULL, PRIMARY KEY (session_id, offset)
);
""")
now = time.time()
db.execute("""UPDATE sessions SET status='interrupted', ended_at=?, updated_at=?,
error='Nest restarted; this shell is no longer connected.'
WHERE status IN ('starting', 'active')""", (now, now))
except BaseException:
self.close()
raise
@staticmethod
def _private_file(path: Path) -> int:
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
raise StateError("session history must use private regular files")
os.fchmod(descriptor, 0o600)
return descriptor
except BaseException:
os.close(descriptor)
raise
@contextmanager
def _db(self):
with self._mutex:
db = None
try:
db = sqlite3.connect(self.path, timeout=5)
db.row_factory = sqlite3.Row
with db:
yield db
except sqlite3.Error as error:
raise StateError("Could not read or save terminal session history.") from error
finally:
if db is not None:
db.close()
@staticmethod
def _record(row) -> dict:
if row is None:
raise NotFoundError("Session not found for this VM.")
result = dict(row)
result["truncated"] = bool(result["truncated"])
return result
def create(self, vm_id: str, rows: int, cols: int) -> dict:
session_id = "session-" + uuid.uuid4().hex
now = time.time()
with self._db() as db:
names = [row[0] for row in db.execute("SELECT name FROM sessions WHERE vm_id=?", (vm_id,))]
number = len(names) + 1
while f"Session {number}" in names:
number += 1
db.execute("""INSERT INTO sessions
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
(session_id, vm_id, f"Session {number}", now, now, rows, cols))
return self.get(vm_id, session_id)
def list(self, vm_id: str) -> list[dict]:
with self._db() as db:
return [self._record(row) for row in db.execute(
"SELECT * FROM sessions WHERE vm_id=? ORDER BY created_at DESC, id DESC", (vm_id,),
)]
def get(self, vm_id: str, session_id: str) -> dict:
with self._db() as db:
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
def rename(self, vm_id: str, session_id: str, name: str) -> dict:
name = validate_session_name(name)
with self._db() as db:
self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
db.execute("UPDATE sessions SET name=?, updated_at=? WHERE vm_id=? AND id=?",
(name, time.time(), vm_id, session_id))
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
def delete(self, vm_id: str, session_id: str) -> None:
with self._db() as db:
record = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
if record["status"] in {"starting", "active"}:
raise ConflictError("End the session before deleting its history.")
# Both tables are removed in one transaction, after the output reader
# has finished. Explicit cleanup also supports existing databases.
db.execute("DELETE FROM output WHERE session_id=?", (session_id,))
db.execute("DELETE FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id))
def activate(self, session_id: str, username: str) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
(username, time.time(), session_id))
def resize(self, session_id: str, rows: int, cols: int) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET rows=?, cols=? WHERE id=?", (rows, cols, session_id))
def finish(self, session_id: str, status: str, exit_code: int | None = None, error: str | None = None) -> None:
now = time.time()
with self._db() as db:
db.execute("""UPDATE sessions SET status=?, ended_at=?, updated_at=?, exit_code=?, error=?
WHERE id=? AND status IN ('starting', 'active')""", (status, now, now, exit_code, error, session_id))
def append(self, session_id: str, data: bytes) -> None:
if not data:
return
with self._db() as db:
row = db.execute("SELECT output_end FROM sessions WHERE id=?", (session_id,)).fetchone()
if row is None:
raise NotFoundError("Session not found.")
offset = row[0]
end = offset + len(data)
start = max(0, end - self.max_output)
db.execute("INSERT INTO output VALUES (?, ?, ?)", (session_id, offset, data))
db.execute("DELETE FROM output WHERE session_id=? AND offset + length(data)<=?", (session_id, start))
first = db.execute("SELECT offset, data FROM output WHERE session_id=? AND offset<?", (session_id, start)).fetchone()
if first is not None:
db.execute("UPDATE output SET offset=?, data=? WHERE session_id=? AND offset=?",
(start, first["data"][start - first["offset"]:], session_id, first["offset"]))
db.execute("UPDATE sessions SET output_start=?, output_end=?, truncated=?, updated_at=? WHERE id=?",
(start, end, int(start > 0), time.time(), session_id))
def read(self, vm_id: str, session_id: str, offset: int, limit: int = 65536) -> tuple[dict, int, bytes]:
with self._db() as db:
session = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
if type(offset) is not int or not 0 <= offset <= session["output_end"]:
raise ValidationError("invalid terminal output offset")
start = max(offset, session["output_start"])
data = bytearray()
for row in db.execute("""SELECT offset, data FROM output
WHERE session_id=? AND offset + length(data)>? ORDER BY offset""", (session_id, start)):
part = row["data"][max(0, start - row["offset"]):]
data.extend(part[:limit - len(data)])
if len(data) >= limit:
break
return session, start, bytes(data)
def close(self) -> None:
if self._owner is not None:
os.close(self._owner)
self._owner = None
-234
View File
@@ -1,234 +0,0 @@
"""Nest-owned live terminal sessions; browser attachments do not own guest shells."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from uvm.app import Application
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
from uvm.guest_terminal import GuestTerminal, validate_size
from .session_store import SessionStore, validate_session_name
async def _persist(function, *arguments):
# Cancellation cannot release the history-owner lock while a worker still
# has an outstanding write to that database.
task = asyncio.create_task(asyncio.to_thread(function, *arguments))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
await task
raise
@dataclass
class _LiveSession:
vm_id: str
terminal: GuestTerminal
changed: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task | None = None
class SessionManager:
def __init__(self, application: Application) -> None:
self.application = application
self._store: SessionStore | None = None
self._live: dict[str, _LiveSession] = {}
self._pending: set[asyncio.Task] = set()
self._deleting: set[str] = set()
self._session_lock = asyncio.Lock()
self._closing = False
def _history(self) -> SessionStore:
if self._closing:
raise ConflictError("Nest is shutting down.")
if self._store is None:
try:
self._store = SessionStore(Path(self.application.settings.base) / "nest" / "sessions.sqlite3")
except OSError as error:
raise StateError("Could not open private terminal history; run Nest with access to the VM registry.") from error
return self._store
async def _vm(self, vm_id: str) -> None:
# Exact IDs prevent a session from following a reused VM name or IP.
await asyncio.to_thread(self.application.state_store.get, vm_id)
async def list(self, vm_id: str) -> list[dict]:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().list, vm_id)
async def get(self, vm_id: str, session_id: str) -> dict:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().get, vm_id, session_id)
async def create(self, vm_id: str, rows: int, cols: int) -> dict:
validate_size(rows, cols)
await self._vm(vm_id)
history = self._history()
# A dropped HTTP request must not abandon a just-created guest connection.
return await self._run_pending(self._create(history, vm_id, rows, cols))
async def _run_pending(self, operation):
"""Finish accepted mutations even if their HTTP request disconnects."""
task = asyncio.create_task(operation)
self._pending.add(task)
def completed(future):
self._pending.discard(future)
if not future.cancelled():
future.exception()
task.add_done_callback(completed)
return await asyncio.shield(task)
async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict:
async with self._session_lock:
if self._closing:
raise ConflictError("Nest is shutting down.")
if len(self._live) >= 32:
raise ConflictError("There are already 32 active terminal sessions. End a session before launching another.")
record = await asyncio.to_thread(history.create, vm_id, rows, cols)
terminal = None
try:
terminal, username = await asyncio.to_thread(self.application.lifecycle.open_terminal, vm_id, rows=rows, cols=cols)
await asyncio.to_thread(history.activate, record["id"], username)
live = _LiveSession(vm_id, terminal)
self._live[record["id"]] = live
live.task = asyncio.create_task(self._read(history, record["id"], live))
except BaseException as error:
if terminal is not None:
terminal.close()
message = str(error) if isinstance(error, UvmError) else "Could not launch the guest terminal."
await asyncio.to_thread(history.finish, record["id"], "failed", None, message)
raise
return await asyncio.to_thread(history.get, vm_id, record["id"])
async def _read(self, history: SessionStore, session_id: str, live: _LiveSession) -> None:
status, exit_code, error = "interrupted", None, None
try:
while True:
message = await live.terminal.read()
if message["type"] == "exit":
status, exit_code = "closed", message["exit_code"]
break
await _persist(history.append, session_id, message["data"])
live.changed.set()
except asyncio.CancelledError:
error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted."
except (UvmError, OSError) as exception:
error = str(exception) if isinstance(exception, UvmError) else "The guest terminal connection was interrupted."
finally:
live.terminal.close()
try:
await _persist(history.finish, session_id, status, exit_code, error)
finally:
live.changed.set()
self._live.pop(session_id, None)
async def read(self, vm_id: str, session_id: str, offset: int) -> tuple[dict, int, bytes]:
return await asyncio.to_thread(self._history().read, vm_id, session_id, offset)
async def wait(self, session_id: str) -> None:
live = self._live.get(session_id)
if live is None:
await asyncio.sleep(0.1)
return
try:
await asyncio.wait_for(live.changed.wait(), timeout=1)
except TimeoutError:
pass
live.changed.clear()
async def _active(self, vm_id: str, session_id: str) -> _LiveSession:
live = self._live.get(session_id)
if live is None:
await self.get(vm_id, session_id)
raise ConflictError("This session has ended. Launch a new session to run commands.")
if live.vm_id != vm_id:
raise NotFoundError("Session not found for this VM.")
return live
async def write(self, vm_id: str, session_id: str, data: bytes) -> None:
live = await self._active(vm_id, session_id)
try:
await live.terminal.write(data)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def resize(self, vm_id: str, session_id: str, rows: int, cols: int) -> None:
validate_size(rows, cols)
live = await self._active(vm_id, session_id)
try:
await live.terminal.resize(rows, cols)
await asyncio.to_thread(self._history().resize, session_id, rows, cols)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def end(self, vm_id: str, session_id: str) -> dict:
await self.get(vm_id, session_id)
return await self._run_pending(self._end(self._history(), vm_id, session_id))
async def _end(self, history: SessionStore, vm_id: str, session_id: str) -> dict:
async with self._session_lock:
return await self._end_locked(history, vm_id, session_id)
async def _end_locked(self, history: SessionStore, vm_id: str, session_id: str) -> dict:
record = await asyncio.to_thread(history.get, vm_id, session_id)
live = self._live.get(session_id)
if live is not None:
try:
await live.terminal.end()
await asyncio.wait_for(asyncio.shield(live.task), timeout=8)
except (OSError, UvmError):
live.task.cancel()
await asyncio.gather(live.task, return_exceptions=True)
elif record["status"] in {"starting", "active"}:
# The session lock excludes creation here. A missing channel can
# only be an interrupted session whose final metadata write failed.
await _persist(history.finish, session_id, "interrupted", None, "This shell is no longer connected.")
return await asyncio.to_thread(history.get, vm_id, session_id)
async def rename(self, vm_id: str, session_id: str, name: str) -> dict:
name = validate_session_name(name)
await self.get(vm_id, session_id)
return await self._run_pending(self._rename(self._history(), vm_id, session_id, name))
async def _rename(self, history: SessionStore, vm_id: str, session_id: str, name: str) -> dict:
record = await _persist(history.rename, vm_id, session_id, name)
live = self._live.get(session_id)
if live is not None:
live.changed.set()
return record
async def delete(self, vm_id: str, session_id: str) -> None:
await self.get(vm_id, session_id)
await self._run_pending(self._delete(self._history(), vm_id, session_id))
async def _delete(self, history: SessionStore, vm_id: str, session_id: str) -> None:
# Creation must finish before deletion can close its channel. Join the
# output reader (including pending disk writes) before removing history.
async with self._session_lock:
self._deleting.add(session_id)
try:
await self._end_locked(history, vm_id, session_id)
await _persist(history.delete, vm_id, session_id)
finally:
self._deleting.discard(session_id)
def is_deleting(self, session_id: str) -> bool:
return session_id in self._deleting
async def shutdown(self) -> None:
self._closing = True
await asyncio.gather(*self._pending, return_exceptions=True)
tasks = [live.task for live in self._live.values()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if self._store is not None:
self._store.close()
+3 -6
View File
@@ -5,25 +5,22 @@ build-backend = "setuptools.build_meta"
[project]
name = "uvm-cli"
version = "0.1.0"
description = "Nest workspace and local Firecracker microVM management"
description = "Local Firecracker microVM command-line manager"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<1",
"pydantic>=2,<3",
"uvicorn>=0.30,<1",
"websockets>=13,<17",
]
[project.optional-dependencies]
test = ["httpx>=0.27,<1"]
[project.scripts]
nest = "nest.cli:main"
uvm = "uvm.cli:main"
[tool.setuptools]
packages = ["nest", "nest.dashboard", "uvm", "uvm.firecracker", "uvm.routers"]
include-package-data = false
packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"]
[tool.setuptools.package-data]
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt", "static/vendor/*.md", "static/vendor/*/LICENSE", "static/vendor/*/lib/*.js", "static/vendor/*/css/*.css"]
"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
-82
View File
@@ -1,82 +0,0 @@
"""Isolated browser-test server: all VM and terminal operations are fakes."""
from __future__ import annotations
import argparse
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import uvicorn
from nest.server import create_app
from tests.test_server import FakeLifecycle, fake_application
from tests.test_sessions import FakeTerminal
from uvm.errors import ConflictError, NotFoundError
from uvm.lifecycle import ListedVm
class BrowserTerminal(FakeTerminal):
def __init__(self):
super().__init__()
self.command = bytearray()
async def write(self, data):
await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")})
for byte in data:
if byte == 13:
await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "})
self.command.clear()
elif byte == 3:
self.command.clear()
else:
self.command.append(byte)
class BrowserLifecycle(FakeLifecycle):
def __init__(self):
super().__init__()
self.vm.username = "worker"
self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")}
def get(self, vm_id):
try:
return self.records[vm_id]
except KeyError as error:
raise NotFoundError("VM not found") from error
def inspect(self, identifier):
for vm in self.records.values():
if identifier in {vm.id, vm.name, vm.guest_ip}:
return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive")
raise NotFoundError("VM not found")
def list_vms(self):
return [self.inspect(vm_id) for vm_id in self.records]
def open_terminal(self, vm_id, **_size):
if self.get(vm_id).status != "running":
raise ConflictError("Start the machine first")
return BrowserTerminal(), "worker"
def install_guest_agent(self, vm_id):
return self.get(vm_id)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
application = fake_application(api_token="browser-test-token")
application.settings.base = args.base
application.lifecycle = BrowserLifecycle()
application.state_store = SimpleNamespace(get=application.lifecycle.get)
api = create_app(application, host="127.0.0.1")
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
api.state.uvicorn_server = server
server.run()
if __name__ == "__main__":
main()
-181
View File
@@ -1,181 +0,0 @@
// Optional integration test: requires Playwright + Chromium and the project's test extra.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { mkdtemp, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { chromium } = require('playwright');
(async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
const origin = `http://127.0.0.1:${port}`;
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
let logs = '';
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
let browser;
let page;
try {
let ready = false;
for (let attempt = 0; attempt < 100; attempt++) {
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
try {
const response = await fetch(`${origin}/health`);
if (response.ok) { ready = true; break; }
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, `Fixture server did not start: ${logs}`);
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
await page.goto(`${origin}/dashboard#vm/vm-test`);
await page.locator('#token-input').fill('browser-test-token');
await page.locator('#auth-btn').click();
await page.locator('#detail-actions [data-action="launch-session"]').click();
await connected();
const firstUrl = page.url();
assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
await page.keyboard.type('echo alpha');
await page.keyboard.press('Enter');
await output('result:echo alpha');
await page.reload();
await connected();
await output('result:echo alpha');
assert.equal(await page.locator('.session-item').count(), 1);
// Rename is cancellable, validated, persisted, and preserves the live widget.
const firstId = new URL(firstUrl).hash.split('/').at(-1);
const firstRow = () => page.locator(`.session-item[data-session-id="${firstId}"]`);
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Discarded name');
await page.locator('#session-rename-cancel').click();
assert.equal(await firstRow().locator('strong').textContent(), 'Session 1');
await page.evaluate(() => { window.originalTerminal = document.querySelector('#session-terminal .xterm'); });
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill(' ');
await page.locator('#session-rename-save').click();
assert.ok(await page.locator('#session-rename-dialog').isVisible());
assert.match(await page.locator('#session-rename-error').textContent(), /Enter a session name/);
await page.locator('#session-name-input').fill(' Deployment ');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Deployment'));
assert.equal(page.url(), firstUrl);
assert.ok(await page.evaluate(() => window.originalTerminal === document.querySelector('#session-terminal .xterm')));
await page.reload();
await connected();
await output('result:echo alpha');
assert.equal(await firstRow().locator('strong').textContent(), 'Deployment');
await page.locator('#detail-actions [data-action="launch-session"]').click();
await page.waitForURL((url) => url.href !== firstUrl);
await connected();
const secondUrl = page.url();
assert.equal(await page.locator('.session-item').count(), 2);
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
await page.keyboard.type('echo beta');
await page.keyboard.press('Enter');
await output('result:echo beta');
// History actions also work without selecting the affected session.
await firstRow().locator('[data-session-action="rename"]').click();
await page.locator('#session-name-input').fill('Operations <alpha>');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
assert.equal(page.url(), secondUrl);
assert.equal(await firstRow().locator('strong').textContent(), 'Operations <alpha>');
assert.equal(await firstRow().locator('alpha').count(), 0);
await output('result:echo beta');
await firstRow().locator('.session-link').click();
await connected();
await output('result:echo alpha');
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
assert.equal(await page.locator('.session-item').count(), 0);
assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
await page.goto(firstUrl);
await connected();
await output('result:echo alpha');
await page.locator('#session-end').click();
await page.locator('#confirm-ok').click();
await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
await page.reload();
await output('result:echo alpha');
assert.ok(await page.locator('#session-end').isDisabled());
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Archived run');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await page.reload();
await output('result:echo alpha');
assert.equal(await firstRow().locator('strong').textContent(), 'Archived run');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(300);
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
assert.ok(fits, 'mobile layout overflows the viewport');
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
// Cancel preserves history; confirmation removes an ended session and clears selection.
await page.locator('#session-delete').click();
await page.locator('#confirm-dialog button[value="cancel"]').click();
assert.equal(await page.locator('.session-item').count(), 2);
await firstRow().locator('[data-session-action="delete"]').click();
await page.locator('#confirm-ok').click();
await page.waitForURL((url) => url.hash === '#vm/vm-test/sessions');
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1');
assert.ok(await page.locator('#session-terminal').isHidden());
await page.reload();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1');
assert.equal(await firstRow().count(), 0);
// Deleting an active session clears other attached browser views as well.
await page.goto(secondUrl);
await connected();
await output('result:echo beta');
const observer = await page.context().newPage();
observer.on('pageerror', (error) => errors.push(error.message));
await observer.goto(secondUrl);
await observer.locator('#token-input').fill('browser-test-token');
await observer.locator('#auth-btn').click();
await observer.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Second workspace');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await observer.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Second workspace'));
await page.locator('#session-delete').click();
await page.locator('#confirm-ok').click();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
await observer.waitForURL((url) => url.hash === '#vm/vm-test/sessions');
assert.ok(await observer.locator('#session-terminal').isHidden());
await page.reload();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
assert.equal(await page.locator('.session-item').count(), 0);
assert.deepEqual(errors, []);
await observer.close();
console.log('Browser sessions passed: terminal/reconnect, VM isolation, persistent rename, active/ended deletion, cancellation, live browser updates, and mobile layout.');
} catch (error) {
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
console.error(logs);
throw error;
} finally {
await browser?.close();
server.kill('SIGTERM');
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
await rm(temporary, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
-130
View File
@@ -1,130 +0,0 @@
"""Browser fixture with the real API/job workers and simulated guest execution."""
from __future__ import annotations
import argparse
import json
import shlex
import threading
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import uvicorn
from nest.server import create_app
from tests.browser_session_server import BrowserLifecycle
from uvm.config import Settings
from uvm.domain import new_vm_id
from uvm.errors import ConflictError, GuestExecutionError
from uvm.guest_exec import ExecResult
from uvm.state import StateStore
from uvm.tool_store import ToolJobStore
from uvm.tools import ToolService
class FixtureConnection:
def __init__(self):
self.closed = threading.Event()
def close(self):
self.closed.set()
class ToolBrowserLifecycle(BrowserLifecycle):
def __init__(self, settings, state, *, delay=1.2):
super().__init__()
self.settings, self.state, self.delay = settings, state, delay
self.tools = ToolJobStore(settings, state)
self.installed = {}
self.attempts = {}
for index, vm in enumerate(self.records.values()):
vm.runtime_owned = True
vm.pid, vm.process_start_time, vm.boot_id = index + 100, "1", "fixture-boot"
settings.vm_dir(vm.id).mkdir(parents=True, mode=0o700, exist_ok=True)
with state.transaction() as document:
document.vms.update(self.records)
def create(self, spec):
vm = replace(self.vm, id=new_vm_id(), name=spec.name, username=spec.username, cpu=spec.cpu,
ram_mib=spec.ram_mib, storage_mib=spec.storage_mib, ports=spec.ports)
with self.state.operation_lock():
self.settings.vm_dir(vm.id).mkdir(mode=0o700)
self.records[vm.id] = vm
with self.state.transaction() as document:
document.vms[vm.id] = vm
if spec.tools:
self.tools.enqueue_locked(vm.id, spec.tools, source="create")
return vm
def queue_tools(self, vm_id, selections, **options):
self.state.get(vm_id)
with self.state.operation_lock():
return self.tools.enqueue_locked(vm_id, selections, **options)
def prepare_tool_install(self, vm_id, *, start_if_stopped=False, restart=False):
with self.state.operation_lock():
vm = self.records[vm_id]
if vm.status == "stopped" and not (start_if_stopped or restart):
raise ConflictError("start the VM first")
vm.status = "running"
with self.state.transaction() as document:
document.vms[vm.id] = vm
return vm
def execute(self, vm_id, command, *, expected_identity=None, on_connect=None, **_options):
self.state.get(vm_id)
payload = json.loads(shlex.split(command)[-1])
connection = FixtureConnection()
if on_connect:
on_connect(connection)
installed = self.installed.setdefault(vm_id, {"curl"})
def observation(tool):
versions = {"nodejs": "24.21.0", "jdk": "21.0.12", "php": "8.5.11", "conda": "26.5.3", "micromamba": "2.9.0"}
version = tool["version"] + ".15" if tool["id"] == "python" else versions.get(tool["id"], "2.0.0")
return {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": tool["key"] in installed,
"detected_version": version if tool["key"] in installed else None, "error": None, "usage": tool["usage"]}
if payload["action"] == "probe":
result = {"ok": True, "busy": False, "boot_id": "guest-fixture", "supported": True,
"tools": [observation(tool) for tool in payload["tools"]]}
else:
if connection.closed.wait(self.delay):
raise GuestExecutionError("fixture connection closed", outcome_unknown=True)
tool = payload["tools"][0]
key = (vm_id, tool["key"])
self.attempts[key] = self.attempts.get(key, 0) + 1
if tool["id"] == "php" and self.attempts[key] == 1:
result = {"ok": False, "error": "Simulated mirror failure", "output": "<script>bad()</script>\nTry again later."}
else:
installed.add(tool["key"])
result = {"ok": True, "status": "installed", "tool": observation(tool),
"output": f"Verified and installed {tool['key']} inside the guest."}
return ExecResult(0, json.dumps(result), "", False, False)
def fixture_application(base, *, delay=1.2):
settings = replace(Settings(), base=base, api_token="browser-test-token", tool_workers=2)
state = StateStore(settings)
lifecycle = ToolBrowserLifecycle(settings, state, delay=delay)
return SimpleNamespace(settings=settings, state_store=state, lifecycle=lifecycle,
tools=ToolService(settings, state, lifecycle))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
application = fixture_application(args.base)
with patch("uvm.tools.require_root"):
api = create_app(application, host="127.0.0.1")
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
api.state.uvicorn_server = server
server.run()
if __name__ == "__main__":
main()
-106
View File
@@ -1,106 +0,0 @@
// Optional browser integration: real API/job persistence, simulated guest commands.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { mkdtemp, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { chromium } = require('playwright');
(async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-tool-browser-'));
const port = process.env.NEST_BROWSER_TEST_PORT || '18766';
const origin = `http://127.0.0.1:${port}`;
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
['-B', '-m', 'tests.browser_tool_server', '--base', temporary, '--port', port],
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
let logs = '';
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
let browser;
let page;
try {
let ready = false;
for (let attempt = 0; attempt < 100; attempt++) {
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
try { if ((await fetch(`${origin}/health`)).ok) { ready = true; break; } } catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, logs);
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const installed = (key) => page.waitForFunction((value) =>
document.querySelector(`[data-install-tool="${value}"]`)?.textContent === 'Installed', key);
await page.goto(`${origin}/dashboard#create`);
await page.locator('#token-input').fill('browser-test-token');
await page.locator('#auth-btn').click();
await page.locator('[data-create-tool="python-3.12"]').check();
await page.locator('[data-create-tool="git"]').check();
await page.locator('[data-create-tool="nodejs"]').check();
assert.equal(await page.locator('[data-create-tool]').count(), 12);
assert.match(await page.locator('#create-summary-details').textContent(), /Git, Node.js, Python 3.12/);
const layout = await page.evaluate(() => ({
parent: document.querySelector('.create-tools-section').parentElement.className,
children: document.querySelector('#create-form').children.length,
}));
assert.equal(layout.parent, 'create-fields');
assert.equal(layout.children, 2);
await page.locator('#create-name').fill('tool-workspace');
await page.locator('#create-submit').click();
await page.waitForURL((url) => /#vm\/vm-[a-f0-9]+\/tools$/.test(url.hash));
const createdUrl = page.url();
await page.locator('#tools-job').waitFor({ state: 'visible' });
// A reload can occur while the server-owned worker is still installing.
await page.reload();
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
await installed('curl');
assert.equal(await page.locator('.tool-installed').count(), 4); // Includes image-provided curl.
assert.ok(await page.locator('[data-install-tool="python-3.12"]').isDisabled());
await page.locator('#tools-job-items details').first().locator('summary').click();
assert.match(await page.locator('#tools-job-items').textContent(), /Verified and installed git/);
await page.locator('[data-install-tool="php"]').click();
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Failed');
await page.locator('#tools-job-items details[data-output="php"] summary').click();
assert.match(await page.locator('.tool-output').textContent(), /<script>bad/);
assert.equal(await page.locator('#tools-job-items script').count(), 0);
await page.locator('#tools-retry').click();
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
await installed('php');
assert.ok(await page.locator('[data-install-tool="php"]').isDisabled());
assert.equal(await page.locator('#tools-job-select option').count(), 3);
// The same click boots a stopped VM before installing its selected tool.
await page.goto(`${origin}/dashboard#vm/vm-other/tools`);
await page.locator('[data-install-tool="htop"]').waitFor();
assert.equal(await page.locator('[data-install-tool="htop"]').textContent(), 'Start & install');
await page.locator('[data-install-tool="htop"]').click();
await installed('htop');
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
assert.ok(await page.locator('[data-install-tool="htop"]').isDisabled());
assert.ok(!(await page.locator('#tools-job-items').textContent()).includes('python-3.12'));
await page.goto(createdUrl);
await page.locator('[data-tool-select="wget"]').check();
await page.locator('[data-tool-select="micromamba"]').check();
await page.locator('#tools-install-selected').click();
await installed('wget');
await installed('micromamba');
await page.waitForFunction(() => document.querySelector('#tools-job-title').textContent === 'Complete');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(250);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'mobile layout overflows');
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
assert.deepEqual(errors, []);
console.log('Browser tools passed: creation, background/reload, detection, failure/retry, safe output, history, Start & install, VM isolation, multi-select, and mobile layout.');
} catch (error) {
if (page) console.error('Tool UI:', await page.locator('#tools-error').textContent(), await page.locator('#tools-job-message').textContent());
console.error(logs);
throw error;
} finally {
await browser?.close();
server.kill('SIGTERM');
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
await rm(temporary, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
-89
View File
@@ -1,89 +0,0 @@
"""Opt-in checks against an already-provisioned disposable VM on an isolated host."""
from __future__ import annotations
import json
import os
import unittest
from urllib.parse import quote
from urllib.request import Request, urlopen
@unittest.skipUnless(os.environ.get("UVM_EXEC_INTEGRATION") == "1", "set UVM_EXEC_INTEGRATION=1 on an isolated VM host")
class GuestExecIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.api_url = os.environ.get("UVM_API_URL", "http://127.0.0.1:8000").rstrip("/")
cls.token = os.environ["UVM_API_TOKEN"]
cls.vm_id = os.environ["UVM_EXEC_VM_ID"]
cls.path = f"/vms/{quote(cls.vm_id, safe='')}"
vm = cls.request("GET", cls.path)
if (vm["id"] != cls.vm_id or vm["observed_status"] != "running"
or vm["username"] == "root" or vm["ports"] != []):
raise RuntimeError("provide a running disposable VM with a non-root login, installed agent, and empty TCP allowlist")
cls.username = vm["username"]
@classmethod
def request(cls, method, path, body=None):
request = Request(
cls.api_url + path, method=method,
data=json.dumps(body).encode("utf-8") if body is not None else None,
headers={"X-UVM-Token": cls.token, "Content-Type": "application/json"},
)
with urlopen(request, timeout=30) as response:
return json.load(response)
def execute(self, command, *, sudo=False, timeout=10):
return self.request("POST", self.path + "/exec", {
"command": command, "sudo": sudo, "timeout_seconds": timeout,
})
def test_user_and_root_execution_work_with_all_tcp_ports_closed(self) -> None:
ordinary = self.execute("id -u; whoami")
self.assertEqual(ordinary["exit_code"], 0)
uid, username = ordinary["stdout"].splitlines()
self.assertNotEqual(uid, "0")
self.assertEqual(username, self.username)
elevated = self.execute("id -u", sudo=True)
self.assertEqual(elevated["exit_code"], 0)
self.assertEqual(elevated["stdout"], "0\n")
def test_multiline_script_returns_output_and_nonzero_status(self) -> None:
result = self.execute("printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7")
self.assertEqual(result, {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
def test_timeout_kills_a_descendant_that_started_a_new_session(self) -> None:
result = self.execute("""python3 - <<'PY'
import os, time
child = os.fork()
if child == 0:
os.setsid()
else:
print(child, flush=True)
time.sleep(30)
PY""", timeout=2)
self.assertTrue(result["timed_out"])
self.assertIsNone(result["exit_code"])
child = int(result["stdout"].strip())
checked = self.execute(f"""python3 - <<'PY'
from pathlib import Path
try:
state = Path('/proc/{child}/stat').read_text().rsplit(') ', 1)[1].split()[0]
except FileNotFoundError:
state = 'gone'
assert state in ('gone', 'Z'), state
PY""")
self.assertEqual(checked["exit_code"], 0, checked["stderr"])
def test_output_is_bounded_while_both_pipes_are_drained(self) -> None:
result = self.execute("""python3 - <<'PY'
import os
for _ in range(32):
os.write(1, b'o' * 65536)
os.write(2, b'e' * 65536)
PY""")
self.assertEqual(result["exit_code"], 0)
self.assertTrue(result["truncated"])
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), 1024 * 1024)
-96
View File
@@ -1,96 +0,0 @@
"""Opt-in tool installation smoke test against a prepared, disposable microVM."""
from __future__ import annotations
import json
import os
import time
import unittest
import uuid
from urllib.parse import quote
from urllib.request import Request, urlopen
@unittest.skipUnless(os.environ.get("UVM_TOOLS_INTEGRATION") == "1",
"set UVM_TOOLS_INTEGRATION=1 on an isolated disposable VM host")
class GuestToolsIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.api_url = os.environ.get("UVM_API_URL", "http://127.0.0.1:8000").rstrip("/")
cls.token = os.environ["UVM_API_TOKEN"]
cls.vm_id = os.environ["UVM_TOOLS_VM_ID"]
cls.path = "/vms/" + quote(cls.vm_id, safe="")
cls.vm = cls.request("GET", cls.path)
if (cls.vm["id"] != cls.vm_id or cls.vm["observed_status"] != "running"
or cls.vm["username"] == "root" or cls.vm["ports"] != []):
raise RuntimeError("provide a disposable running Ubuntu 22.04 VM with a non-root user and no open TCP ports")
@classmethod
def request(cls, method, path, body=None, *, timeout=120):
request = Request(cls.api_url + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"X-UVM-Token": cls.token, "Content-Type": "application/json"})
with urlopen(request, timeout=timeout) as response:
return json.load(response)
def test_install_catalog_and_use_runtimes_as_the_guest_user(self):
catalog = self.request("GET", "/tools")
selections = [{"id": tool["id"], "version": version} for tool in catalog for version in tool["versions"] or [None]]
body = {"tools": selections, "request_id": str(uuid.uuid4())}
job = self.request("POST", self.path + "/tools/install", body)
deadline = time.monotonic() + 3600
while job["status"] in {"queued", "starting", "waiting", "installing"} and time.monotonic() < deadline:
time.sleep(2)
job = self.request("GET", self.path + "/tools/jobs/" + job["id"])
self.assertEqual(job["status"], "succeeded", json.dumps(job, indent=2))
self.assertEqual(self.request("POST", self.path + "/tools/install", body)["id"], job["id"])
inventory = self.request("GET", self.path + "/tools?refresh=true")
self.assertEqual(len(inventory["inventory"]), 12)
self.assertTrue(all(item["installed"] for item in inventory["inventory"]), inventory)
script = r'''set -eu
test "$(id -u)" != 0
git --version
node -e 'if (1 + 1 !== 2) process.exit(1)'
npm --version
wget --version >/dev/null
curl --version >/dev/null
htop --version
php -r 'if (2 + 2 !== 4) exit(1);'
work=$(mktemp -d "$HOME/nest-tools-smoke.XXXXXX")
name=$(basename "$work")
export MAMBA_ROOT_PREFIX="$HOME/micromamba"
cleanup() {
conda env remove -n "$name" -y >/dev/null 2>&1 || true
micromamba env remove -n "$name" -y >/dev/null 2>&1 || true
rm -rf -- "$work"
}
trap cleanup EXIT
printf 'class Hello { public static void main(String[] args) { System.out.println("ok"); } }\n' > "$work/Hello.java"
javac "$work/Hello.java"
test "$(java -cp "$work" Hello)" = ok
for version in 3.11 3.12 3.13; do
"python$version" -m venv "$work/venv-$version"
"$work/venv-$version/bin/python" -c 'import ssl, sqlite3, sys; assert sys.prefix != sys.base_prefix'
"$work/venv-$version/bin/python" -m pip --version
done
conda create -y -n "$name" python=3.13
conda run -n "$name" python -c 'import sys; assert sys.version_info[:2] == (3, 13)'
eval "$(conda shell.posix hook)"
conda activate "$name"
python -c 'import sys; assert sys.version_info[:2] == (3, 13)'
conda deactivate
micromamba create -y -n "$name" -c conda-forge python=3.12
micromamba run -n "$name" python -c 'import sys; assert sys.version_info[:2] == (3, 12)'
eval "$(micromamba shell hook --shell posix)"
micromamba activate "$name"
python -c 'import sys; assert sys.version_info[:2] == (3, 12)'
micromamba deactivate
'''
result = self.request("POST", self.path + "/exec",
{"command": script, "sudo": False, "timeout_seconds": 1200}, timeout=1220)
self.assertFalse(result["timed_out"], result)
self.assertEqual(result["exit_code"], 0, result)
after = self.request("GET", self.path)
for key in ("id", "guest_ip", "mac", "username", "ports", "cpu", "ram_mib", "storage_mib"):
self.assertEqual(after[key], self.vm[key])
+11 -71
View File
@@ -2,18 +2,16 @@
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create
network namespaces (root, or unprivileged user namespaces). No sudo, installation,
Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network
namespaces (root, or unprivileged user namespaces). No sudo, installation,
/run/netns mounts, host firewall changes or br_netfilter sysctls are used.
Bridge-connected veths stand in for guest TAPs. A third namespace with two
uplinks tests NAT, offline startup, reconnects and host route changes without
using the real internet. Ordinary test runs skip without executing commands.
Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests
guest egress without NAT. Ordinary test runs skip without executing commands.
"""
from __future__ import annotations
import contextlib
import errno
import json
import os
import re
@@ -64,13 +62,6 @@ class SocketEndpoint:
except TimeoutError:
sock.close()
return False
except OSError as error:
sock.close()
if operation == "connect" and error.errno in {
errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, errno.ECONNREFUSED,
}:
return False
raise
except BaseException:
sock.close()
raise
@@ -215,7 +206,6 @@ def _sandbox(parent_fd):
from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError
from uvm.firewall import FirewallManager
from uvm.network import NetworkManager
from uvm.system import CommandRunner
runner = CommandRunner(emit=None)
@@ -230,12 +220,11 @@ def _sandbox(parent_fd):
manager = FirewallManager(settings, runner)
manager.check_available()
run("ip", "link", "set", "lo", "up")
# All changes are inside the freshly verified namespace. A pre-existing
# terminal drop catches regressions that append ineffective forward rules.
run("iptables", "-P", "FORWARD", "DROP")
run("iptables", "-A", "FORWARD", "-j", "DROP")
assert not run("ip", "route", "show", "default").strip()
NetworkManager(settings, runner).ensure_bridge()
run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0")
run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge)
run("ip", "link", "set", settings.bridge, "up")
# This IPv4 sysctl belongs only to the freshly verified network namespace.
run("sysctl", "-w", "net.ipv4.ip_forward=1")
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge)
@@ -274,16 +263,6 @@ def _sandbox(parent_fd):
run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)])
run("ip", "link", "add", "fw-up2", "type", "veth", "peer", "name", "fw-end2")
run("ip", "link", "set", "fw-end2", "netns", str(outside.process.pid))
run("ip", "addr", "add", "172.30.74.1/24", "dev", "fw-up2")
run("ip", "link", "set", "fw-up2", "up")
outside.request("run", command=["ip", "link", "set", "fw-end2", "name", "eth1"])
outside.request("run", command=["ip", "addr", "add", "172.30.74.2/24", "dev", "eth1"])
outside.request("run", command=["ip", "link", "set", "eth1", "up"])
internet_ip = "203.0.113.73"
outside.request("run", command=["ip", "addr", "add", f"{internet_ip}/32", "dev", "lo"])
manager.apply(vm)
manager.apply(peer_vm)
for tap in (vm.tap, peer_vm.tap):
@@ -317,7 +296,7 @@ def _sandbox(parent_fd):
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
def service_ids():
listing = run("conntrack", "-L", "--family", "ipv4", "--proto", "tcp",
listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp",
"--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id")
return set(re.findall(r"\bid=(\d+)", listing))
@@ -349,7 +328,6 @@ def _sandbox(parent_fd):
guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
query = outside.request("udp_recv", name="dns")
assert query[0] == "dns-query"
assert query[1][0] == "172.30.73.1", "guest egress must use the host uplink's address"
outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054)
@@ -360,44 +338,6 @@ def _sandbox(parent_fd):
assert endpoint.request("ping", ip=target), (endpoint, target)
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True)
# No UVM reconfiguration after this point: the kernel must follow the
# host routes for both TCP and UDP/53, including after offline creation.
outside.request("listen", name="internet", ip=internet_ip, port=9443)
outside.request("udp_bind", name="internet-dns", ip=internet_ip, port=53)
def offline(label):
assert not host.request("connect", name=f"host-{label}", ip=internet_ip, port=9443)
assert not guest.request("connect", name=f"guest-{label}", ip=internet_ip, port=9443)
assert guest.request("ping", ip=str(settings.gateway)), "local networking must still work offline"
def online(label, source):
for endpoint, role in ((host, "host"), (guest, "guest")):
name = f"{role}-{label}"
assert endpoint.request("connect", name=name, ip=internet_ip, port=9443)
assert outside.request("accept", listener="internet", name=name)
transfer(endpoint, outside, name, "request")
transfer(outside, endpoint, name, "response")
guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0)
guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label)
query = outside.request("udp_recv", name="internet-dns")
assert query[0] == label and query[1][0] == source, query
outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name=label)[0] == "dns-reply"
offline("initially-offline")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("first-uplink", "172.30.73.1")
run("ip", "route", "replace", "default", "via", "172.30.74.2", "dev", "fw-up2")
online("changed-uplink", "172.30.74.1")
run("ip", "route", "del", "default")
run("ip", "link", "set", "fw-up", "down")
run("ip", "link", "set", "fw-up2", "down")
offline("disconnected")
run("ip", "link", "set", "fw-up", "up")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("reconnected", "172.30.73.1")
print("PASS: host/guest NAT egress follows offline/online, uplink changes and reconnects", flush=True)
# Spoofed source MACs must not reach FDB learning, including the local
# link-local path which can skip bridge prerouting entirely.
spoof = bytes.fromhex("02aabbccddee")
@@ -453,7 +393,7 @@ class PrivatePortNamespaceTests(unittest.TestCase):
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
if probe.returncode:
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}")
missing = [name for name in ("ip", "bridge", "nft", "iptables", "conntrack", "sysctl") if shutil.which(name) is None]
missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None]
if missing:
self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
with open("/proc/self/ns/net", "rb") as parent:
-1
View File
@@ -108,7 +108,6 @@ class CliCreateTests(unittest.TestCase):
command="create",
cpu="1",
ram="512",
storage="30G",
host_ip=None,
username="admin",
password="custom-secret",
+3 -34
View File
@@ -30,7 +30,7 @@ class CliTestCase(unittest.TestCase):
def setUp(self) -> None:
self.vm = replace(
make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")),
status="running", disk_ready=True, storage_mib=30 * 1024,
status="running", disk_ready=True,
)
self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True)
self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced")
@@ -45,9 +45,8 @@ class CliTestCase(unittest.TestCase):
]
self.lifecycle.create.side_effect = lambda spec: replace(
self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username,
password=spec.password, name=spec.name, ports=spec.ports, storage_mib=spec.storage_mib,
password=spec.password, name=spec.name, ports=spec.ports,
)
self.lifecycle.resize.return_value = self.vm
self.application = SimpleNamespace(
settings=Settings(), lifecycle=self.lifecycle,
state_store=Mock(spec_set=("operation_lock",)),
@@ -187,38 +186,10 @@ class CliParsingTests(CliTestCase):
self.assertEqual(self.invoke([])[0], 2)
class CliResizeCommandTests(CliTestCase):
def test_resize_forwards_resources_and_timeout_without_changing_identity(self) -> None:
self.lifecycle.resize.return_value = replace(self.vm, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
code, stdout, stderr = self.invoke([
"resize", "web", "--cpu", "2", "--ram", "4G", "--storage", "50G", "--timeout", "0.5", "--json",
])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=2, ram_mib=4096, storage_mib=51200, timeout=0.5)
result = json.loads(stdout)
self.assertEqual(result["storage_mib"], 51200)
self.assertEqual(result["guest_ip"], self.vm.guest_ip)
self.assertEqual(result["username"], self.vm.username)
self.assertNotIn("password", result)
self.assertNotIn(self.vm.password, stdout + stderr)
def test_partial_resize_preserves_omitted_fields(self) -> None:
code, _stdout, stderr = self.invoke(["resize", "web", "--storage", "60G"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=None, ram_mib=None, storage_mib=61440, timeout=None)
def test_invalid_resize_requests_are_rejected_before_lifecycle_calls(self) -> None:
for options in ([], ["--storage", "29G"], ["--ram", "127M"], ["--cpu", "nan"],
["--cpu", "0"], ["--cpu", "2", "--timeout", "-1"], ["--username", "new-user"]):
with self.subTest(options=options):
self.assertEqual(self.invoke(["resize", "web", *options])[0], 2)
self.lifecycle.resize.assert_not_called()
class CliCreateCommandTests(CliTestCase):
def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
code, stdout, stderr = self.invoke([
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G", "--storage", "50G",
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G",
"--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin",
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
], stdin=io.StringIO(" stdin-secret \r\n"))
@@ -228,7 +199,6 @@ class CliCreateCommandTests(CliTestCase):
self.assertEqual(spec.name, "web")
self.assertEqual(spec.cpu, 0.5)
self.assertEqual(spec.ram_mib, 1024)
self.assertEqual(spec.storage_mib, 50 * 1024)
self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8"))
self.assertEqual(spec.username, "admin")
self.assertEqual(spec.password, " stdin-secret ")
@@ -252,7 +222,6 @@ class CliCreateCommandTests(CliTestCase):
code, stdout, stderr = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected)
self.assertEqual(self.lifecycle.create.call_args.args[0].storage_mib, 30 * 1024)
self.assertNotIn("WARNING", stdout)
self.assertIn("default password", stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
-90
View File
@@ -1,90 +0,0 @@
from __future__ import annotations
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import ConfigurationError
class ArchitectureSettingsTests(unittest.TestCase):
def test_resolves_native_defaults_when_settings_are_created(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("AMD64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
for factory in (Settings, Settings.from_environment):
with (
self.subTest(machine=machine, factory=factory),
patch.dict(os.environ, {}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = factory()
base = (
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
f"{architecture}"
)
self.assertEqual(settings.architecture, architecture)
self.assertEqual(settings.kernel_url, f"{base}/vmlinux-6.1.155")
self.assertEqual(settings.rootfs_url, "")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
base = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64"
for overrides in (
{"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs},
{"UVM_KERNEL_URL": kernel, "UVM_ROOTFS_URL": rootfs},
):
with (
self.subTest(overrides=overrides),
patch.dict(os.environ, overrides, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="aarch64")),
):
settings = Settings.from_environment()
self.assertEqual(
settings.kernel_url,
overrides.get("UVM_KERNEL_URL", f"{base}/vmlinux-6.1.155"),
)
self.assertEqual(
settings.rootfs_url,
overrides.get("UVM_ROOTFS_URL", ""),
)
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
with (
patch.dict(os.environ, {
"UVM_KERNEL_URL": "https://example.com/kernel",
"UVM_ROOTFS_URL": "https://example.com/rootfs.ext4",
}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="riscv64")),
self.assertRaisesRegex(ConfigurationError, "unsupported host architecture: riscv64"),
):
Settings.from_environment()
class KnownHostsSettingsTests(unittest.TestCase):
def test_known_hosts_files_are_loaded_as_explicit_absolute_paths(self) -> None:
with patch.dict(os.environ, {
"UVM_KNOWN_HOSTS_FILES": "/root/.ssh/known_hosts:/home/operator/.ssh/known_hosts",
}, clear=True):
settings = Settings.from_environment()
self.assertEqual(settings.known_hosts_files, (
Path("/root/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts"),
))
with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(Settings.from_environment().known_hosts_files)
def test_relative_or_empty_path_entries_are_rejected(self) -> None:
for value in ("known_hosts", "/tmp/known_hosts:", ":/tmp/known_hosts"):
with self.subTest(value=value), patch.dict(os.environ, {"UVM_KNOWN_HOSTS_FILES": value}, clear=True):
with self.assertRaisesRegex(ConfigurationError, "UVM_KNOWN_HOSTS_FILES"):
Settings.from_environment()
-93
View File
@@ -3,11 +3,9 @@ from __future__ import annotations
import unittest
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import VmRecord
from uvm.errors import FirecrackerError
from uvm.firecracker.api import FirecrackerClient
from uvm.firecracker.config import build_config, vcpu_count
@@ -21,38 +19,6 @@ class RecordingFirecrackerClient(FirecrackerClient):
class FirecrackerConfigTests(unittest.TestCase):
def test_uses_arm_boot_console_arguments_only_on_arm64(self) -> None:
vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
)
for architecture in ("x86_64", "aarch64", "arm64"):
with self.subTest(architecture=architecture):
config = build_config(
Settings(architecture=architecture),
vm,
Path("/images/vmlinux"),
Path("/vm/rootfs.ext4"),
)
arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments)
self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
client = RecordingFirecrackerClient()
client.configure_and_start(config)
self.assertEqual(client.calls[-2], ("PUT", "/entropy", {}))
self.assertEqual(client.calls[-1], ("PUT", "/actions", {"action_type": "InstanceStart"}))
def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord(
id="vm-test",
@@ -79,7 +45,6 @@ class FirecrackerConfigTests(unittest.TestCase):
self.assertEqual(config["drives"][0]["path_on_host"], "/vm/rootfs.ext4")
self.assertIn("ip=10.42.0.2::10.42.0.1", config["boot-source"]["boot_args"])
self.assertNotIn("password", str(config))
self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
def test_configures_firecracker_in_required_order_before_start(self) -> None:
vm = VmRecord(
@@ -109,8 +74,6 @@ class FirecrackerConfigTests(unittest.TestCase):
"/boot-source",
"/drives/rootfs",
"/network-interfaces/eth0",
"/vsock",
"/entropy",
"/actions",
],
)
@@ -120,62 +83,6 @@ class FirecrackerConfigTests(unittest.TestCase):
)
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
def test_entropy_failure_prevents_guest_boot(self) -> None:
config = {
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512},
"boot-source": {},
"drives": [{}],
"network-interfaces": [{}],
"entropy": {},
"vsock": {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"},
}
client = RecordingFirecrackerClient()
def request(method: str, path: str, body: object) -> None:
client.calls.append((method, path, body))
if path == "/entropy":
raise FirecrackerError("entropy device unavailable")
with patch.object(client, "_request", side_effect=request):
with self.assertRaisesRegex(FirecrackerError, "entropy device unavailable"):
client.configure_and_start(config)
self.assertEqual(client.calls[-1], ("PUT", "/entropy", {}))
self.assertNotIn("/actions", [path for _method, path, _body in client.calls])
for entropy in (None, [], False):
with self.subTest(entropy=entropy):
client.calls.clear()
config["entropy"] = entropy
with self.assertRaisesRegex(FirecrackerError, "no valid entropy device"):
client.configure_and_start(config)
self.assertEqual(client.calls, [])
def test_missing_invalid_or_failed_vsock_configuration_prevents_guest_boot(self) -> None:
config = {
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512}, "boot-source": {},
"drives": [{}], "network-interfaces": [{}], "entropy": {},
}
for vsock in (None, {}, {"guest_cid": True, "uds_path": "/tmp/vsock.sock"},
{"guest_cid": 2, "uds_path": "/tmp/vsock.sock"}, {"guest_cid": 3, "uds_path": ""}):
with self.subTest(vsock=vsock):
client = RecordingFirecrackerClient()
config["vsock"] = vsock
with self.assertRaisesRegex(FirecrackerError, "vsock"):
client.configure_and_start(config)
self.assertEqual(client.calls, [])
config["vsock"] = {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"}
client = RecordingFirecrackerClient()
def request(method, path, body):
client.calls.append((method, path, body))
if path == "/vsock":
raise FirecrackerError("vsock unavailable")
with patch.object(client, "_request", side_effect=request), self.assertRaises(FirecrackerError):
client.configure_and_start(config)
self.assertNotIn("/actions", [path for _method, path, _body in client.calls])
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
settings = Settings(
network=IPv4Network("10.50.0.0/16"),
+6 -89
View File
@@ -102,9 +102,9 @@ class FakeRunner:
self.after_batch()
elif args == ("conntrack", "--version"):
stdout = "conntrack v1.4.8 (conntrack-tools)"
elif args[:2] == ("conntrack", "-D"):
elif args[:2] == ("conntrack", "--delete"):
code = self.ct_delete_code
elif args[:2] == ("conntrack", "-L"):
elif args[:2] == ("conntrack", "--list"):
stdout = self.ct_remaining
else:
raise AssertionError(f"unexpected command: {args}")
@@ -376,34 +376,6 @@ class FirewallManagerTests(unittest.TestCase):
exprs[:-1] = reversed(exprs[:-1])
self.assertEqual(self.manager.status(self.vm), "enforced")
def test_apply_accepts_nft_eliding_ethertype_from_the_ipv4_tcp_guard(self):
self.vm.ports = (PortRule(22),)
def elide_ethertype():
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if not any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
continue
# nft 1.0.9 keeps ip protocol tcp to bind TCP to IPv4 in a
# bridge table, but omits its redundant ether type ip check.
expressions[:] = [expr for expr in expressions if expr != {
"match": {"left": {"payload": {"protocol": "ether", "field": "type"}},
"op": "==", "right": "ip"},
}]
self.runner.after_batch = elide_ethertype
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
# Losing BOTH IPv4 constraints is a real policy change, not formatting.
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
expressions[:] = [expr for expr in expressions if expr.get("match", {}).get("left") != {
"payload": {"protocol": "ip", "field": "protocol"},
}]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self):
self.vm.ports = ()
self.manager.apply(self.vm)
@@ -458,7 +430,7 @@ class FirewallManagerTests(unittest.TestCase):
self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too.
self.manager.cleanup_connections(self.vm)
for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
for command in ("-D", "-L"):
for command in ("--delete", "--list"):
self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip),
[args for args, _ in self.runner.calls])
self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls))
@@ -466,7 +438,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.assertRaisesRegex(UvmError, "do not reuse"):
self.manager.cleanup_connections(self.vm)
self.runner.ct_remaining = ""
self.runner.fail_command = ("conntrack", "-L", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
with self.assertRaises(CommandError):
self.manager.cleanup_connections(self.vm)
@@ -483,7 +455,7 @@ class FirewallManagerTests(unittest.TestCase):
expected = [("ip", "-j", "-d", "link", "show")]
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst")
for command in ("-D", "-L"))
for command in ("--delete", "--list"))
# No nft policy, publishing, or unrelated address/conntrack flush.
self.assertEqual([args for args, _ in self.runner.calls], expected)
@@ -504,7 +476,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.subTest(address=address, network=network):
self.runner.calls.clear()
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "-D")]
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")]
self.assertEqual(len(deletes), 4)
self.assertTrue(all(args[-1] == address for args in deletes))
@@ -716,32 +688,6 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric))
self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]')))
def test_bridge_tcp_guard_normalizes_numeric_protocol_and_implicit_ethertype(self):
# nft 1.0.9 src/payload.c payload_may_dependency_kill() removes the
# EtherType before ip protocol, but retains ip protocol before TCP.
# Protocol and flag representations follow the upstream fixtures in
# tests/py/inet/ip_tcp.t.json.output and tests/py/inet/tcp.t.json.
dumped = '''[
{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}},
"op": "==", "right": 6}},
{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}},
{"match": {"left": {"&": [
{"payload": {"protocol": "tcp", "field": "flags"}}, ["fin", "syn", "rst", "ack"]
]}, "op": "!=", "right": "syn"}},
{"drop": null}
]'''
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
guards = [item["rule"] for item in policy if "rule" in item
and any("&" in expr.get("match", {}).get("left", {}) for expr in item["rule"]["expr"])]
self.assertEqual({rule["chain"] for rule in guards}, {"from_vm", "to_vm"})
for rule in guards:
with self.subTest(chain=rule["chain"]):
expected = self.normalize_rule(json.dumps(rule["expr"]))
self.assertEqual(expected, self.normalize_rule(dumped))
self.assertNotEqual(expected, self.normalize_rule(json.dumps(json.loads(dumped)[1:])))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"right": 6', '"right": 17')))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"op": "!="', '"op": "=="')))
def test_upstream_full_dump_ignores_only_handles_and_metainfo(self):
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
dumped = json.loads('''{"nftables": [
@@ -760,35 +706,6 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertNotEqual(_normalize(dumped), _normalize(expected))
@unittest.skipUnless(shutil.which("conntrack"), "conntrack unavailable for nonmutating option checks")
class ConntrackCommandParserTests(unittest.TestCase):
@staticmethod
def parse(command):
# An invalid final option aborts before any netlink operation, even
# when the tests run as root. This also checks delete command syntax.
return subprocess.run(
(*command, "--uvm-parser-sentinel"), text=True, capture_output=True,
timeout=5, env={**os.environ, "LC_ALL": "C"},
)
def test_cleanup_commands_reach_only_the_final_invalid_option(self):
runner = FakeRunner()
FirewallManager(Settings(), runner).cleanup_connections(vm_record())
commands = [args for args, _ in runner.calls if args[0] == "conntrack"]
self.assertEqual(len(commands), 8)
for command in commands:
with self.subTest(command=command):
result = self.parse(command)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertRegex(result.stderr, r"(?:unknown|unrecognized) option [`']--uvm-parser-sentinel'")
def test_invalid_option_is_rejected_before_the_sentinel(self):
result = self.parse(("conntrack", "--uvm-invalid-option"))
self.assertEqual(result.returncode, 2)
self.assertIn("--uvm-invalid-option", result.stderr)
self.assertNotIn("--uvm-parser-sentinel", result.stderr)
@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks")
class NftJsonParserTests(unittest.TestCase):
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
-253
View File
@@ -1,253 +0,0 @@
from __future__ import annotations
import hashlib
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.images import GUEST_AGENT_PREPARE, ImageStore, guest_agent_files
from uvm.system import CommandRunner
class GuestRuntimeBootstrapTests(unittest.TestCase):
def test_runtime_install_is_guest_only_skipped_when_ready_and_stops_on_apt_failure(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
python = directory / "python3"
apt = directory / "apt-get"
ready = directory / "ready"
python.write_text(
f"#!{sys.executable}\nimport os, sys\nfrom pathlib import Path\n"
"sys.exit(0 if Path(os.environ['READY']).exists() else 1)\n", encoding="utf-8",
)
apt.write_text(
f"#!{sys.executable}\nimport json, os, sys\nfrom pathlib import Path\n"
"print(json.dumps(sys.argv[1:]))\n"
"if sys.argv[-1] == 'update' and os.environ['FAIL_UPDATE'] == '1': sys.exit(100)\n"
"if sys.argv[-1] == 'python3': Path(os.environ['READY']).touch()\n", encoding="utf-8",
)
python.chmod(0o755)
apt.chmod(0o755)
script = GUEST_AGENT_PREPARE.replace("/usr/bin/python3", str(python)).replace("/usr/bin/apt-get", str(apt))
for installed, fail in ((True, False), (False, False), (False, True)):
with self.subTest(installed=installed, fail=fail):
ready.unlink(missing_ok=True)
if installed:
ready.touch()
result = CommandRunner(emit=None).run((
"env", f"READY={ready}", f"FAIL_UPDATE={int(fail)}", "/bin/sh", "-c", script,
), check=False, capture=True)
calls = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(result.returncode, 100 if fail else 0)
self.assertEqual(len(calls), 0 if installed else 1 if fail else 2)
if calls:
self.assertEqual(calls[0][-1], "update")
self.assertIn("APT::Update::Error-Mode=any", calls[0])
if not installed and not fail:
self.assertEqual(calls[-1][-2:], ["install", "python3"])
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
"e2fsprogs and coreutils are required for offline guest agent checks")
class GuestAgentImageTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.settings = Settings(base=self.base / "uvm")
self.settings.images_dir.mkdir(parents=True)
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
self.disk.parent.mkdir()
self.runner = CommandRunner(emit=None)
self.images = ImageStore(self.settings, self.runner)
self.retained = {
"/etc/shadow": "worker:$6$retained-hash:1:0:99999:7:::\n",
"/etc/ssh/ssh_host_ed25519_key": "retained private SSH key\n",
"/home/worker/data": "retained application data\n",
}
def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None:
root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
payloads = {
**self.retained,
"/usr/lib/systemd/systemd": "systemd fixture", "/usr/bin/apt-get": "apt fixture",
}
if marker is not None:
payloads["/etc/uvm-image"] = marker
for path, content in payloads.items():
target = root / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
target.chmod(0o644)
for directory in (root, *(path for path in root.rglob("*") if path.is_dir())):
directory.chmod(0o755)
with self.settings.rootfs_image.open("wb") as image:
image.truncate(32 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
# mkfs -d retains the creating user's ownership. Model the root-built
# guest while allowing this test itself to run without root.
paths = {"/"}
for path in payloads:
paths.add(path)
paths.update(str(parent) for parent in Path(path).parents)
commands = "\n".join(f"set_inode_field {path} {field} 0" for path in paths for field in ("uid", "gid"))
self.runner.run(("debugfs", "-w", self.settings.rootfs_image), input_text=commands + "\n", capture=True)
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
self.disk.chmod(0o600)
def guest(self, command: str, *, disk: Path | None = None) -> str:
return self.runner.run(("debugfs", "-R", command, disk or self.disk), capture=True).stdout
def snapshot(self) -> tuple[int, bytes]:
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
def replace_guest_file(self, path: str, contents: str, *, disk: Path | None = None) -> None:
source = self.base / "edited"
source.write_text(contents, encoding="utf-8")
source.chmod(0o644)
self.runner.run(("debugfs", "-w", disk or self.disk), input_text=(
f"rm {path}\nwrite {source} {path}\nset_inode_field {path} uid 0\nset_inode_field {path} gid 0\n"
), capture=True)
def test_existing_guest_is_upgraded_atomically_with_private_identity_and_retained_data(self) -> None:
self.make_disk()
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"Mode:\s+0?600")
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"User:\s+0\s+Group:\s+0")
for path, content in guest_agent_files().items():
self.assertEqual(self.guest(f"cat {path}"), content.decode("utf-8"))
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
self.assertEqual(self.guest("cat /etc/uvm-image"), "uvm-ubuntu-jammy-v3\n")
for path, content in self.retained.items():
self.assertEqual(self.guest(f"cat {path}"), content)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.guest("cat /home/worker/data"), self.retained["/home/worker/data"])
def test_all_compatible_images_get_vsock_agent_setup_without_opt_in(self) -> None:
for marker in ("uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v3\n", "uvm-ubuntu-jammy-v4\n",
"uvm-ubuntu-jammy-v5\n", "custom-image\n", None):
with self.subTest(marker=marker):
self.make_disk(marker)
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
for path, content in self.retained.items():
self.assertEqual(self.guest(f"cat {path}"), content)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
def test_current_agent_setup_is_read_only_on_later_boots_and_tracks_vm_identity(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
before = self.snapshot()
with patch.object(self.images, "_copy_sparse") as copy:
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-recreated", "worker"))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-recreated", "username": "worker"})
def test_default_setup_reenables_an_existing_agent_service(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.runner.run(("debugfs", "-w", "-R", "rm /etc/systemd/system/multi-user.target.wants/uvm-agent.service", self.disk), capture=True)
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
def test_incompatible_images_fail_default_setup_without_changing_the_disk(self) -> None:
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
with self.subTest(binary=binary):
self.make_disk(marker=None)
self.runner.run(("debugfs", "-w", "-R", f"rm {binary}", self.disk), capture=True)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "automatic vsock guest-agent setup requires a systemd/apt image"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertEqual(self.snapshot(), before)
def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True)
old_payload = "previous packaged agent\n"
self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "customized guest agent"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()):
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
updated = guest_agent_files()
updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n"
with patch("uvm.images.guest_agent_files", return_value=updated):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertTrue(self.guest("cat /usr/lib/uvm/agent.py").endswith("# next packaged revision\n"))
self.replace_guest_file("/usr/lib/uvm/agent.py", "guest customization\n")
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "customized guest agent file"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_failure_during_provisioning_keeps_the_original_disk(self) -> None:
self.make_disk()
before = self.snapshot()
write = self.images._write_guest_file
def fail(candidate, path, source):
self.assertNotEqual(candidate, self.disk)
write(candidate, path, source)
raise UvmError("simulated write failure")
with patch.object(self.images, "_write_guest_file", side_effect=fail), self.assertRaisesRegex(UvmError, "write failure"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_guest_edits_revealed_by_journal_replay_are_checked_before_replacement(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
before = self.snapshot()
run = self.runner.run
def replay(command, **options):
result = run(command, **options)
if command[:3] == ("e2fsck", "-f", "-p"):
self.replace_guest_file("/usr/lib/uvm/agent.py", "journal-committed guest edit\n", disk=command[-1])
return result
with patch.object(self.runner, "run", side_effect=replay), self.assertRaisesRegex(UvmError, "customized"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
def test_unsafe_guest_directory_and_shared_disk_are_rejected(self) -> None:
self.make_disk()
with self.assertRaisesRegex(UvmError, "unsafe"):
self.images.provision_guest_agent(self.settings.rootfs_image, "vm-test", "worker", install=True)
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /etc mode 040777", self.disk), capture=True)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "unsafe guest agent directory"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
-289
View File
@@ -1,289 +0,0 @@
from __future__ import annotations
import os
import pwd
import shlex
import socket
import struct
import sys
import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from uvm import guest_agent as agent
from uvm.guest_exec import GuestSession, validate_exec
class FakeScope:
"""Use real subprocesses/process groups without touching the host's cgroups."""
def __init__(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.path = Path(self.temporary.name)
(self.path / "cgroup.procs").touch()
self.kills = 0
self.joined_pid = None
def kill(self) -> None:
self.kills += 1
self.joined_pid = (self.path / "cgroup.procs").read_text()
def close(self) -> None:
self.temporary.cleanup()
class GuestCommandTests(unittest.TestCase):
def setUp(self) -> None:
self.account = pwd.getpwuid(os.geteuid())
self.scope = FakeScope()
self.addCleanup(self.scope.temporary.cleanup)
def run_command(self, command, timeout=5):
return agent.run_command(command, self.account.pw_name, timeout, scope_factory=lambda: self.scope)
def test_multiline_script_has_separate_output_exit_status_and_no_stdin(self) -> None:
result = self.run_command("printf 'hello\\n'\nprintf 'problem\\n' >&2\nread input || exit 7")
self.assertEqual(result, {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
self.assertTrue(self.scope.joined_pid.isdigit())
self.assertGreater(self.scope.kills, 0)
def test_command_enters_scope_before_running_and_uses_account_home_and_environment(self) -> None:
with patch.dict(os.environ, {"UVM_API_TOKEN": "must-not-reach-guest-command"}):
result = self.run_command('printf "%s\\n" "$$" "$HOME" "$USER" "$UVM_API_TOKEN"; pwd; id -u')
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["stdout"].splitlines(), [
self.scope.joined_pid, self.account.pw_dir, self.account.pw_name, "", self.account.pw_dir, str(os.geteuid()),
])
def test_timeout_kills_the_shell_and_children_that_ignore_sigterm(self) -> None:
start = time.monotonic()
result = self.run_command("trap '' TERM; sleep 30 & printf '%s\\n' \"$!\"; wait", timeout=0.25)
self.assertTrue(result["timed_out"])
self.assertIsNone(result["exit_code"])
self.assertLess(time.monotonic() - start, 4)
child = int(result["stdout"])
try:
state = Path(f"/proc/{child}/stat").read_text().split(") ", 1)[1].split()[0]
except FileNotFoundError:
state = "gone"
self.assertIn(state, {"gone", "Z"})
def test_large_stdout_and_stderr_are_drained_without_unbounded_capture(self) -> None:
script = "import os; [(os.write(1, b'o'*65536), os.write(2, b'e'*65536)) for _ in range(32)]"
result = self.run_command(f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}")
self.assertEqual(result["exit_code"], 0)
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), agent.MAX_OUTPUT_BYTES)
self.assertTrue(result["truncated"])
self.assertFalse(result["timed_out"])
def test_non_utf8_output_is_replaced_without_losing_the_exit_code(self) -> None:
result = self.run_command("printf '\\377\\000'; exit 255")
self.assertEqual(result["stdout"], "\ufffd\0")
self.assertEqual(result["exit_code"], 255)
def test_cgroup_cleanup_failure_is_an_unknown_outcome_and_still_reaps_the_process(self) -> None:
with patch.object(self.scope, "kill", side_effect=OSError("cannot kill scope")):
with self.assertRaises(agent.ExecutionFailure) as raised:
self.run_command("exit 0")
self.assertTrue(raised.exception.outcome_unknown)
def test_privilege_drop_initializes_groups_before_uid_and_exec(self) -> None:
account = Mock(pw_name="worker", pw_uid=1001, pw_gid=1002, pw_dir="/home/worker")
events = []
with (
patch.object(agent.pwd, "getpwnam", return_value=account),
patch.object(agent.os, "geteuid", return_value=0),
patch.object(agent.os, "initgroups", side_effect=lambda *args: events.append(("groups", args))),
patch.object(agent.os, "setgid", side_effect=lambda value: events.append(("gid", value))),
patch.object(agent.os, "setuid", side_effect=lambda value: events.append(("uid", value))),
patch.object(agent.os, "chdir") as chdir,
patch.object(agent.os, "umask"),
patch.object(agent.os, "execve") as execute,
):
agent.child_main(self.scope.path, "worker", "printf x > file")
self.assertEqual(events, [("groups", ("worker", 1002)), ("gid", 1002), ("uid", 1001)])
chdir.assert_called_once_with("/home/worker")
self.assertEqual(execute.call_args.args[:2], ("/bin/sh", ["sh", "-c", "printf x > file"]))
self.assertEqual(execute.call_args.args[2]["HOME"], "/home/worker")
class GuestProtocolTests(unittest.TestCase):
def test_validates_byte_limits_types_finite_deadlines_and_text_before_transport(self) -> None:
for command, sudo, timeout in (
("", False, 1), (" \n", False, 1), ("a\0b", False, 1), ("\ud800", False, 1),
("x" * (agent.MAX_COMMAND_BYTES + 1), False, 1),
("\u00e9" * agent.MAX_COMMAND_BYTES, False, 1),
("id", "false", 1), ("id", 0, 1), ("id", False, True),
("id", False, 0), ("id", False, -1), ("id", False, float("nan")),
("id", False, float("inf")), ("id", False, 3601), ("id", False, "60"),
):
with self.subTest(command=command[:20], sudo=sudo, timeout=timeout), self.assertRaises(ValidationError):
validate_exec(command, sudo, timeout)
validate_exec("id", False, 0.1)
def test_oversized_frame_is_rejected_before_reading_a_body(self) -> None:
left, right = socket.socketpair()
with left, right:
left.sendall(struct.pack("!I", agent.MAX_REQUEST_BYTES + 1))
with self.assertRaises(agent.ProtocolError):
agent.receive_frame(right, agent.MAX_REQUEST_BYTES, time.monotonic() + 1)
def test_read_deadline_applies_to_the_whole_frame(self) -> None:
left, right = socket.socketpair()
with left, right:
left.sendall(struct.pack("!I", 5) + b"{")
start = time.monotonic()
with self.assertRaises(TimeoutError):
agent.receive_frame(right, 1024, time.monotonic() + 0.05)
self.assertLess(time.monotonic() - start, 0.5)
def test_agent_maps_sudo_to_root_and_rejects_bad_requests_without_launching(self) -> None:
config = {"vm_id": "vm-test", "username": "worker"}
for sudo in (False, True, "true"):
with self.subTest(sudo=sudo):
left, right = socket.socketpair()
result = {"exit_code": 0, "stdout": "", "stderr": "", "timed_out": False, "truncated": False}
runner = Mock(return_value=result)
thread = threading.Thread(target=agent.serve_connection, args=(right, config), kwargs={"runner": runner})
thread.start()
with left:
deadline = time.monotonic() + 2
self.assertEqual(agent.receive_frame(left, 4096, deadline), {"version": 1, **config})
agent.send_frame(left, {"version": 1, "command": "id", "sudo": sudo, "timeout_seconds": 2}, 4096, deadline)
reply = agent.receive_frame(left, 4096, deadline)
thread.join(2)
self.assertFalse(thread.is_alive())
if type(sudo) is bool:
self.assertEqual(reply, result)
runner.assert_called_once_with("id", "root" if sudo else "worker", 2)
else:
self.assertFalse(reply["outcome_unknown"])
runner.assert_not_called()
def test_guest_local_connections_are_rejected_before_a_worker_is_started(self) -> None:
connection = Mock()
listener = Mock()
listener.__enter__ = Mock(return_value=listener)
listener.__exit__ = Mock(return_value=False)
listener.accept.side_effect = [(connection, (3, 9999)), KeyboardInterrupt()]
with (
patch.object(agent.sys, "argv", ["agent.py"]), patch.object(agent.os, "geteuid", return_value=0),
patch.object(agent, "load_config", return_value={"vm_id": "vm-test", "username": "root"}),
patch.object(agent.socket, "socket", return_value=listener), patch.object(agent.threading, "Thread") as worker,
self.assertRaises(KeyboardInterrupt),
):
agent.main()
connection.close.assert_called_once()
worker.assert_not_called()
class GuestTransportTests(unittest.TestCase):
@contextmanager
def firecracker(self, handler):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "vsock.sock"
errors = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
listener.bind(str(path))
listener.listen(1)
listener.settimeout(3)
def serve():
try:
connection, _peer = listener.accept()
with connection:
handler(connection)
except (BrokenPipeError, EOFError, ConnectionResetError):
pass
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=serve, daemon=True)
thread.start()
try:
yield path
finally:
thread.join(4)
self.assertFalse(thread.is_alive())
if errors:
raise errors[0]
def handshake(self, connection, **changes):
deadline = time.monotonic() + 2
self.assertEqual(agent.receive_exact(connection, len(b"CONNECT 1070\n"), deadline), b"CONNECT 1070\n")
# The acknowledgement and JSON frame can be coalesced on the wire.
connection.sendall(b"OK 1234\n")
agent.send_frame(connection, {"version": 1, "vm_id": "vm-test", "username": "worker", **changes}, 4096, deadline)
def test_round_trip_preserves_command_text_and_a_nonzero_exit(self) -> None:
command = "printf '%s\\n' 'quotes; $(literal)'\nexit 7"
expected = {"exit_code": 7, "stdout": "quotes; $(literal)\n", "stderr": "", "timed_out": False, "truncated": False}
def handle(connection):
self.handshake(connection)
request = agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
self.assertEqual(request, {"version": 1, "command": command, "sudo": True, "timeout_seconds": 3})
agent.send_frame(connection, expected, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
try:
result = session.execute(command, sudo=True, timeout_seconds=3)
finally:
session.close()
self.assertEqual(result.exit_code, 7)
self.assertEqual(result.stdout, expected["stdout"])
def test_wrong_vm_or_process_identity_prevents_command_submission(self) -> None:
for changes, pid in (({"vm_id": "vm-other"}, os.getpid()), ({"version": 2}, os.getpid()),
({"username": "root"}, os.getpid()), ({}, os.getpid() + 100000)):
with self.subTest(changes=changes, pid=pid):
def handle(connection):
self.handshake(connection, **changes)
self.assertEqual(connection.recv(1), b"")
with self.firecracker(handle) as path, self.assertRaises(GuestAgentUnavailableError):
GuestSession.connect(path, "vm-test", "worker", pid)
def test_connection_loss_after_submission_has_unknown_outcome_without_retry(self) -> None:
submissions = []
def handle(connection):
self.handshake(connection)
submissions.append(agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2))
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
with self.assertRaises(GuestExecutionError) as raised:
try:
session.execute("touch example", sudo=False, timeout_seconds=1)
finally:
session.close()
self.assertTrue(raised.exception.outcome_unknown)
self.assertEqual(len(submissions), 1)
def test_malformed_result_is_not_reported_as_success(self) -> None:
for result in ({"exit_code": True, "stdout": "", "stderr": "", "timed_out": False, "truncated": False},
{"error": "bad", "outcome_unknown": "false"}, {"error": "\ud800", "outcome_unknown": False},
{"exit_code": 0}):
with self.subTest(result=result):
def handle(connection):
self.handshake(connection)
agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
agent.send_frame(connection, result, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
with self.assertRaises(GuestExecutionError) as raised:
try:
session.execute("id", sudo=False, timeout_seconds=1)
finally:
session.close()
self.assertTrue(raised.exception.outcome_unknown)
-156
View File
@@ -1,156 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError
from uvm.guest_exec import ExecResult
class GuestExecLifecycleTests(LifecycleTestCase):
def test_create_provisions_agent_before_the_disk_is_ready_or_the_guest_is_started(self) -> None:
def provision(disk, vm_id, username):
self.host.call("images.provision_agent", vm_id)
vm = self.store.get(vm_id)
self.assertFalse(vm.disk_ready)
self.assertEqual(self.process.observe(vm), "gone")
self.assertEqual(disk, Path(vm.disk))
self.assertEqual(username, "worker")
self.provision_agent.side_effect = provision
vm = self.create_vm(username="worker")
self.assert_call_order(vm, "images.provision_agent", "process.start")
def test_stopped_boots_automatically_provision_agent_before_starting_the_vmm(self) -> None:
vm = self.create_vm(username="worker", password="retained-password")
identity = (vm.id, vm.guest_ip, vm.mac, vm.username, vm.password)
def provision(disk, vm_id, username):
self.host.call("images.provision_agent", vm_id)
record = self.store.get(vm_id)
self.assertTrue(self.store.operation_active)
self.assertEqual(self.process.observe(record), "gone")
self.assertEqual((record.id, record.guest_ip, record.mac, record.username, record.password), identity)
self.assertEqual((disk, username), (Path(vm.disk), "worker"))
self.provision_agent.side_effect = provision
for action in ("start", "restart", "resize"):
with self.subTest(action=action):
if action == "start":
self.service.stop(vm.id)
self.host.events.clear()
self.provision_agent.reset_mock()
self.provision.reset_mock()
if action == "resize":
started = self.service.resize(vm.id, cpu=2)
else:
started = getattr(self.service, action)(vm.id)
self.assertEqual(started.status, "running")
self.provision_agent.assert_called_once_with(Path(vm.disk), vm.id, "worker")
self.provision.assert_not_called()
self.assert_call_order(vm, "images.provision_agent", "process.start")
def test_live_or_ambiguous_vms_never_run_offline_agent_provisioning(self) -> None:
vm = self.create_vm()
self.provision_agent.reset_mock()
self.service.start(vm.id)
self.process.unknown.add(vm.id)
with self.assertRaises(ConflictError):
self.service.start(vm.id)
self.provision_agent.assert_not_called()
def test_default_agent_setup_failure_blocks_boot_and_keeps_the_retained_disk(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
before = Path(vm.disk).read_bytes()
count = len(self.process.started)
self.provision_agent.side_effect = UvmError("agent setup failed")
with self.assertRaisesRegex(UvmError, "agent setup failed"):
self.service.start(vm.id)
self.assertEqual(len(self.process.started), count)
self.assertEqual(Path(vm.disk).read_bytes(), before)
self.assertEqual(self.store.get(vm.id).status, "failed")
self.provision_agent.side_effect = None
self.assertEqual(self.service.start(vm.id).status, "running")
def test_default_agent_setup_failure_rolls_back_a_new_vm_before_boot(self) -> None:
self.provision_agent.side_effect = UvmError("unsupported guest image")
with self.assertRaisesRegex(UvmError, "unsupported guest image"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertEqual(self.process.started, [])
def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None:
vm = self.create_vm(name="worker", ports=())
result = ExecResult(7, "output", "problem", False, False)
session = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return session
def execute(command, **options):
self.assertFalse(self.store.operation_active)
self.assertFalse(self.store.transaction_active)
self.service.stop(vm.id)
return result
self.service._guest_connector = Mock(side_effect=connect)
session.execute.side_effect = execute
self.assertEqual(self.service.execute(vm.id, "exit 7", sudo=True, timeout_seconds=2), result)
session.execute.assert_called_once_with("exit 7", sudo=True, timeout_seconds=2)
session.close.assert_called_once()
def test_execution_rejects_aliases_stopped_unknown_and_pending_vms_before_connecting(self) -> None:
vm = self.create_vm(name="worker")
self.service._guest_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.execute(alias, "id")
for changes in ({"status": "stopped"}, {"operation": "policy"}, {"operation": "delete"}):
self.persist(vm, **changes)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.persist(vm, status="running", operation=None)
self.process.unknown.add(vm.id)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_not_called()
def test_unavailable_or_lost_agent_does_not_retry_or_mutate_vm_state(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.service._guest_connector = Mock(side_effect=GuestAgentUnavailableError("not ready"))
with self.assertRaises(GuestAgentUnavailableError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_called_once()
session = Mock()
session.execute.side_effect = GuestExecutionError("connection lost", outcome_unknown=True)
self.service._guest_connector = Mock(return_value=session)
with self.assertRaises(GuestExecutionError):
self.service.execute(vm.id, "id")
session.execute.assert_called_once()
session.close.assert_called_once()
self.assertEqual(self.settings.state_path.read_bytes(), before)
def test_agent_upgrade_requires_a_stopped_proven_offline_disk_and_retains_identity(self) -> None:
vm = self.create_vm(username="worker")
self.provision_agent.reset_mock()
with self.assertRaises(ConflictError):
self.service.install_guest_agent(vm.id)
self.provision_agent.assert_not_called()
stopped = self.service.stop(vm.id)
before = stopped.to_dict()
def provision(disk, vm_id, username, *, install):
self.assertTrue(self.store.operation_active)
self.assertEqual(self.process.observe(self.store.get(vm_id)), "gone")
self.assertEqual((disk, username, install), (Path(vm.disk), "worker", True))
self.provision_agent.side_effect = provision
self.assertEqual(self.service.install_guest_agent(vm.id).to_dict(), before)
self.provision_agent.assert_called_once()
self.assertEqual(self.store.get(vm.id).to_dict(), before)
self.assertEqual(len(self.process.started), 1)
-215
View File
@@ -1,215 +0,0 @@
from __future__ import annotations
import configparser
import hashlib
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import GUEST_NETWORK_TEMPLATE, ImageStore
from uvm.system import CommandResult, CommandRunner
PROFILE = "/etc/systemd/network/10-uvm.network"
# The shipped v1/v2 profile that retained the IP but lost the proto-boot route.
LEGACY_PROFILE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
class GuestNetworkValidationTests(unittest.TestCase):
def test_invalid_guest_addresses_fail_before_disk_access(self) -> None:
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
for address in ("invalid", "::1", "192.0.2.2", "10.42.0.0", "10.42.0.255", "10.42.0.1"):
with self.subTest(address=address), self.assertRaises(ValidationError):
store.configure_guest_network(Path("/nonexistent/rootfs.ext4"), address)
runner.run.assert_not_called()
def test_shared_or_linked_disks_are_rejected_before_guest_commands(self) -> None:
with tempfile.TemporaryDirectory() as name:
settings = Settings(base=Path(name))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"shared template")
disk = settings.base / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = settings.base / "linked.ext4"
linked.hardlink_to(disk)
symlink = settings.base / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
for candidate in (settings.rootfs_image, disk, linked, symlink):
with self.subTest(candidate=candidate), self.assertRaisesRegex(UvmError, "unsafe"):
store.configure_guest_network(candidate, "10.42.0.2")
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
"e2fsprogs and coreutils are required for offline guest networking checks")
class OfflineGuestNetworkTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.settings = Settings(base=self.base / "uvm")
self.settings.images_dir.mkdir(parents=True)
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
self.disk.parent.mkdir()
self.runner = CommandRunner(emit=None)
self.store = ImageStore(self.settings, self.runner)
self.retained = {
"/etc/shadow": "adm:$6$original-password-hash:1:0:99999:7:::\n",
"/etc/ssh/ssh_host_ed25519_key": "original private host key\n",
"/home/adm/data": "retained guest application data\n",
}
def make_disk(self, *, marker="uvm-ubuntu-jammy-v2\n", profile=LEGACY_PROFILE, symlink=False) -> None:
root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
files = dict(self.retained)
if marker is not None:
files["/etc/uvm-image"] = marker
if profile is not None:
files["/etc/alternate.network" if symlink else PROFILE] = profile
for path, contents in files.items():
local = root / path.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text(contents, encoding="utf-8")
local.chmod(0o644)
if symlink:
local = root / PROFILE.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.symlink_to("/etc/alternate.network")
with self.settings.rootfs_image.open("wb") as image:
image.truncate(32 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
if profile is not None:
for field in ("uid", "gid"):
self.runner.run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0",
self.settings.rootfs_image), capture=True)
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
self.disk.chmod(0o600)
def guest_file(self, path: str) -> str:
return self.runner.run(("debugfs", "-R", f"cat {path}", self.disk), capture=True).stdout
def snapshot(self) -> tuple[int, bytes]:
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE),
("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE),
("uvm-ubuntu-jammy-v5\n", GUEST_NETWORK_TEMPLATE)):
with self.subTest(marker=marker):
self.make_disk(marker=marker, profile=profile)
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.store.configure_guest_network(self.disk, "10.42.0.2"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Match"]["Name"], "eth0")
self.assertEqual(config["Network"]["Address"], "10.42.0.2/24")
self.assertEqual(config["Network"]["Gateway"], "10.42.0.1")
self.assertTrue(config["Network"].getboolean("KeepConfiguration"))
self.assertFalse(config["Network"].getboolean("DHCP"))
for path, contents in self.retained.items():
self.assertEqual(self.guest_file(path), contents)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), original_template)
self.assertEqual(self.disk.stat().st_size, 32 * 1024 * 1024)
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_custom_subnets_and_gateways_are_used_in_the_guest_profile(self) -> None:
settings = replace(self.settings, network=IPv4Network("10.50.0.0/16"), gateway=IPv4Address("10.50.1.1"))
self.make_disk()
self.assertTrue(ImageStore(settings, self.runner).configure_guest_network(self.disk, "10.50.8.9"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Network"]["Address"], "10.50.8.9/16")
self.assertEqual(config["Network"]["Gateway"], "10.50.1.1")
def test_custom_images_missing_profiles_and_guest_edits_are_left_intact(self) -> None:
for marker, profile, symlink in (
(None, LEGACY_PROFILE, False), ("custom-image\n", LEGACY_PROFILE, False),
("uvm-ubuntu-jammy-v2\n", None, False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "# guest customization\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "Gateway=10.42.0.9\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE, True),
):
with self.subTest(marker=marker, profile=profile, symlink=symlink):
self.make_disk(marker=marker, profile=profile, symlink=symlink)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_a_failed_or_interrupted_profile_write_keeps_the_original_disk(self) -> None:
for error in (UvmError("profile readback failed"), KeyboardInterrupt()):
with self.subTest(error=type(error).__name__):
self.make_disk()
before = self.snapshot()
write = self.store._write_guest_file
def fail(candidate, path, contents):
self.assertNotEqual(candidate, self.disk)
write(candidate, path, contents)
raise error
with patch.object(self.store, "_write_guest_file", side_effect=fail), self.assertRaises(type(error)):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_filesystem_check_failure_prevents_publication(self) -> None:
self.make_disk()
before = self.snapshot()
run = self.runner.run
def check_failure(command, **options):
if command[0] == "e2fsck":
return CommandResult(tuple(map(str, command)), 4, stderr="unrepaired filesystem errors")
return run(command, **options)
with patch.object(self.runner, "run", side_effect=check_failure):
with self.assertRaisesRegex(UvmError, "unrepaired filesystem errors"):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_guest_edits_revealed_after_journal_replay_prevent_migration(self) -> None:
self.make_disk()
before = self.snapshot()
edited = self.base / "edited.network"
edited.write_text(LEGACY_PROFILE + "Gateway=10.42.0.9\n", encoding="utf-8")
run = self.runner.run
def replay(command, **options):
result = run(command, **options)
if command[:3] == ("e2fsck", "-f", "-p"):
run(("debugfs", "-w", "-R", f"rm {PROFILE}", command[-1]), capture=True)
run(("debugfs", "-w", "-R", f"write {edited} {PROFILE}", command[-1]), capture=True)
for field in ("uid", "gid"):
run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", command[-1]), capture=True)
return result
with patch.object(self.runner, "run", side_effect=replay):
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
-165
View File
@@ -1,165 +0,0 @@
from __future__ import annotations
import asyncio
import os
import pwd
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from tests.test_guest_exec import FakeScope
from tests.test_lifecycle import LifecycleTestCase
from uvm import guest_agent as agent
from uvm.errors import ConflictError, GuestAgentUnavailableError, NotFoundError
from uvm.guest_terminal import GuestTerminal
class GuestTerminalTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.scope = FakeScope()
self.addCleanup(self.scope.temporary.cleanup)
self.home = self.enterContext(tempfile.TemporaryDirectory())
username = pwd.getpwuid(os.geteuid()).pw_name
original_popen = subprocess.Popen
# Exercise the actual guest child entry point with a temporary account
# home, so interactive shells never read/write the developer's dotfiles.
script = f"""
import os, runpy
from pathlib import Path
from types import SimpleNamespace
agent = runpy.run_path({agent.__file__!r})
agent['pwd'].getpwnam = lambda _: SimpleNamespace(pw_name={username!r}, pw_uid=os.geteuid(),
pw_gid=os.getegid(), pw_dir={self.home!r}, pw_shell='/bin/sh')
agent['child_main'](Path({str(self.scope.path)!r}), {username!r}, None)
"""
def launch(_args, **kwargs):
return original_popen([sys.executable, "-I", "-c", script], **kwargs)
self.enterContext(patch.object(agent.subprocess, "Popen", side_effect=launch))
left, right = socket.socketpair()
self.terminal = GuestTerminal(left)
self.addCleanup(self.terminal.close)
def runner(connection, account, rows, cols):
return agent.run_terminal(connection, account, rows, cols, scope_factory=lambda: self.scope)
self.thread = threading.Thread(target=agent.serve_connection,
args=(right, {"vm_id": "vm-test", "username": username}),
kwargs={"terminal_runner": runner}, daemon=True)
self.thread.start()
self.addCleanup(self.thread.join, 3)
agent.receive_frame(left, 4096, time.monotonic() + 2)
await asyncio.to_thread(self.terminal.start, 24, 80)
self.prompt = b"# " if os.geteuid() == 0 else b"$ "
await self.until(self.prompt)
async def until(self, wanted):
data = bytearray()
try:
async with asyncio.timeout(5):
while wanted not in data:
message = await self.terminal.read()
self.assertEqual(message["type"], "output", message)
data.extend(message["data"])
except TimeoutError:
self.fail(f"Missing {wanted!r} in terminal output: {bytes(data)!r}")
return bytes(data)
async def asyncTearDown(self):
self.terminal.close()
await asyncio.to_thread(self.thread.join, 3)
self.assertFalse(self.thread.is_alive())
async def test_terminal_has_tty_persistent_shell_state_and_exit_code(self):
await self.terminal.write(b"test -t 0 && printf '\\nREAL-TTY\\n'\r")
await self.until(b"\r\nREAL-TTY\r\n")
await self.terminal.write(b"export NEST_TEST=hello; cd /\r")
await self.terminal.write(b"printf 'STATE:%s:%s\\n' \"$NEST_TEST\" \"$PWD\"\r")
await self.until(b"STATE:hello:/\r\n")
await self.terminal.write(b"exit 7\r")
async with asyncio.timeout(5):
while True:
message = await self.terminal.read()
if message["type"] == "exit":
self.assertEqual(message["exit_code"], 7)
break
async def test_resize_and_control_c_reach_the_guest_foreground_job(self):
await self.terminal.resize(40, 120)
await self.terminal.write(b"stty size\r")
await self.until(b"40 120\r\n")
await self.terminal.write(b"sh -c 'printf \"\\nJOB-STARTED\\n\"; exec sleep 20'\r")
await self.until(b"\r\nJOB-STARTED\r\n")
await self.terminal.write(b"\x03")
await self.until(self.prompt)
await self.terminal.write(b"printf '\\nINTERRUPTED\\n'\r")
await self.until(b"\r\nINTERRUPTED\r\n")
async def test_explicit_end_terminates_the_shell(self):
await self.terminal.write(b"printf '\\nREADY\\n'\r")
await self.until(b"\r\nREADY\r\n")
await self.terminal.end()
async with asyncio.timeout(5):
while (message := await self.terminal.read())["type"] != "exit":
pass
self.assertIsInstance(message["exit_code"], int)
self.assertGreater(self.scope.kills, 0)
class TerminalCompatibilityTests(unittest.TestCase):
def test_old_agent_rejection_explains_how_to_upgrade(self):
left, right = socket.socketpair()
terminal = GuestTerminal(left)
self.addCleanup(terminal.close)
def old_agent():
with right:
agent.receive_frame(right, 4096, time.monotonic() + 2)
agent.send_frame(right, {"error": "invalid execution request fields", "outcome_unknown": False},
4096, time.monotonic() + 2)
thread = threading.Thread(target=old_agent)
thread.start()
try:
with self.assertRaisesRegex(GuestAgentUnavailableError, "Restart this VM to apply automatic guest-agent setup"):
terminal.start(24, 80)
finally:
thread.join(3)
class TerminalLifecycleTests(LifecycleTestCase):
def test_terminal_connects_under_lock_and_opens_without_holding_the_lock(self):
vm = self.create_vm(name="worker", ports=())
terminal = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return terminal
def start(rows, cols):
self.assertFalse(self.store.operation_active)
self.assertEqual((rows, cols), (40, 120))
self.service._terminal_connector = Mock(side_effect=connect)
terminal.start.side_effect = start
self.assertEqual(self.service.open_terminal(vm.id, rows=40, cols=120), (terminal, vm.username))
terminal.close.assert_not_called()
def test_aliases_and_stopped_vms_cannot_create_a_terminal(self):
vm = self.create_vm(name="worker")
self.service._terminal_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.open_terminal(alias)
self.service.stop(vm.id)
with self.assertRaises(ConflictError):
self.service.open_terminal(vm.id)
self.service._terminal_connector.assert_not_called()
+2 -261
View File
@@ -1,15 +1,13 @@
from __future__ import annotations
import hashlib
import shutil
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import UvmError, ValidationError
from uvm.errors import UvmError
from uvm.images import (
_FIRECRACKER_DEMO_PUBLIC_KEY,
_GuestFile,
@@ -19,7 +17,7 @@ from uvm.images import (
ImageStore,
)
from uvm.integrity import write_manifest
from uvm.system import CommandResult, CommandRunner
from uvm.system import CommandResult
class PasswordHashRunner:
@@ -71,16 +69,6 @@ class ImageStoreTests(unittest.TestCase):
self.assertEqual(runner.options["input_text"], "secret-value\n")
self.assertTrue(runner.options["sensitive"])
def test_rejects_invalid_usernames_before_accessing_the_guest_disk(self) -> None:
runner = PasswordHashRunner()
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
for username in ("../root", "user\nroot", "user:1000", "user name"):
with self.subTest(username=username), self.assertRaises(ValidationError):
store.provision_credentials(Path("/tmp/rootfs.ext4"), username, "secret")
self.assertEqual(runner.calls, [])
def test_updates_only_the_requested_shadow_entry(self) -> None:
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
@@ -130,44 +118,6 @@ class ImageStoreTests(unittest.TestCase):
self.assertIn("Match User service\n PasswordAuthentication yes", updated)
self.assertNotIn("ChallengeResponseAuthentication yes", updated)
def test_full_ubuntu_sshd_global_directives_stay_outside_match_blocks(self) -> None:
updated = _enable_ssh_password_authentication(
"Include /etc/ssh/sshd_config.d/*.conf\n"
"UsePAM yes\nSubsystem sftp /usr/lib/openssh/sftp-server\n"
"Match Group restricted\n X11Forwarding no\n PasswordAuthentication no\n",
"codemate-user",
)
self.assertLess(updated.index("UsePAM yes"), updated.index("Match User codemate-user"))
self.assertLess(updated.index("Subsystem sftp"), updated.index("Match User codemate-user"))
self.assertLess(updated.index("Match User codemate-user"), updated.index("Match Group restricted"))
self.assertIn("Match Group restricted\n X11Forwarding no", updated)
@unittest.skipUnless(
shutil.which("sshd") and shutil.which("ssh-keygen") and Path("/run/sshd").is_dir(),
"OpenSSH server tools and the privilege-separation directory are required",
)
def test_openssh_accepts_the_full_config_and_limits_password_auth_to_the_guest_user(self) -> None:
runner = CommandRunner(emit=None)
with tempfile.TemporaryDirectory() as temporary:
key = Path(temporary) / "host_key"
runner.run(("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key), capture=True)
contents = _enable_ssh_password_authentication(
"UsePAM yes\nSubsystem sftp internal-sftp\nPermitRootLogin no\n",
"codemate-user",
)
for key_type in ("rsa", "ecdsa", "ed25519"):
contents = contents.replace(f"/etc/ssh/ssh_host_{key_type}_key", str(key))
config = Path(temporary) / "sshd_config"
config.write_text(contents, encoding="utf-8")
for username, expected in (("codemate-user", "yes"), ("root", "no")):
with self.subTest(username=username):
result = runner.run((
shutil.which("sshd"), "-T", "-f", config,
"-C", f"user={username},host=localhost,addr=127.0.0.1",
), capture=True)
self.assertIn(f"passwordauthentication {expected}\n", result.stdout)
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
own_key = "ssh-ed25519 AAAA-own-key developer@example"
@@ -237,212 +187,3 @@ class ImageStoreTests(unittest.TestCase):
)
self.assertEqual(runner.calls, [])
def test_directory_creation_checks_effects_even_when_debugfs_reports_success(self) -> None:
class FailedMkdirRunner:
@staticmethod
def run(command, **_options) -> CommandResult:
return CommandResult(
args=tuple(map(str, command)),
returncode=0,
stderr="File not found by ext2_lookup" if "stat " in str(command[2]) else "",
)
store = ImageStore(Settings(), runner=FailedMkdirRunner()) # type: ignore[arg-type]
with self.assertRaisesRegex(UvmError, "could not verify guest rootfs directory"):
store._create_guest_directory(Path("/tmp/rootfs.ext4"), "/home/user", 0o700, 1000, 1000)
@unittest.skipUnless(
all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp", "openssl", "ssh-keygen")),
"e2fsprogs, coreutils, OpenSSL, and OpenSSH are required for offline credential checks",
)
class OfflineGuestCredentialsTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.template = self.base / "template.ext4"
self.disk = self.base / "rootfs.ext4"
self.runner = CommandRunner(emit=None)
self.store = ImageStore(Settings(base=self.base), self.runner)
self.files = {
"/etc/passwd": (
"root:x:0:0:root:/root:/bin/bash\n"
"service:x:999:1002::/nonexistent:/usr/sbin/nologin\n"
"ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash\n"
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n"
),
"/etc/shadow": (
"root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
"ubuntu:!:2:0:99999:7:::\nnobody:*:1:0:99999:7:::\n"
),
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n",
"/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n",
"/etc/sudoers": "Defaults env_reset\nroot ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n",
"/usr/bin/sudo": "sudo binary fixture\n",
"/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n",
"/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
}
def make_disk(self) -> None:
root = self.base / "guest"
for guest_path, contents in self.files.items():
path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
mode = {"/etc/shadow": 0o640, "/etc/gshadow": 0o640, "/etc/sudoers": 0o440, "/usr/bin/sudo": 0o4755}
path.chmod(mode.get(guest_path, 0o644))
with self.template.open("wb") as image:
image.truncate(16 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
for guest_path in self.files:
gid = 42 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0
for field, value in (("uid", 0), ("gid", gid)):
self.runner.run(
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", self.template),
capture=True,
)
self.store._copy_sparse(self.template, self.disk)
def contents(self, guest_path: str) -> str:
return self.runner.run(("debugfs", "-R", f"cat {guest_path}", self.disk), capture=True).stdout
def entry(self, guest_path: str, username: str) -> list[str]:
return next(line.split(":") for line in self.contents(guest_path).splitlines()
if line.split(":", 1)[0] == username)
def assert_metadata(self, guest_path: str, mode: int, uid: int, gid: int) -> None:
result = self.runner.run(("debugfs", "-R", f"stat {guest_path}", self.disk), capture=True)
self.assertRegex(result.stdout, rf"Mode:\s+0*{mode:o}\b")
self.assertRegex(result.stdout, rf"User:\s+{uid}\s+Group:\s+{gid}\b")
def assert_password(self, username: str, password: str) -> None:
password_hash = self.entry("/etc/shadow", username)[1]
self.assertTrue(password_hash.startswith("$6$"))
result = self.runner.run(
("openssl", "passwd", "-6", "-salt", password_hash.split("$")[2], "-stdin"),
capture=True, input_text=f"{password}\n", sensitive=True,
)
self.assertEqual(password_hash, result.stdout.strip())
def test_custom_username_creates_a_login_account_on_the_private_disk(self) -> None:
self.make_disk()
template_digest = hashlib.sha256(self.template.read_bytes()).hexdigest()
day_before = int(time.time() // 86400)
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
# UID 1000 and GIDs 1000/1001 are occupied; GID 1002 is used only in passwd.
self.assertEqual(
self.contents("/etc/passwd"),
self.files["/etc/passwd"] + "codemate-user:x:1001:1003::/home/codemate-user:/bin/sh\n",
)
self.assertEqual(self.contents("/etc/group"), self.files["/etc/group"] + "codemate-user:x:1003:\n")
self.assertEqual(self.contents("/etc/gshadow"), self.files["/etc/gshadow"] + "codemate-user:!::\n")
self.assertTrue(self.contents("/etc/shadow").startswith(self.files["/etc/shadow"]))
shadow = self.entry("/etc/shadow", "codemate-user")
self.assertLessEqual(day_before, int(shadow[2]))
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
self.assert_password("codemate-user", "custom-password")
self.assertIn("codemate-user ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
self.assertIn("Defaults:codemate-user !rootpw, !runaspw, !targetpw\n", self.contents("/etc/sudoers"))
self.assert_metadata("/etc/sudoers", 0o440, 0, 0)
self.assert_metadata("/usr/bin/sudo", 0o4755, 0, 0)
if shutil.which("visudo"):
sudoers = self.base / "sudoers"
sudoers.write_text(self.contents("/etc/sudoers"), encoding="utf-8")
sudoers.chmod(0o440)
self.runner.run(("visudo", "-c", "-f", sudoers), capture=True)
for guest_path in ("/etc/passwd", "/etc/group"):
self.assert_metadata(guest_path, 0o644, 0, 0)
for guest_path in ("/etc/shadow", "/etc/gshadow"):
self.assert_metadata(guest_path, 0o640, 0, 42)
self.assert_metadata("/home", 0o755, 0, 0)
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1003)
sshd_config = self.contents("/etc/ssh/sshd_config")
self.assertIn("Match User codemate-user\n PasswordAuthentication yes\n", sshd_config)
self.assertIn("PermitRootLogin no", sshd_config)
self.assertEqual(self.contents("/root/.ssh/authorized_keys"), "ssh-ed25519 AAAA-own-key\n")
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
self.assertEqual(hashlib.sha256(self.template.read_bytes()).hexdigest(), template_digest)
def test_existing_root_and_non_root_accounts_keep_their_identity_and_home(self) -> None:
self.files["/home/ubuntu/data"] = "existing guest data\n"
self.make_disk()
for username in ("root", "ubuntu"):
with self.subTest(username=username):
self.store._copy_sparse(self.template, self.disk)
original_shadow = self.entry("/etc/shadow", username)
self.store.provision_credentials(self.disk, username, "changed-password")
for guest_path in ("/etc/passwd", "/etc/group", "/etc/gshadow", "/home/ubuntu/data"):
self.assertEqual(self.contents(guest_path), self.files[guest_path])
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
self.assert_password(username, "changed-password")
if username != "root":
self.assertIn(f"{username} ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
else:
self.assertEqual(self.contents("/etc/sudoers"), self.files["/etc/sudoers"])
for line in self.files["/etc/shadow"].splitlines():
if line.split(":", 1)[0] != username:
self.assertIn(line + "\n", self.contents("/etc/shadow"))
def test_new_user_reuses_an_existing_named_group(self) -> None:
self.files["/etc/group"] += "codemate-user:x:1100:ubuntu\n"
self.files["/etc/gshadow"] += "codemate-user:!::ubuntu\n"
self.make_disk()
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1100"])
for guest_path in ("/etc/group", "/etc/gshadow"):
self.assertEqual(self.contents(guest_path), self.files[guest_path])
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1100)
def test_new_user_supports_images_without_gshadow_and_without_final_newlines(self) -> None:
del self.files["/etc/gshadow"]
for guest_path in ("/etc/passwd", "/etc/shadow", "/etc/group"):
self.files[guest_path] = self.files[guest_path].rstrip("\n")
self.files["/home/retained"] = "existing home directory contents\n"
self.make_disk()
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1003"])
self.assertEqual(self.entry("/etc/group", "codemate-user"), ["codemate-user", "x", "1003", ""])
self.assert_password("codemate-user", "custom-password")
self.assertEqual(self.contents("/home/retained"), self.files["/home/retained"])
self.assertIsNone(self.store._read_guest_file(
self.disk, "/etc/gshadow", self.base / "gshadow", required=False,
))
def test_existing_home_path_is_not_overwritten(self) -> None:
self.files["/home/codemate-user"] = "existing guest file\n"
self.make_disk()
with self.assertRaisesRegex(UvmError, "cannot create guest rootfs directory: /home/codemate-user"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"])
self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"])
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
def test_custom_users_fail_with_an_actionable_error_on_stripped_images(self) -> None:
del self.files["/usr/bin/sudo"]
del self.files["/etc/sudoers"]
self.make_disk()
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo.*uvm install --force"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
def test_sudo_without_setuid_permission_does_not_produce_a_broken_administrator(self) -> None:
self.make_disk()
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /usr/bin/sudo mode 0100755", self.disk), capture=True)
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
-373
View File
@@ -1,373 +0,0 @@
from __future__ import annotations
import hashlib
import io
import json
import os
import shutil
import sys
import tarfile
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.images import ImageStore
from uvm.install import Installer, _GUEST_APT_SCRIPT, _ROOTFS_RECIPE
from uvm.integrity import load_manifest, write_manifest
from uvm.state import StateStore
from uvm.system import CommandResult, CommandRunner
class InstallerArchitectureTests(unittest.TestCase):
def test_installs_matching_native_artifacts_with_a_verified_manifest(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
with (
self.subTest(machine=machine),
tempfile.TemporaryDirectory() as temporary_directory,
patch.dict(os.environ, {"UVM_BASE": temporary_directory}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = Settings.from_environment()
version = settings.firecracker_version
binaries = {
"firecracker": f"firecracker for {architecture}".encode(),
"jailer": f"jailer for {architecture}".encode(),
}
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
for name, content in binaries.items():
member = tarfile.TarInfo(
f"release-{version}-{architecture}/{name}-{version}-{architecture}"
)
member.size = len(content)
member.mode = 0o755
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
kernel_bytes = f"kernel for {architecture}".encode()
rootfs_bytes = f"rootfs for {architecture}".encode()
settings = replace(
settings,
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
rootfs_url=(
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}/rootfs/bionic.rootfs.ext4"
),
)
guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
f"{architecture}/vmlinux-6.1.155": kernel_bytes,
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
}
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm"),
patch(
"uvm.install.urllib.request.urlopen",
side_effect=lambda url, timeout: io.BytesIO(payloads[url]),
) as download,
):
firecracker, assets = installer.install()
self.assertEqual(firecracker.read_bytes(), binaries["firecracker"])
self.assertEqual(settings.jailer_binary.read_bytes(), binaries["jailer"])
self.assertEqual(assets.kernel.read_bytes(), kernel_bytes)
self.assertEqual(assets.rootfs.read_bytes(), rootfs_bytes)
self.assertEqual(download.call_count, 3)
self.assertEqual({call.args[0] for call in download.call_args_list}, set(payloads))
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums, {
name: hashlib.sha256(content).hexdigest()
for name, content in {
**binaries, "kernel": kernel_bytes, "rootfs": rootfs_bytes,
}.items()
})
def test_arm64_install_requires_checksums_before_host_commands_or_downloads(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
settings = Settings(base=Path(temporary_directory), architecture="aarch64")
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm") as check_kvm,
patch("uvm.install.urllib.request.urlopen") as download,
self.assertRaisesRegex(UvmError, "Firecracker archive checksum is required"),
):
installer.install()
runner.run.assert_not_called()
check_kvm.assert_not_called()
download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists())
def test_default_build_records_the_authenticated_local_image_digest(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
settings = Settings(
base=Path(temporary), firecracker_sha256="1" * 64,
kernel_sha256=hashlib.sha256(b"kernel").hexdigest(),
)
settings.bin_dir.mkdir()
settings.firecracker_binary.write_bytes(b"firecracker")
settings.jailer_binary.write_bytes(b"jailer")
installer = Installer(settings, Mock(spec=CommandRunner), StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"), patch("uvm.install.check_kvm"),
patch.object(installer, "install_apt_packages"),
patch.object(installer, "install_firecracker_binary", return_value=settings.firecracker_binary),
patch.object(installer, "download", side_effect=lambda _url, path, **_kwargs: path.write_bytes(b"kernel")),
patch.object(installer, "_build_ubuntu_rootfs", side_effect=lambda path: path.write_bytes(b"full Ubuntu")) as build,
):
installer.install()
build.assert_called_once_with(settings.rootfs_image)
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums["rootfs"], hashlib.sha256(b"full Ubuntu").hexdigest())
self.assertEqual(ImageStore(settings).installed_assets().rootfs, settings.rootfs_image)
def test_old_rootfs_checksum_requires_an_explicit_prebuilt_url_before_mutations(self) -> None:
settings = Settings(firecracker_sha256="1" * 64, kernel_sha256="2" * 64, rootfs_sha256="3" * 64)
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, Mock(spec=StateStore), emit=None)
with self.assertRaisesRegex(UvmError, "unset UVM_ROOTFS_SHA256"):
installer._validate_integrity_policy()
runner.run.assert_not_called()
def test_only_verified_current_recipe_images_are_reused(self) -> None:
for verified, marker, force in (
(True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False),
(True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True),
(True, "uvm-ubuntu-jammy-v2\n", False),
):
with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"image")
write_manifest(settings.integrity_manifest_path,
{"rootfs": hashlib.sha256(b"image").hexdigest()}, verified=verified)
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("debugfs",), 0, stdout=marker)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with patch.object(installer, "_build_ubuntu_rootfs") as build:
installer._install_ubuntu_rootfs(settings.rootfs_image, force=force)
self.assertEqual(build.called, not (verified and marker == _ROOTFS_RECIPE and not force))
class UbuntuBuildRunner:
"""Simulate package installation; exercise real ext4 formatting and readback."""
def __init__(self, *, failure: str | None = None) -> None:
self.calls: list[tuple[str, ...]] = []
self.failure = failure
self.runner = CommandRunner(emit=None)
def run(self, command, **options) -> CommandResult:
args = tuple(map(str, command))
self.calls.append(args)
if "debootstrap" in args:
root = Path(args[-2])
if self.failure == "bootstrap":
self.write_files(root, {"debootstrap/debootstrap.log": "dpkg: --install needs at least one package archive file argument\n"})
return CommandResult(args, 1, stderr="W: Failure while unpacking the base system\n")
self.write_files(root, {
"etc/passwd": "root:x:0:0::/root:/bin/bash\n",
"etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n",
"etc/apt/sources.list": "deb https://ports.ubuntu.com/ubuntu-ports jammy main\n",
"usr/bin/apt-get": "apt binary",
"var/lib/dpkg/status": "Package: apt\nStatus: install ok installed\nVersion: 1.0\n",
"var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n",
"var/log/bootstrap.log": "Base system installed successfully.\n",
})
for relative in ("dev", "proc", "sys", "usr/sbin"):
(root / relative).mkdir(parents=True, exist_ok=True)
(root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf")
# Ubuntu's real script removes this, even if it existed before bootstrap.
(root / "usr/sbin/policy-rc.d").unlink(missing_ok=True)
return CommandResult(args, 0, stdout="I: Base system installed successfully.\n")
if "uvm-guest-packages" in args:
root = Path(args[args.index("uvm-guest-packages") + 1])
if (root / "usr/sbin/policy-rc.d").read_text() != "#!/bin/sh\nexit 101\n":
raise AssertionError("guest services must be disabled during apt provisioning")
sources = (root / "etc/apt/sources.list").read_text()
if "jammy-updates main universe" not in sources or "jammy-security main universe" not in sources:
raise AssertionError("apt must see updates and security before installing guest packages")
if self.failure == "packages":
return CommandResult(args, 100, stdout="Reading package lists...\n", stderr="E: unmet guest dependencies\n")
status = "\n\n".join(
f"Package: {package}\nStatus: install ok installed\nVersion: 1.0"
for package in ("apt", "sudo", "openssh-server", "systemd-sysv", "python3")
) + "\n"
files = {
"etc/sudoers": "root ALL=(ALL:ALL) ALL\n",
"etc/ssh/sshd_config": "UsePAM yes\n",
"etc/ssh/ssh_host_rsa_key": "template host key",
"usr/bin/sudo": "sudo binary",
"usr/bin/python3": "python binary",
"usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
"var/lib/dpkg/status": status,
"var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
}
if self.failure == "metadata":
files["var/lib/dpkg/status"] = ""
self.write_files(root, files)
(root / "usr/bin/sudo").chmod(0o4755)
return CommandResult(args, 0, stdout="Configured guest packages.\n")
if args[0] == "systemctl":
return CommandResult(args, 0)
return self.runner.run(command, **options)
@staticmethod
def write_files(root: Path, files: dict[str, str]) -> None:
for relative, contents in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
class UbuntuAptStageTests(unittest.TestCase):
def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
root = base / "guest with spaces"
policy = root / "usr/sbin/policy-rc.d"
policy.parent.mkdir(parents=True)
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
tools = base / "tools"
tools.mkdir()
for name in ("mount", "chroot"):
tool = tools / name
tool.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\nfrom pathlib import Path\n"
"name = Path(sys.argv[0]).name\n"
"print(json.dumps([name, *sys.argv[1:]]))\n"
"if name == 'chroot':\n"
" assert (Path(sys.argv[1]) / 'usr/sbin/policy-rc.d').is_file()\n"
" if os.environ.get('FAIL_UPDATE') == '1' and sys.argv[-1] == 'update':\n"
" sys.exit(100)\n",
encoding="utf-8",
)
tool.chmod(0o755)
runner = CommandRunner(emit=None)
for fail in (False, True):
with self.subTest(fail=fail):
result = runner.run((
"env", f"PATH={tools}", f"FAIL_UPDATE={int(fail)}",
"/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
"uvm-guest-packages", root, "sudo", "openssh-server",
), check=False, capture=True)
events = [json.loads(line) for line in result.stdout.splitlines()]
mounts = [event for event in events if event[0] == "mount"]
self.assertEqual([event[-1] for event in mounts], [
str(root / "dev"), str(root / "dev"), str(root / "proc"), str(root / "sys"),
])
calls = [event for event in events if event[0] == "chroot"]
self.assertEqual(result.returncode, 100 if fail else 0)
self.assertEqual(len(calls), 1 if fail else 3)
self.assertEqual(calls[0][-3:], ["-o", "APT::Update::Error-Mode=any", "update"])
for call in calls:
self.assertEqual(call[1:6], [str(root), "/usr/bin/env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get"])
if not fail:
self.assertEqual(calls[1][-1], "dist-upgrade")
self.assertEqual(calls[2][-3:], ["install", "sudo", "openssh-server"])
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")),
"e2fsprogs is required for offline Ubuntu image checks")
class UbuntuRootfsBuildTests(unittest.TestCase):
def test_build_keeps_package_metadata_and_sudo_permissions_with_native_signed_bootstrap(self) -> None:
for architecture, deb_arch, mirror in (
("aarch64", "arm64", "https://ports.ubuntu.com/ubuntu-ports"),
("x86_64", "amd64", "https://archive.ubuntu.com/ubuntu"),
):
with self.subTest(architecture=architecture), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary), architecture=architecture)
runner = UbuntuBuildRunner()
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
installer._build_ubuntu_rootfs(settings.rootfs_image)
bootstrap = next(call for call in runner.calls if "debootstrap" in call)
self.assertIn(f"--arch={deb_arch}", bootstrap)
self.assertEqual(bootstrap[-1], mirror)
self.assertIn("--force-check-gpg", bootstrap)
self.assertIn("--components=main", bootstrap)
self.assertIn("--include=ca-certificates,ubuntu-keyring", bootstrap)
self.assertFalse(any(part.startswith("--extra-suites") for part in bootstrap))
self.assertIn("--mount-proc", bootstrap)
self.assertIn("--kill-child", bootstrap)
packages = next(call for call in runner.calls if "uvm-guest-packages" in call)
self.assertLess(runner.calls.index(bootstrap), runner.calls.index(packages))
self.assertIn("sudo", packages)
self.assertIn("openssh-server", packages)
self.assertIn("python3", packages)
self.assertIn("--mount-proc", packages)
self.assertIn("--kill-child", packages)
self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600)
log = settings.images_dir / "ubuntu-build.log"
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
self.assertIn("Base system installed successfully.", log.read_text())
self.assertIn("Configured guest packages.", log.read_text())
def guest(command):
return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True)
self.assertIn("Package: apt\nStatus: install ok installed", guest("cat /var/lib/dpkg/status").stdout)
self.assertEqual(guest("cat /var/lib/dpkg/info/sudo.list").stdout, "/usr/bin/sudo\n")
self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755")
self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE)
self.assertIn("socket.AF_VSOCK", guest("cat /usr/lib/uvm/agent.py").stdout)
self.assertIn("Delegate=yes", guest("cat /etc/systemd/system/uvm-agent.service").stdout)
self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout)
self.assertIn("KeepConfiguration=yes", guest("cat /etc/systemd/network/10-uvm.network").stdout)
self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout)
for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"):
self.assertIn("File not found", guest(f"stat {path}").stderr)
def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None:
for failure in ("bootstrap", "packages", "metadata"):
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"old template")
runner = UbuntuBuildRunner(failure=failure)
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
log = settings.images_dir / "ubuntu-build.log"
with self.assertRaisesRegex(UvmError, "details saved to .*ubuntu-build.log"):
installer._build_ubuntu_rootfs(settings.rootfs_image)
self.assertEqual(settings.rootfs_image.read_bytes(), b"old template")
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
details = log.read_text()
if failure == "bootstrap":
self.assertIn("--install needs at least one package archive", details)
self.assertIn("Failure while unpacking", details)
self.assertFalse(any("uvm-guest-packages" in call for call in runner.calls))
elif failure == "packages":
self.assertIn("Reading package lists...", details)
self.assertIn("E: unmet guest dependencies", details)
self.assertLess(details.index("Base system installed successfully."), details.index("E: unmet guest dependencies"))
else:
self.assertIn("incomplete apt/dpkg package metadata", details)
self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls))
-277
View File
@@ -1,277 +0,0 @@
from __future__ import annotations
import errno
import os
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.test_cli import make_vm
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import Settings
from uvm.domain import PortRule
from uvm.errors import FirecrackerError, UvmError, ValidationError
from uvm.known_hosts import KnownHostsManager, _without_hosts
from uvm.system import CommandRunner
class KnownHostsTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
self.path = self.directory / "known_hosts"
self.vm = replace(make_vm(), ports=(PortRule(22), PortRule(2222)))
self.manager = KnownHostsManager(Settings(known_hosts_files=(self.path,)))
def test_removes_all_vm_key_types_and_aliases_but_preserves_other_hosts(self) -> None:
self.path.write_bytes(
b"# Known hosts\r\n"
b"10.42.0.2 ssh-ed25519 AAAAed first key\n"
b"10.42.0.2 ecdsa-sha2-nistp256 AAAAecdsa second key\n"
b"10.42.0.2 ssh-rsa AAAArsa third key\n"
b"uvm-vm-test ssh-ed25519 AAAAalias\n"
b"[10.42.0.2]:22 ssh-ed25519 AAAAport22\n"
b"[10.42.0.2]:2222 ssh-ed25519 AAAAport2222\n"
b"[10.42.0.2]:8022 ssh-ed25519 AAAAold-port\n"
b"[uvm-vm-test]:2222 ssh-ed25519 AAAAportalias\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,10.42.0.2,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority 10.42.0.2,example.com ssh-ed25519 AAAAca\n"
b"@revoked 10.42.0.2 ssh-ed25519 AAAArevoked\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(),
b"# Known hosts\r\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority example.com ssh-ed25519 AAAAca\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
def test_missing_files_and_unmatched_hosts_are_noops(self) -> None:
missing = self.directory / "missing" / ".ssh" / "known_hosts"
KnownHostsManager(Settings(known_hosts_files=(missing, self.path))).remove(self.vm)
self.assertEqual(list(self.directory.iterdir()), [])
self.path.write_bytes(b"10.42.0.3 ssh-ed25519 AAAAother\n")
before = self.path.stat()
with patch("uvm.known_hosts.os.replace") as publish:
self.manager.remove(self.vm)
publish.assert_not_called()
self.assertEqual(self.path.stat().st_ino, before.st_ino)
self.assertEqual(self.path.stat().st_mtime_ns, before.st_mtime_ns)
def test_rewrite_preserves_ownership_and_file_mode_and_is_idempotent(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.path.chmod(0o640)
before = self.path.stat()
self.manager.remove(self.vm)
after = self.path.stat()
self.assertEqual((after.st_uid, after.st_gid, after.st_mode), (before.st_uid, before.st_gid, before.st_mode))
self.assertEqual(self.path.read_bytes(), b"10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.manager.remove(self.vm)
self.assertEqual(self.path.stat().st_ino, after.st_ino)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_root_rewrite_restores_the_file_owners_uid_and_gid(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
inode = self.path.stat().st_ino
real_fstat, real_stat = os.fstat, os.stat
def ownership(metadata):
if metadata.st_ino != inode:
return metadata
values = list(metadata)
values[4:6] = [12345, 23456]
return os.stat_result(values)
with (
patch("uvm.known_hosts.os.fstat", side_effect=lambda fd: ownership(real_fstat(fd))),
patch("uvm.known_hosts.os.stat", side_effect=lambda *args, **kwargs: ownership(real_stat(*args, **kwargs))),
patch("uvm.known_hosts.os.fchown") as chown,
):
self.manager.remove(self.vm)
self.assertEqual(chown.call_count, 1)
self.assertEqual(chown.call_args.args[1:], (12345, 23456))
self.assertEqual(self.path.read_bytes(), b"")
def test_extended_file_attributes_survive_replacement(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
try:
os.setxattr(self.path, "user.uvm-fixture", b"preserved")
except OSError as error:
if error.errno in (errno.ENOTSUP, errno.EPERM):
self.skipTest("extended file attributes are unavailable")
raise
self.manager.remove(self.vm)
self.assertEqual(os.getxattr(self.path, "user.uvm-fixture"), b"preserved")
def test_default_files_include_root_and_sudo_user_from_passwd_not_home(self) -> None:
root = SimpleNamespace(pw_uid=0, pw_dir="/root")
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", side_effect=lambda uid: {0: root, 1000: user}[uid]),
patch.dict(os.environ, {"HOME": "/wrong-home", "SUDO_UID": "1000"}, clear=True),
):
paths = KnownHostsManager(Settings())._files()
self.assertEqual(paths, {
Path("/root/.ssh/known_hosts"): 0, Path("/root/.ssh/known_hosts2"): 0,
Path("/home/operator/.ssh/known_hosts"): 1000, Path("/home/operator/.ssh/known_hosts2"): 1000,
})
def test_sudo_user_fallback_and_nonroot_context_are_scoped_to_the_launcher(self) -> None:
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", return_value=SimpleNamespace(pw_uid=0, pw_dir="/root")),
patch("uvm.known_hosts.pwd.getpwnam", return_value=user) as lookup,
patch.dict(os.environ, {"SUDO_USER": "operator"}, clear=True),
):
self.assertIn(Path("/home/operator/.ssh/known_hosts"), KnownHostsManager(Settings())._files())
lookup.assert_called_once_with("operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=1000),
patch("uvm.known_hosts.pwd.getpwuid", return_value=user),
patch.dict(os.environ, {"SUDO_UID": "9999"}, clear=True),
):
self.assertEqual(set(KnownHostsManager(Settings())._files()), {
Path("/home/operator/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts2"),
})
def test_publication_failure_preserves_original_and_removes_temporary_file(self) -> None:
contents = b"10.42.0.2 ssh-ed25519 AAAAold\n"
self.path.write_bytes(contents)
with patch("uvm.known_hosts.os.replace", side_effect=OSError("publish failed")):
with self.assertRaisesRegex(UvmError, "publish failed"):
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), contents)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_concurrent_append_is_not_lost_and_cleanup_can_be_retried(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
addition = b"10.42.0.4 ssh-ed25519 AAAAconcurrent\n"
def append(contents, hosts, candidates):
with self.path.open("ab") as output:
output.write(addition)
return _without_hosts(contents, hosts, candidates)
with patch("uvm.known_hosts._without_hosts", side_effect=append):
with self.assertRaisesRegex(UvmError, "changed during cleanup"):
self.manager.remove(self.vm)
self.assertTrue(self.path.read_bytes().endswith(addition))
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), addition)
def test_temporary_name_collision_never_removes_a_foreign_file(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
foreign = self.directory / ".known_hosts.uvm-collision"
foreign.write_bytes(b"keep this")
with patch("uvm.known_hosts.uuid.uuid4", return_value=SimpleNamespace(hex="collision")):
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(foreign.read_bytes(), b"keep this")
self.assertIn(b"10.42.0.2", self.path.read_bytes())
def test_symlink_and_hardlinked_files_are_not_rewritten(self) -> None:
target = self.directory / "target"
target.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
for kind in ("symlink", "hardlink"):
with self.subTest(kind=kind):
self.path.unlink(missing_ok=True)
if kind == "symlink":
self.path.symlink_to(target)
else:
self.path.hardlink_to(target)
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(target.read_bytes(), b"10.42.0.2 ssh-ed25519 AAAAold\n")
@unittest.skipUnless(shutil.which("ssh-keygen"), "OpenSSH is required for hashed-host interoperability")
def test_removes_real_openssh_hashed_hosts_for_all_guest_keys_and_keeps_other_hosts(self) -> None:
runner = CommandRunner(emit=None)
lines = []
for key_type in ("ed25519", "ecdsa"):
key = self.directory / key_type
runner.run(("ssh-keygen", "-q", "-t", key_type, "-N", "", "-f", key), capture=True)
public = key.with_suffix(".pub").read_text().strip()
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222", "10.42.0.3"):
lines.append(f"{host} {public}\n")
self.path.write_text("".join(lines))
runner.run(("ssh-keygen", "-H", "-f", self.path), capture=True)
self.assertNotIn(b"10.42.0.2", self.path.read_bytes())
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True)
self.manager.remove(self.vm)
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
result = runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True, check=False)
self.assertEqual(result.returncode, 1)
runner.run(("ssh-keygen", "-F", "10.42.0.3", "-f", self.path), capture=True)
class DeleteKnownHostsTests(LifecycleTestCase):
def test_deletion_cleans_keys_after_teardown_before_releasing_the_ip(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.destroy(vm.id)
self.assert_call_order(vm, "process.gone", "network.delete_vm_tap", "firewall.remove", "known_hosts.remove")
self.assertEqual(self.host.records["known_hosts.remove", vm.id].operation, "delete")
self.assertNotIn(vm.id, self.store.load().vms)
def test_stop_restart_and_resize_retain_host_keys(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
self.service.start(vm.id)
self.service.restart(vm.id)
self.service.resize(vm.id, ram_mib=1024)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_failed_stop_does_not_remove_host_keys(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("still alive")
with self.assertRaises(UvmError):
self.service.destroy(vm.id)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_cleanup_error_retains_the_ip_reservation_and_delete_retries(self) -> None:
vm = self.create_vm()
self.host.failures["known_hosts.remove", vm.id] = UvmError("known_hosts is not writable")
with self.assertRaisesRegex(UvmError, "known_hosts is not writable"):
self.service.destroy(vm.id)
self.assertEqual(self.store.get(vm.id).operation, "delete")
self.assertFalse(Path(vm.disk).exists())
with self.assertRaises(ValidationError):
self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.service.destroy(vm.id)
created = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(created.guest_ip, vm.guest_ip)
def test_delete_removes_real_cache_entries_and_allows_reuse_without_stale_keys(self) -> None:
vm = self.create_vm()
cache = self.settings.base / "known_hosts"
cache.write_text(f"{vm.guest_ip} ssh-ed25519 AAAAold\n10.42.0.99 ssh-ed25519 AAAAother\n")
self.service._known_hosts = KnownHostsManager(replace(self.settings, known_hosts_files=(cache,)))
self.service.destroy(vm.id)
self.assertEqual(cache.read_text(), "10.42.0.99 ssh-ed25519 AAAAother\n")
replacement = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(replacement.guest_ip, vm.guest_ip)
def test_bulk_delete_uses_the_same_known_hosts_cleanup(self) -> None:
vms = [self.create_vm(), self.create_vm()]
results = self.service.run_many("delete", [vm.id for vm in vms])
self.assertTrue(all(result.outcome == "succeeded" for result in results))
for vm in vms:
self.assertIn("known_hosts.remove", self.host.actions(vm.id))
+1 -88
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
import shutil
import tempfile
import unittest
from collections.abc import Callable, Iterable, Iterator
@@ -81,16 +80,6 @@ class FakeHost:
return [action for action, target in self.events if target == vm_id]
class FakeKnownHosts:
def __init__(self, host: FakeHost) -> None:
self.host = host
def remove(self, vm: VmRecord) -> None:
self.host.call("known_hosts.remove", vm.id)
if Path(vm.socket).parent.exists():
raise AssertionError("known_hosts cleanup must follow VM runtime deletion")
class FakeFirewall:
def __init__(self, host: FakeHost, network: FakeNetwork) -> None:
self.host = host
@@ -293,30 +282,12 @@ class LifecycleTestCase(unittest.TestCase):
self.store = TrackedStateStore(self.settings)
self.host = FakeHost(self.store)
self.images = ImageStore(self.settings)
self.disk_sizes: dict[Path, int] = {}
self.enterContext(patch.object(self.images, "_copy_sparse", side_effect=shutil.copy2))
self.filesystem_resize = self.enterContext(patch.object(
self.images, "_resize_filesystem",
side_effect=lambda disk, capacity: self.disk_sizes.update({disk: capacity * 1024 * 1024}),
))
self.enterContext(patch.object(
self.images, "disk_size_bytes",
side_effect=lambda disk: self.disk_sizes.get(disk, disk.stat().st_size),
))
self.resize_disk = self.enterContext(patch.object(
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
))
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
self.configure_network = self.enterContext(patch.object(
self.images, "configure_guest_network", side_effect=self.configure_guest_network,
))
self.provision_agent = self.enterContext(patch.object(self.images, "provision_guest_agent"))
self.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
))
self.network = FakeNetwork(self.settings, self.host)
self.process = FakeProcess(self.host)
self.known_hosts = FakeKnownHosts(self.host)
self.clients: dict[str, FakeClient] = {}
self.service = LifecycleService(
self.settings,
@@ -324,7 +295,6 @@ class LifecycleTestCase(unittest.TestCase):
self.images,
self.network, # type: ignore[arg-type]
self.process, # type: ignore[arg-type]
known_hosts=self.known_hosts, # type: ignore[arg-type]
client_factory=self.make_client, # type: ignore[arg-type]
)
self.require_root = self.enterContext(patch("uvm.lifecycle.require_root"))
@@ -337,21 +307,6 @@ class LifecycleTestCase(unittest.TestCase):
self.clients[client.vm_id] = client
return client
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
vm_id = disk.parent.name
self.host.call("images.resize_disk", vm_id)
if self.process.observe(self.store.get(vm_id)) != "gone":
raise AssertionError("cannot resize a running or ambiguous VM")
self.disk_sizes[disk] = storage_mib * 1024 * 1024
def configure_guest_network(self, disk: Path, guest_ip: str) -> None:
vm = self.store.get(disk.parent.name)
self.host.call("images.configure_network", vm.id)
if self.process.observe(vm) != "gone":
raise AssertionError("cannot configure a running or ambiguous VM disk")
if guest_ip != vm.guest_ip:
raise AssertionError("guest network must use the VM's retained address")
def create_vm(self, **kwargs) -> VmRecord:
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
@@ -493,7 +448,7 @@ class LifecycleTests(LifecycleTestCase):
def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
vm = self.create_vm()
self.assert_call_order(
vm, "images.configure_network", "network.prepare_tap", "firewall.apply", "firewall.readback",
vm, "network.prepare_tap", "firewall.apply", "firewall.readback",
"network.activate_tap", "process.start", "process.spawn", "process.ready",
"client.configure", "firewall.status",
)
@@ -511,46 +466,6 @@ class LifecycleTests(LifecycleTestCase):
self.assertEqual(spawned.operation, "configuring")
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid)
def test_guest_network_configuration_failure_rolls_back_before_marking_the_disk_ready(self) -> None:
self.host.failures["images.configure_network", None] = UvmError("guest network verification failed")
with self.assertRaisesRegex(UvmError, "guest network verification failed"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertFalse(self.network.created_taps)
self.assertFalse(self.process.started)
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_retained_guest_network_is_configured_after_exit_and_before_boot(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.restart(vm.id)
self.assert_call_order(vm, "images.configure_network", "process.start", "client.configure")
actions = self.host.actions(vm.id)
self.assertLess(max(index for index, action in enumerate(actions) if action == "process.gone"),
actions.index("images.configure_network"))
self.configure_network.assert_called_with(Path(vm.disk), vm.guest_ip)
self.assertEqual(self.provision.call_count, 1)
def test_live_start_does_not_modify_the_guest_disk(self) -> None:
vm = self.create_vm()
self.configure_network.reset_mock()
self.service.start(vm.id)
self.configure_network.assert_not_called()
def test_failed_retained_network_configuration_keeps_disk_for_retry(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
disk = Path(vm.disk)
disk.write_bytes(b"retained data and credentials")
self.host.failures["images.configure_network", vm.id] = UvmError("network verification failed")
with self.assertRaisesRegex(UvmError, "network verification failed"):
self.service.start(vm.id)
self.assertEqual(disk.read_bytes(), b"retained data and credentials")
self.assertEqual(self.process.observe(self.store.get(vm.id)), "gone")
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.service.start(vm.id).status, "running")
self.assertEqual(self.provision.call_count, 1)
def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
with self.assertRaisesRegex(UvmError, "policy unavailable"):
@@ -595,7 +510,6 @@ class LifecycleTests(LifecycleTestCase):
settings = replace(self.settings, **changed)
service = LifecycleService(
settings, self.store, self.images, self.network, self.process,
known_hosts=self.known_hosts,
client_factory=self.make_client,
)
self.host.events.clear()
@@ -628,7 +542,6 @@ class LifecycleTests(LifecycleTestCase):
settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway))
service = LifecycleService(
settings, self.store, self.images, self.network, self.process,
known_hosts=self.known_hosts,
client_factory=self.make_client,
)
with (
+2 -28
View File
@@ -385,7 +385,7 @@ class LifecyclePortTests(LifecycleTestCase):
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
def test_start_of_running_vm_repairs_policy_and_egress_without_reboot_or_reprovision(self) -> None:
def test_start_of_running_vm_repairs_policy_without_reboot_or_reprovision(self) -> None:
vm = self.create_vm()
del self.network.firewall.tables[vm.id]
self.host.events.clear()
@@ -395,34 +395,9 @@ class LifecyclePortTests(LifecycleTestCase):
self.assertNotIn("process.start", self.host.actions(vm.id))
self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.assertNotIn("client.configure", self.host.actions(vm.id))
self.assertIn(("network.ensure_bridge", None), self.host.events)
self.assertLess(self.host.events.index(("firewall.apply", vm.id)),
self.host.events.index(("network.ensure_bridge", None)))
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
def test_live_start_rejects_changed_network_settings_before_repairing_network(self) -> None:
vm = self.create_vm()
self.service._settings = replace(self.settings, bridge="another-bridge")
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaisesRegex(ConflictError, "retained network"):
self.service.start(vm.id)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertNotIn(("network.ensure_bridge", None), self.host.events)
self.assertNotIn("firewall.apply", self.host.actions(vm.id))
def test_live_start_can_retry_an_egress_setup_failure_with_the_same_process(self) -> None:
vm = self.create_vm()
self.host.failures["network.ensure_bridge", None] = UvmError("egress setup failed")
with self.assertRaisesRegex(UvmError, "egress setup failed"):
self.service.start(vm.id)
self.assertEqual(self.process.observe(self.store.get(vm.id)), "alive")
self.assertEqual(self.network.firewall.status(vm), "enforced")
self.assertEqual(self.service.start(vm.id).pid, vm.pid)
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.provision.call_count, 1)
class LifecycleBulkTests(LifecycleTestCase):
def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None:
@@ -616,8 +591,7 @@ class LifecycleInspectionTests(LifecycleTestCase):
def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None:
settings = replace(self.settings, base=self.settings.base / "not-installed")
service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process,
known_hosts=self.known_hosts)
service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process)
self.assertEqual(service.list_vms(), [])
self.assertEqual(service.resolve_targets([], all_vms=True), [])
for method in (service.inspect, service.find_for_ssh, service.log_path):
-173
View File
@@ -1,173 +0,0 @@
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)
+39 -75
View File
@@ -5,7 +5,6 @@ import unittest
from copy import deepcopy
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord
@@ -23,10 +22,6 @@ class FakeRunner:
self.links: dict[str, dict] = {}
self.inventory: str | None = None
self.failures: dict[tuple[str, ...], BaseException] = {}
self.default_routes = "default via 192.0.2.1 dev eth0\n"
self.rules: dict[tuple[str, str], list[tuple[str, ...]]] = {
("nat", "POSTROUTING"): [], ("filter", "FORWARD"): [],
}
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
del capture, timeout
@@ -73,21 +68,11 @@ class FakeRunner:
elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"):
pass
elif args[0] == "iptables":
if args[1:4] != ("--wait", "5", "-t"):
raise AssertionError(f"unexpected iptables options: {args}")
table, operation, chain, *rule = args[4:]
rules = self.rules[table, chain]
if operation == "-C":
return CommandResult(args=args, returncode=0 if tuple(rule) in rules else 1)
if operation == "-I":
position, *rule = rule
rules.insert(int(position) - 1, tuple(rule))
else:
raise AssertionError(f"unexpected iptables mutation: {args}")
return CommandResult(args=args, returncode=1 if "-C" in args else 0)
elif args != ("ip", "route", "show", "default"):
raise AssertionError(f"unexpected host command: {args}")
if args == ("ip", "route", "show", "default"):
return CommandResult(args=args, returncode=0, stdout=self.default_routes)
return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n")
return CommandResult(args=args, returncode=0)
@@ -142,61 +127,44 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
self.runner.calls,
)
self.assertIn(("sysctl", "-w", "net.ipv4.ip_forward=1"), self.runner.calls)
nat, = self.runner.rules["nat", "POSTROUTING"]
self.assertEqual(nat[:6], ("-s", "10.42.0.0/24", "!", "-o", "uvm0", "-m"))
self.assertEqual(nat[-2:], ("-j", "MASQUERADE"))
inbound, outbound = self.runner.rules["filter", "FORWARD"]
self.assertEqual(outbound[:4], ("-i", "uvm0", "-s", "10.42.0.0/24"))
self.assertEqual(inbound[:8], (
"-o", "uvm0", "-d", "10.42.0.0/24", "-m", "conntrack",
"--ctstate", "ESTABLISHED,RELATED",
))
self.assertEqual(inbound[-2:], ("-j", "ACCEPT"))
self.assertEqual(outbound[-2:], ("-j", "ACCEPT"))
def test_egress_is_installed_offline_and_survives_uplink_changes_without_extra_rules(self) -> None:
self.runner.default_routes = ""
self.network.ensure_bridge()
offline_rules = deepcopy(self.runner.rules)
self.assertEqual([len(rules) for rules in offline_rules.values()], [1, 2])
for route in (
"default via 192.0.2.1 dev eth0\n",
"default via 198.51.100.1 dev wlan0\n",
"default dev tun0\ndefault via 198.51.100.1 dev wlan0 metric 600\n",
"",
):
with self.subTest(route=route):
self.runner.default_routes = route
self.runner.calls.clear()
self.network.ensure_bridge()
self.assertEqual(self.runner.rules, offline_rules)
self.assertFalse(any("-I" in call for call in self.runner.calls))
self.assertFalse(any("eth0" in rule or "wlan0" in rule or "tun0" in rule
for rules in offline_rules.values() for rule in rules))
def test_egress_exceptions_precede_existing_drops_and_preserve_foreign_and_legacy_rules(self) -> None:
existing = [
("-j", "HOST-FIREWALL"), ("-j", "DROP"),
("-i", "uvm0", "-o", "eth0", "-s", "10.42.0.0/24", "-j", "ACCEPT"),
self.assertIn(
(
"iptables",
"-A",
"FORWARD",
"-i",
"uvm0",
"-o",
"eth0",
"-s",
"10.42.0.0/24",
"-j",
"ACCEPT",
),
self.runner.calls,
)
checks = [
call
for call in self.runner.calls
if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING")
]
self.runner.rules["filter", "FORWARD"] = list(existing)
self.network.ensure_bridge()
rules = list(self.runner.rules["filter", "FORWARD"])
self.assertEqual(rules[2:], existing)
self.assertTrue(all("uvm-egress" in rule and rule[-1] == "ACCEPT" for rule in rules[:2]))
self.network.ensure_bridge()
self.assertEqual(self.runner.rules["filter", "FORWARD"], rules)
def test_iptables_inspection_errors_are_not_treated_as_missing_rules(self) -> None:
check = ("iptables", "--wait", "5", "-t", "nat", "-C", "POSTROUTING",
"-s", "10.42.0.0/24", "!", "-o", "uvm0",
"-m", "comment", "--comment", "uvm-egress", "-j", "MASQUERADE")
with patch.object(self.runner, "run", return_value=CommandResult(check, 4, stderr="permission denied")) as run:
with self.assertRaisesRegex(CommandError, "could not inspect.*permission denied"):
self.network._ensure_masquerade()
self.assertEqual(run.call_count, 1)
self.assertEqual(len(checks), 1)
self.assertIn(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
"10.42.0.0/24",
"-o",
"eth0",
"-j",
"MASQUERADE",
),
self.runner.calls,
)
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
settings = Settings(
@@ -210,10 +178,6 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
runner.calls,
)
for rules in runner.rules.values():
for rule in rules:
self.assertIn("10.50.0.0/16", rule)
self.assertNotIn("10.42.0.0/24", rule)
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"):
-26
View File
@@ -113,28 +113,6 @@ class ProcessTestCase(unittest.TestCase):
class FirecrackerProcessSafetyTests(ProcessTestCase):
def test_vsock_socket_is_removed_only_after_confirmed_process_exit(self) -> None:
path = self.manager._settings.guest_socket_path(self.vm.id)
path.parent.mkdir(parents=True)
path.touch()
self.vm.process_start_time = None
with self.assertRaises(FirecrackerError):
self.manager.terminate(self.vm)
self.assertTrue(path.exists())
self.vm.boot_id = OLD_BOOT_ID
self.manager.terminate(self.vm)
self.assertFalse(path.exists())
def test_unowned_vsock_socket_keeps_a_missing_pid_ambiguous(self) -> None:
path = self.manager._settings.guest_socket_path(self.vm.id)
path.parent.mkdir(parents=True)
path.touch()
self.vm.pid = self.vm.process_start_time = None
self.assertEqual(self.manager.observe(self.vm), "unknown")
with self.assertRaises(FirecrackerError):
self.manager.terminate(self.vm)
self.assertTrue(path.exists())
def test_observes_a_matching_identity_as_alive(self) -> None:
self.assertEqual(self.manager.observe(self.vm), "alive")
self.assertTrue(self.manager.is_alive(self.vm))
@@ -830,10 +808,6 @@ class FirecrackerLegacyAdoptionTests(ProcessTestCase):
def root_lstat(path: Path) -> os.stat_result:
metadata = list(real_lstat(path))
metadata[4] = 0
# Model protected system ancestors independently of the caller's
# TMPDIR permissions. Unsafe-path fixtures live inside self.directory.
if path in self.directory.parents:
metadata[0] &= ~0o022
return os.stat_result(metadata)
self.root_lstat = root_lstat
-195
View File
@@ -1,195 +0,0 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import MIN_STORAGE_MIB
from uvm.domain import PortRule, VmResources, VmSpec
from uvm.errors import ConflictError, FirecrackerError, UvmError, ValidationError
class LifecycleResizeTests(LifecycleTestCase):
def test_creation_provisions_the_default_or_requested_storage(self) -> None:
for capacity in (MIN_STORAGE_MIB, 80 * 1024):
with self.subTest(capacity=capacity):
vm = self.create_vm(storage_mib=capacity)
self.copy_disk.assert_called_with(
self.settings.rootfs_image, Path(vm.disk), storage_mib=capacity,
)
self.filesystem_resize.assert_called_with(Path(vm.disk), capacity)
self.assertEqual(vm.storage_mib, capacity)
self.assertEqual(self.store.get(vm.id).storage_mib, capacity)
self.assertEqual(VmSpec(cpu=1, ram_mib=512).storage_mib, MIN_STORAGE_MIB)
def test_stop_resize_start_preserves_disk_data_credentials_and_network_identity(self) -> None:
vm = self.create_vm(name="database", username="admin", password="same-secret",
ports=(PortRule(22), PortRule(5432)))
sibling = self.create_vm(name="sibling")
sibling_before = self.store.get(sibling.id).to_dict()
original = replace(vm)
disk = Path(vm.disk)
contents = b"guest data\nunchanged password hash\noriginal SSH host keys\n"
disk.write_bytes(contents)
for cpu, ram_mib, storage_mib in ((2, 4096, 50 * 1024), (0.5, 256, MIN_STORAGE_MIB)):
with self.subTest(cpu=cpu, storage=storage_mib):
previous_pid = self.store.get(vm.id).pid
locks = self.store.operation_locks
self.host.events.clear()
resized = self.service.resize(vm.name, cpu=cpu, ram_mib=ram_mib,
storage_mib=storage_mib, timeout=0.5)
self.assertEqual(self.store.operation_locks - locks, 1)
self.assertEqual((resized.cpu, resized.ram_mib, resized.storage_mib), (cpu, ram_mib, storage_mib))
self.assertEqual(resized.status, "running")
self.assertIsNone(resized.pending_resize)
self.assertIsNone(resized.operation)
self.assertNotEqual(resized.pid, previous_pid)
for field in ("id", "name", "guest_ip", "gateway", "network", "bridge", "mac", "tap",
"disk", "username", "password", "ports", "created_at"):
self.assertEqual(getattr(resized, field), getattr(original, field), field)
self.assertEqual(disk.read_bytes(), contents)
actions = self.host.actions(vm.id)
self.assertLess(actions.index("process.gone"), actions.index("images.resize_disk"))
self.assertLess(actions.index("images.resize_disk"), actions.index("process.start"))
stopped = self.host.records["process.terminate", vm.id]
self.assertEqual(stopped.pending_resize, VmResources(cpu, ram_mib, storage_mib))
self.assertEqual(stopped.operation, "resize")
config = self.clients[vm.id].config
self.assertEqual(config["machine-config"], {"vcpu_count": max(1, int(cpu)), "mem_size_mib": ram_mib})
self.assertEqual(config["drives"][0]["path_on_host"], vm.disk)
self.assertEqual(self.store.get(vm.id).to_dict(), resized.to_dict())
self.assertEqual(self.store.get(sibling.id).to_dict(), sibling_before)
self.assertEqual(self.copy_disk.call_count, 2)
self.assertEqual(self.provision.call_count, 2)
self.assertEqual(self.resize_disk.call_count, 2)
def test_cpu_or_memory_only_resize_keeps_storage_and_boots_an_initially_stopped_vm(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.service.stop(vm.id)
first = self.service.resize(vm.guest_ip, cpu=2)
self.assertEqual((first.cpu, first.ram_mib, first.storage_mib), (2, 512, 50 * 1024))
second = self.service.resize(vm.id, ram_mib=4096)
self.assertEqual((second.cpu, second.ram_mib, second.storage_mib), (2, 4096, 50 * 1024))
self.assertEqual(second.status, "running")
self.resize_disk.assert_not_called()
self.assertEqual(self.provision.call_count, 1)
def test_invalid_resize_requests_do_not_change_state_or_stop_the_machine(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
for changes in ({}, {"cpu": 0}, {"cpu": float("nan")}, {"cpu": True},
{"ram_mib": 127}, {"ram_mib": 512.0}, {"storage_mib": 29 * 1024},
{"storage_mib": True}, {"storage_mib": 2**63}, {"cpu": 2, "timeout": -1}):
with self.subTest(changes=changes), self.assertRaises(ValidationError):
self.service.resize(vm.id, **changes)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
self.resize_disk.assert_not_called()
def test_resize_rejects_unsafe_or_unbootable_vm_before_stopping(self) -> None:
for problem in ("unknown", "delete", "legacy", "unprovisioned", "missing", "topology", "kernel"):
with self.subTest(problem=problem):
vm = self.create_vm()
if problem == "unknown":
self.process.unknown.add(vm.id)
elif problem == "delete":
self.persist(vm, operation="delete")
elif problem == "legacy":
self.persist(vm, ports=None)
elif problem == "unprovisioned":
self.persist(vm, disk_ready=False)
elif problem == "missing":
Path(vm.disk).unlink()
elif problem == "topology":
self.persist(vm, bridge="different")
else:
self.settings.kernel_image.unlink()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaises(UvmError):
self.service.resize(vm.id, cpu=2)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.resize_disk.assert_not_called()
if problem == "topology":
self.persist(vm, bridge="uvm0")
if problem == "kernel":
self.settings.kernel_image.write_bytes(b"kernel")
def test_failed_stop_keeps_old_resources_and_persists_retryable_target(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("exit unconfirmed")
with self.assertRaisesRegex(UvmError, "failed to resize.*exit unconfirmed"):
self.service.resize(vm.id, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.ram_mib, retained.storage_mib), (1, 512, MIN_STORAGE_MIB))
self.assertEqual(retained.pending_resize, VmResources(2, 4096, 50 * 1024))
self.assertEqual(retained.pid, vm.pid)
self.resize_disk.assert_not_called()
self.assertEqual(len(self.process.started), 1)
with self.assertRaisesRegex(ConflictError, "resize is pending"):
self.service.start(vm.id)
retried = self.service.resize(vm.id, cpu=2)
self.assertEqual((retried.cpu, retried.ram_mib, retried.storage_mib), (2, 4096, 50 * 1024))
def test_failed_disk_resize_is_stopped_and_start_retries_persisted_target(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.host.failures["images.resize_disk", vm.id] = UvmError("filesystem does not fit")
with self.assertRaisesRegex(UvmError, "filesystem does not fit"):
self.service.resize(vm.id, cpu=2, storage_mib=MIN_STORAGE_MIB)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (1, 50 * 1024))
self.assertEqual(retained.pending_resize, VmResources(2, 512, MIN_STORAGE_MIB))
self.assertIsNone(retained.pid)
self.assertEqual(len(self.process.started), 1)
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
restarted = self.service.start(vm.id)
self.assertEqual((restarted.cpu, restarted.storage_mib), (2, MIN_STORAGE_MIB))
self.assertIsNone(restarted.pending_resize)
self.assertEqual(self.provision.call_count, 1)
def test_failed_boot_retains_applied_resources_for_a_later_start(self) -> None:
vm = self.create_vm()
self.host.failures["client.configure", vm.id] = FirecrackerError("boot failed")
with self.assertRaisesRegex(UvmError, "boot failed"):
self.service.resize(vm.id, cpu=2, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (2, 50 * 1024))
self.assertIsNone(retained.pending_resize)
self.assertTrue(retained.disk_ready)
restarted = self.service.start(vm.id)
self.assertEqual(restarted.status, "running")
self.resize_disk.assert_called_once_with(Path(vm.disk), 50 * 1024)
def test_legacy_disk_gets_minimum_capacity_on_its_first_resource_resize(self) -> None:
vm = self.create_vm()
self.persist(vm, storage_mib=None)
resized = self.service.resize(vm.id, cpu=2)
self.assertEqual(resized.storage_mib, MIN_STORAGE_MIB)
self.resize_disk.assert_called_once_with(Path(vm.disk), MIN_STORAGE_MIB)
def test_retry_can_change_target_after_disk_publication_before_metadata_save(self) -> None:
for recorded_gib, published_gib in ((30, 50), (50, 30)):
with self.subTest(recorded=recorded_gib, published=published_gib):
vm = self.create_vm(storage_mib=recorded_gib * 1024)
self.service.stop(vm.id)
self.persist(vm, operation="resize", pending_resize=VmResources(2, 4096, published_gib * 1024))
self.disk_sizes[Path(vm.disk)] = published_gib * 1024**3
self.resize_disk.reset_mock()
result = self.service.resize(vm.id, storage_mib=recorded_gib * 1024)
self.resize_disk.assert_called_once_with(Path(vm.disk), recorded_gib * 1024)
self.assertEqual(result.storage_mib * 1024 * 1024, self.disk_sizes[Path(vm.disk)])
self.assertIsNone(result.pending_resize)
def test_interrupted_resize_can_be_stopped_then_resumed_or_deleted(self) -> None:
vm = self.create_vm()
self.host.failures["images.resize_disk", vm.id] = KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
self.service.resize(vm.id, storage_mib=50 * 1024)
stopped = self.service.stop(vm.id)
self.assertEqual(stopped.pending_resize.storage_mib, 50 * 1024)
self.assertEqual(stopped.operation, "resize")
self.service.destroy(vm.id)
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(Path(vm.disk).exists())
+28 -223
View File
@@ -7,16 +7,12 @@ import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import patch
from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
from uvm.errors import (
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
NotFoundError, UvmError, ValidationError,
)
from uvm.guest_exec import ExecResult
from uvm.domain import PortRule, VmRecord, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
@@ -26,7 +22,6 @@ def fake_application(*, api_token: str | None = None):
app_name="uvm",
default_vcpu=1,
default_ram_mib=512,
default_storage_mib=30 * 1024,
default_ssh_user="root",
api_token=api_token,
api_tls_cert=None,
@@ -103,31 +98,19 @@ class ServerCliTests(unittest.TestCase):
application = fake_application(api_token="test-token")
application.settings.api_tls_cert = certificate
application.settings.api_tls_key = key
api = SimpleNamespace(state=SimpleNamespace())
server = SimpleNamespace(run=Mock())
config = object()
fake_uvicorn = SimpleNamespace(
Config=Mock(return_value=config),
Server=Mock(return_value=server),
)
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
with (
patch("uvm.server._validate_server_settings"),
patch("uvm.server.create_api", return_value=api),
patch("uvm.server.create_api", return_value=object()),
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
patch.object(fake_uvicorn, "run") as run_mock,
):
run_server(application, host="0.0.0.0", port=8443)
fake_uvicorn.Config.assert_called_once_with(
api,
host="0.0.0.0",
port=8443,
ssl_certfile=str(certificate),
ssl_keyfile=str(key),
)
fake_uvicorn.Server.assert_called_once_with(config)
self.assertIs(api.state.uvicorn_server, server)
server.run.assert_called_once_with()
run_mock.assert_called_once()
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
for token in ("s\u00e9cret", "contains space", ""):
@@ -145,8 +128,6 @@ class ServerErrorTests(unittest.TestCase):
(ConfigurationError("invalid settings"), 422),
(UvmError("this operation needs root"), 403),
(UvmError("host operation failed"), 500),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost connection", outcome_unknown=True), 502),
):
with self.subTest(error=error):
self.assertEqual(_http_status_for(error), expected)
@@ -164,8 +145,6 @@ if FASTAPI_AVAILABLE:
class FakeLifecycle:
def __init__(self) -> None:
self.last_spec: VmSpec | None = None
self.last_resize: dict | None = None
self.last_execution: dict | None = None
self.observed_status = "running"
self.policy_status = "enforced"
self.vm = VmRecord(
@@ -173,7 +152,6 @@ class FakeLifecycle:
name="test-vm",
cpu=1,
ram_mib=512,
storage_mib=30 * 1024,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
@@ -209,7 +187,6 @@ class FakeLifecycle:
self.last_spec = spec
self.vm.cpu = spec.cpu
self.vm.ram_mib = spec.ram_mib
self.vm.storage_mib = spec.storage_mib
if spec.guest_ip is not None:
self.vm.guest_ip = str(spec.guest_ip)
self.vm.username = spec.username
@@ -245,16 +222,6 @@ class FakeLifecycle:
self.policy_status = "inactive"
return self.vm
def resize(self, identifier: str, *, cpu=None, ram_mib=None, storage_mib=None, timeout=None) -> VmRecord:
self.inspect(identifier)
self.last_resize = dict(cpu=cpu, ram_mib=ram_mib, storage_mib=storage_mib, timeout=timeout)
for key, value in (("cpu", cpu), ("ram_mib", ram_mib), ("storage_mib", storage_mib)):
if value is not None:
setattr(self.vm, key, value)
self.vm.pending_resize = None
self.vm.operation = None
return self.start(identifier)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
return self.vm
@@ -263,19 +230,6 @@ class FakeLifecycle:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def execute(self, vm_id: str, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
self.last_execution = dict(vm_id=vm_id, command=command, sudo=sudo, timeout_seconds=timeout_seconds)
return ExecResult(7, "hello\n", "problem\n", False, False)
def install_guest_agent(self, vm_id: str) -> VmRecord:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
if self.vm.status != "stopped":
raise ConflictError("stop the VM before installing the guest agent")
return self.vm
def diagnostics(self) -> list[dict]:
return [{"name": "kvm", "ok": True, "message": "available"}]
@@ -332,8 +286,7 @@ class ApiRouterTests(unittest.TestCase):
)
application.state_store = SimpleNamespace(initialize=lambda: None)
self.headers = {"X-UVM-Token": "test-token"}
self.api = create_api(application, host="127.0.0.1")
self.client = TestClient(self.api)
self.client = TestClient(create_api(application, host="127.0.0.1"))
self.addCleanup(self.client.close)
def test_health_is_available_without_credentials(self) -> None:
@@ -342,10 +295,20 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def test_standalone_uvm_api_does_not_serve_the_nest_dashboard(self) -> None:
for path in ("/", "/dashboard", "/dashboard/static/js/dashboard.js"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 404)
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
root = self.client.get("/", follow_redirects=False)
dashboard = self.client.get("/dashboard")
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
script = self.client.get("/dashboard/static/js/dashboard.js")
self.assertEqual(root.status_code, 307)
self.assertEqual(root.headers["location"], "/dashboard")
self.assertEqual(dashboard.status_code, 200)
self.assertIn("UVM Control", dashboard.text)
self.assertEqual(stylesheet.status_code, 200)
self.assertIn("--acid", stylesheet.text)
self.assertEqual(script.status_code, 200)
self.assertIn("sessionStorage", script.text)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
for method, path, payload in (
@@ -356,15 +319,11 @@ class ApiRouterTests(unittest.TestCase):
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
("POST", "/vms/test-vm/start", {}),
("POST", "/vms/test-vm/restart", {}),
("POST", "/vms/test-vm/resize", {"cpu": 2}),
("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("POST", "/vms/vm-test/exec", {"command": "id"}),
("POST", "/vms/vm-test/agent/install", {}),
("GET", "/diagnostics", {}),
("POST", "/exit", {}),
("POST", "/install", {}),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
@@ -379,24 +338,6 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(authorized.json()[0]["id"], "vm-test")
self.assertNotIn("password", authorized.json()[0])
def test_exit_requests_a_graceful_authenticated_server_shutdown(self) -> None:
server = SimpleNamespace(should_exit=False)
self.api.state.uvicorn_server = server
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"status": "stopping"})
self.assertTrue(server.should_exit)
def test_exit_requires_a_running_uvicorn_server(self) -> None:
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json(), {
"detail": "server shutdown is unavailable outside nest --serve or uvm --serve",
})
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
created = self.client.post(
"/vms",
@@ -435,68 +376,6 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(self.lifecycle.last_spec.password, "root")
self.assertIsNone(self.lifecycle.last_spec.name)
self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),))
self.assertEqual(self.lifecycle.last_spec.storage_mib, 30 * 1024)
self.assertEqual(response.json()["storage_mib"], 30 * 1024)
def test_create_accepts_custom_storage_and_rejects_capacities_below_minimum(self) -> None:
for capacity, expected in ((50, 50 * 1024), ("50G", 50 * 1024), ("1T", 1024 * 1024)):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(self.lifecycle.last_spec.storage_mib, expected)
self.assertEqual(response.json()["storage_mib"], expected)
self.lifecycle.last_spec = None
for capacity in ("29G", 29, "30M", "bad", True, None, [], {}):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_spec)
def test_resize_accepts_partial_resources_and_returns_the_same_vm_identity(self) -> None:
before = self.lifecycle.vm.to_dict()
response = self.client.post("/vms/test-vm/resize?timeout=0.5", headers=self.headers,
json={"cpu": 2, "ram": "4G", "storage": "50G"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.lifecycle.last_resize, {"cpu": 2, "ram_mib": 4096, "storage_mib": 51200, "timeout": 0.5})
for field in ("id", "name", "guest_ip", "gateway", "mac", "username"):
self.assertEqual(response.json()[field], before[field])
self.assertEqual(response.json()["status"], "running")
self.assertEqual(response.json()["policy_status"], "enforced")
self.assertEqual(response.json()["storage_mib"], 51200)
self.assertIsNone(response.json()["pending_resize"])
self.assertNotIn("password", response.json())
self.assertEqual(self.lifecycle.vm.password, before["password"])
partial = self.client.post("/vms/10.42.0.2/resize", headers=self.headers, json={"ram": "2G"})
self.assertEqual(partial.status_code, 200)
self.assertEqual(self.lifecycle.last_resize, {"cpu": None, "ram_mib": 2048, "storage_mib": None, "timeout": None})
self.assertEqual(partial.json()["storage_mib"], 51200)
def test_resize_rejects_invalid_or_non_resource_edits_before_lifecycle_calls(self) -> None:
for payload in ({}, {"cpu": None}, {"cpu": 0}, {"cpu": True}, {"ram": "127M"},
{"storage": "29G"}, {"storage": False}, {"storage": []},
{"cpu": 2, "username": "new-user"}, {"cpu": 2, "password": "new-secret"},
{"cpu": 2, "guest_ip": "10.42.0.9"}):
with self.subTest(payload=payload):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_resize)
for timeout in ("-1", "nan", "inf"):
response = self.client.post(f"/vms/vm-test/resize?timeout={timeout}", headers=self.headers, json={"cpu": 2})
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.lifecycle.last_resize)
def test_resize_errors_and_pending_resources_are_exposed_without_credentials(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("unknown process"), 409),
(UvmError("filesystem does not fit"), 500)):
with patch.object(self.lifecycle, "resize", side_effect=error):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json={"storage": "30G"})
self.assertEqual(response.status_code, expected)
self.assertEqual(response.json(), {"error": {"message": str(error)}})
target = VmResources(2, 4096, 51200)
self.lifecycle.vm.pending_resize = target
response = self.client.get("/vms/vm-test", headers=self.headers)
self.assertEqual(response.json()["pending_resize"], target.to_dict())
self.assertNotIn("guest-secret", response.text)
def test_create_accepts_valid_names(self) -> None:
for name in (None, "a", "web-1", "a" * 63):
@@ -575,7 +454,7 @@ class ApiRouterTests(unittest.TestCase):
def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None:
expected_fields = {
"id", "name", "cpu", "ram_mib", "storage_mib", "pending_resize", "guest_ip", "gateway", "mac", "username",
"id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username",
"status", "observed_status", "ports", "policy_status", "operation", "pid",
"created_at", "updated_at", "last_error",
}
@@ -677,74 +556,6 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(sync.json()["id"], "vm-test")
self.assertEqual(sync.json()["policy_status"], "inactive")
def test_exec_preserves_script_text_privileges_and_nonzero_exit_results(self) -> None:
command = "printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7"
response = self.client.post("/vms/vm-test/exec", headers=self.headers,
json={"command": command, "sudo": True, "timeout_seconds": 12.5})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json(), {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
self.assertEqual(self.lifecycle.last_execution, {
"vm_id": "vm-test", "command": command, "sudo": True, "timeout_seconds": 12.5,
})
self.assertNotIn("guest-secret", response.text)
default = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(default.status_code, 200)
self.assertEqual(self.lifecycle.last_execution["sudo"], False)
self.assertEqual(self.lifecycle.last_execution["timeout_seconds"], 60)
def test_exec_rejects_malformed_requests_before_calling_the_service(self) -> None:
for payload in (
{}, {"command": ""}, {"command": " \n"}, {"command": "a\0b"}, {"command": 42},
{"command": "x" * 65537}, {"command": "\u00e9" * 40000},
{"command": "id", "sudo": "false"}, {"command": "id", "sudo": 1},
{"command": "id", "timeout_seconds": True}, {"command": "id", "timeout_seconds": "10"},
{"command": "id", "timeout_seconds": 0}, {"command": "id", "timeout_seconds": 3601},
{"command": "id", "username": "root"}, {"command": "id", "host_path": "/etc/passwd"},
):
with self.subTest(payload=str(payload)[:100]):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_exec_errors_distinguish_vm_state_agent_readiness_and_unknown_outcomes(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("stopped"), 409),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost response", outcome_unknown=True), 502)):
with self.subTest(error=error), patch.object(self.lifecycle, "execute", side_effect=error):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(response.status_code, expected)
if isinstance(error, GuestExecutionError):
self.assertTrue(response.json()["error"]["outcome_unknown"])
with patch.object(self.lifecycle, "execute", return_value=ExecResult(None, "partial", "", True, True)):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "sleep 10"})
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["exit_code"])
self.assertTrue(response.json()["timed_out"])
self.assertTrue(response.json()["truncated"])
def test_exec_rejects_nonfinite_and_invalid_unicode_json_without_serialization_errors(self) -> None:
for body in ('{"command":"id","timeout_seconds":NaN}',
'{"command":"id","timeout_seconds":Infinity}',
'{"command":"\\ud800"}', '{"command":"id","sudo":NaN}'):
with self.subTest(body=body):
response = self.client.post("/vms/vm-test/exec", headers={**self.headers, "Content-Type": "application/json"},
content=body)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_agent_install_requires_stopping_and_uses_the_existing_vm_disk(self) -> None:
blocked = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(blocked.status_code, 409)
self.client.post("/vms/vm-test/stop", headers=self.headers)
with patch.object(self.lifecycle, "install_guest_agent", wraps=self.lifecycle.install_guest_agent) as install:
response = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"id": "vm-test", "status": "installed", "restart_required": True})
install.assert_called_once_with("vm-test")
self.assertEqual(self.lifecycle.vm.status, "stopped")
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
with (
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
@@ -855,14 +666,15 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
def test_api_schema_exposes_management_operations(self) -> None:
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(
{(path, method) for path, methods in schema["paths"].items() for method in methods},
{
("/", "get"),
("/dashboard", "get"),
("/health", "get"),
("/exit", "post"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),
@@ -871,17 +683,10 @@ class ApiRouterTests(unittest.TestCase):
("/vms/{identifier}/logs", "get"),
("/vms/{vm_id}/start", "post"),
("/vms/{vm_id}/restart", "post"),
("/vms/{vm_id}/resize", "post"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/vms/{vm_id}/exec", "post"),
("/vms/{vm_id}/agent/install", "post"),
("/tools", "get"),
("/vms/{vm_id}/tools", "get"),
("/vms/{vm_id}/tools/install", "post"),
("/vms/{vm_id}/tools/jobs/{job_id}", "get"),
("/diagnostics", "get"),
},
)
-502
View File
@@ -1,502 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from nest.server import create_app
from nest.session_store import SessionStore
from nest.sessions import SessionManager
from tests.test_server import FASTAPI_AVAILABLE, fake_application
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, StateError, ValidationError
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
class FakeTerminal:
def __init__(self) -> None:
self.events = asyncio.Queue()
self.events.put_nowait({"type": "output", "data": b"guest$ "})
self.input = []
self.sizes = []
self.closed = False
self.end_calls = 0
async def read(self):
event = await self.events.get()
if isinstance(event, Exception):
raise event
return event
async def write(self, data):
self.input.append(data)
await self.events.put({"type": "output", "data": data + b"\r\nguest$ "})
async def resize(self, rows, cols):
self.sizes.append((rows, cols))
async def end(self):
self.end_calls += 1
await self.events.put({"type": "exit", "exit_code": 0})
def close(self):
self.closed = True
def session_application(base):
application = fake_application(api_token="test-token")
application.settings.base = Path(base)
application.terminals = []
application.vm_ids = {"vm-test", "vm-other"}
def get(vm_id):
if vm_id not in application.vm_ids:
raise NotFoundError("VM not found")
return SimpleNamespace(id=vm_id)
def open_terminal(vm_id, **_size):
get(vm_id)
terminal = FakeTerminal()
application.terminals.append(terminal)
return terminal, "worker"
application.state_store = SimpleNamespace(get=get)
application.lifecycle = SimpleNamespace(open_terminal=open_terminal)
return application
class SessionStoreTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.path = Path(temporary.name) / "nest" / "sessions.sqlite3"
self.store = SessionStore(self.path, max_output=8)
self.addCleanup(self.store.close)
def test_history_is_scoped_by_exact_vm_id_and_survives_reopening(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
self.store.append(record["id"], b"hello")
self.store.finish(record["id"], "closed", 7)
self.assertEqual(self.store.list("vm-b"), [])
with self.assertRaises(NotFoundError):
self.store.get("vm-b", record["id"])
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
saved, start, output = reopened.read("vm-a", record["id"], 0)
self.assertEqual((saved["status"], saved["exit_code"], saved["username"], start, output), ("closed", 7, "worker", 0, b"hello"))
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700)
def test_restarted_server_marks_only_live_sessions_interrupted(self):
live = self.store.create("vm-a", 24, 80)
ended = self.store.create("vm-a", 24, 80)
self.store.activate(live["id"], "worker")
self.store.finish(ended["id"], "closed", 0)
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.get("vm-a", live["id"])["status"], "interrupted")
self.assertIsNotNone(reopened.get("vm-a", live["id"])["ended_at"])
self.assertEqual(reopened.get("vm-a", ended["id"])["status"], "closed")
def test_transcript_is_bounded_and_offsets_support_exact_replay(self):
record = self.store.create("vm-a", 24, 80)
self.store.append(record["id"], b"abcdefghij")
self.store.append(record["id"], b"kl")
saved, start, output = self.store.read("vm-a", record["id"], 0)
self.assertEqual((start, output, saved["output_end"], saved["truncated"]), (4, b"efghijkl", 12, True))
self.assertEqual(self.store.read("vm-a", record["id"], 9)[1:], (9, b"jkl"))
self.assertEqual(self.store.read("vm-a", record["id"], 12)[2], b"")
def test_second_server_cannot_reconcile_another_servers_live_sessions(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
with self.assertRaises(ConflictError):
SessionStore(self.path)
self.assertEqual(self.store.get("vm-a", record["id"])["status"], "active")
def test_symlinked_history_file_is_not_opened_or_rewritten(self):
self.store.close()
self.path.unlink()
target = self.path.parent / "unrelated"
target.write_bytes(b"keep this")
self.path.symlink_to(target)
with self.assertRaises(OSError):
SessionStore(self.path)
self.assertEqual(target.read_bytes(), b"keep this")
def test_rename_preserves_session_identity_state_and_output_across_reopening(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
self.store.append(record["id"], b"history")
before = self.store.get("vm-a", record["id"])
renamed = self.store.rename("vm-a", record["id"], " Déploiement 'alpha' ")
self.assertEqual(renamed["name"], "Déploiement 'alpha'")
for key in before.keys() - {"name", "updated_at"}:
self.assertEqual(renamed[key], before[key])
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
saved, _start, output = reopened.read("vm-a", record["id"], 0)
self.assertEqual(saved["name"], renamed["name"])
self.assertEqual(output, b"history")
def test_invalid_session_names_do_not_change_the_record(self):
record = self.store.create("vm-a", 24, 80)
for name in ("", " ", "a" * 81, "two\nlines", "bad\x00name", "\ud800", 123, None):
with self.subTest(name=repr(name)), self.assertRaises(ValidationError):
self.store.rename("vm-a", record["id"], name)
self.assertEqual(self.store.get("vm-a", record["id"]), record)
def test_rename_and_delete_cannot_cross_vm_boundaries(self):
record = self.store.create("vm-a", 24, 80)
self.store.finish(record["id"], "closed", 0)
for operation in (lambda: self.store.rename("vm-b", record["id"], "other"),
lambda: self.store.delete("vm-b", record["id"])):
with self.assertRaises(NotFoundError):
operation()
self.assertEqual(self.store.get("vm-a", record["id"])["name"], record["name"])
def test_delete_removes_metadata_and_transcript_and_keeps_other_sessions(self):
first = self.store.create("vm-a", 24, 80)
second = self.store.create("vm-a", 24, 80)
self.store.append(first["id"], b"remove")
self.store.append(second["id"], b"keep")
self.store.finish(first["id"], "closed", 0)
self.store.delete("vm-a", first["id"])
with self.assertRaises(NotFoundError):
self.store.read("vm-a", first["id"], 0)
with sqlite3.connect(self.path) as db:
self.assertEqual(db.execute("SELECT COUNT(*) FROM output WHERE session_id=?", (first["id"],)).fetchone()[0], 0)
self.assertEqual(self.store.read("vm-a", second["id"], 0)[2], b"keep")
def test_failed_delete_rolls_back_metadata_and_output_together(self):
record = self.store.create("vm-a", 24, 80)
self.store.append(record["id"], b"keep")
self.store.finish(record["id"], "closed", 0)
with sqlite3.connect(self.path) as db:
db.execute("CREATE TRIGGER reject_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'test failure'); END")
with self.assertRaises(StateError):
self.store.delete("vm-a", record["id"])
self.assertEqual(self.store.read("vm-a", record["id"], 0)[2], b"keep")
def test_running_history_requires_closure_and_default_names_avoid_existing_labels(self):
records = [self.store.create("vm-a", 24, 80) for _ in range(3)]
with self.assertRaises(ConflictError):
self.store.delete("vm-a", records[1]["id"])
self.store.finish(records[1]["id"], "closed", 0)
self.store.delete("vm-a", records[1]["id"])
created = self.store.create("vm-a", 24, 80)
self.assertNotIn(created["name"], {records[0]["name"], records[2]["name"]})
class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.application = session_application(self.temporary.name)
self.manager = SessionManager(self.application)
self.addAsyncCleanup(self.manager.shutdown)
async def test_failed_launch_is_saved_in_history(self):
def unavailable(*_args, **_kwargs):
raise GuestAgentUnavailableError("update the guest agent")
self.application.lifecycle.open_terminal = unavailable
with self.assertRaises(GuestAgentUnavailableError):
await self.manager.create("vm-test", 24, 80)
history = await self.manager.list("vm-test")
self.assertEqual(history[0]["status"], "failed")
self.assertEqual(history[0]["error"], "update the guest agent")
async def test_shutdown_closes_live_channels_and_saves_interrupted_history(self):
record = await self.manager.create("vm-test", 24, 80)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
store = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3")
self.addCleanup(store.close)
self.assertEqual(store.get("vm-test", record["id"])["status"], "interrupted")
async def test_cancelling_the_create_request_does_not_orphan_the_shell(self):
import threading
entered, release = threading.Event(), threading.Event()
connect = self.application.lifecycle.open_terminal
def slow_connect(*args, **kwargs):
entered.set()
release.wait(2)
return connect(*args, **kwargs)
self.application.lifecycle.open_terminal = slow_connect
request = asyncio.create_task(self.manager.create("vm-test", 24, 80))
await asyncio.to_thread(entered.wait, 2)
request.cancel()
release.set()
await asyncio.gather(request, return_exceptions=True)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
async def test_guest_loss_ends_the_session_and_retains_output(self):
record = await self.manager.create("vm-test", 24, 80)
terminal = self.application.terminals[0]
await terminal.events.put(GuestExecutionError("VM connection lost", outcome_unknown=True))
async with asyncio.timeout(2):
while (await self.manager.get("vm-test", record["id"]))["status"] == "active":
await asyncio.sleep(0.01)
saved, _start, output = await self.manager.read("vm-test", record["id"], 0)
self.assertEqual(saved["status"], "interrupted")
self.assertEqual(output, b"guest$ ")
self.assertTrue(terminal.closed)
async def test_shutdown_finishes_inflight_output_before_releasing_history_ownership(self):
import threading
entered, release = threading.Event(), threading.Event()
history = self.manager._history()
append = history.append
def slow_append(*args):
entered.set()
release.wait(2)
append(*args)
with patch.object(history, "append", side_effect=slow_append):
record = await self.manager.create("vm-test", 24, 80)
self.assertTrue(await asyncio.to_thread(entered.wait, 2))
closing = asyncio.create_task(self.manager.shutdown())
try:
await asyncio.sleep(0.03)
self.assertFalse(closing.done())
with self.assertRaises(ConflictError):
SessionStore(history.path)
finally:
release.set()
await closing
reopened = SessionStore(history.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ")
async def test_rename_keeps_the_same_live_terminal(self):
record = await self.manager.create("vm-test", 24, 80)
terminal = self.application.terminals[0]
renamed = await self.manager.rename("vm-test", record["id"], "Deployment")
self.assertEqual((renamed["id"], renamed["status"], renamed["name"]), (record["id"], "active", "Deployment"))
await self.manager.write("vm-test", record["id"], b"pwd\r")
self.assertEqual(terminal.input, [b"pwd\r"])
self.assertFalse(terminal.closed)
self.assertEqual(len(self.application.terminals), 1)
async def test_delete_closes_only_its_live_terminal_and_removes_all_history(self):
record = await self.manager.create("vm-test", 24, 80)
other = await self.manager.create("vm-test", 24, 80)
await self.manager.write("vm-test", record["id"], b"last output\r")
await self.manager.delete("vm-test", record["id"])
self.assertTrue(self.application.terminals[0].closed)
self.assertFalse(self.application.terminals[1].closed)
self.assertEqual([item["id"] for item in await self.manager.list("vm-test")], [other["id"]])
with sqlite3.connect(self.manager._history().path) as db:
self.assertEqual(db.execute("SELECT COUNT(*) FROM output WHERE session_id=?", (record["id"],)).fetchone()[0], 0)
async def test_delete_waits_for_an_inflight_launch_before_closing_its_channel(self):
import threading
entered, release = threading.Event(), threading.Event()
connect = self.application.lifecycle.open_terminal
def slow_connect(*args, **kwargs):
entered.set()
release.wait(2)
return connect(*args, **kwargs)
self.application.lifecycle.open_terminal = slow_connect
creating = asyncio.create_task(self.manager.create("vm-test", 24, 80))
self.assertTrue(await asyncio.to_thread(entered.wait, 2))
record = (await self.manager.list("vm-test"))[0]
deleting = asyncio.create_task(self.manager.delete("vm-test", record["id"]))
try:
await asyncio.sleep(0.03)
self.assertFalse(deleting.done())
finally:
release.set()
await asyncio.gather(creating, deleting)
self.assertTrue(self.application.terminals[0].closed)
self.assertEqual(await self.manager.list("vm-test"), [])
async def test_cancelled_delete_request_finishes_cleanup_even_during_shutdown(self):
record = await self.manager.create("vm-test", 24, 80)
terminal = self.application.terminals[0]
entered, release = asyncio.Event(), asyncio.Event()
end = terminal.end
async def slow_end():
entered.set()
await release.wait()
await end()
terminal.end = slow_end
request = asyncio.create_task(self.manager.delete("vm-test", record["id"]))
await asyncio.wait_for(entered.wait(), timeout=2)
request.cancel()
await asyncio.gather(request, return_exceptions=True)
release.set()
await self.manager.shutdown()
reopened = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3")
self.addCleanup(reopened.close)
self.assertTrue(terminal.closed)
self.assertEqual(reopened.list("vm-test"), [])
async def test_close_and_delete_are_serialized_for_the_same_session(self):
record = await self.manager.create("vm-test", 24, 80)
results = await asyncio.gather(self.manager.end("vm-test", record["id"]),
self.manager.delete("vm-test", record["id"]), return_exceptions=True)
self.assertTrue(all(not isinstance(result, Exception) or isinstance(result, NotFoundError) for result in results))
self.assertTrue(self.application.terminals[0].closed)
self.assertEqual(self.application.terminals[0].end_calls, 1)
self.assertEqual(await self.manager.list("vm-test"), [])
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
class SessionApiTests(unittest.TestCase):
def setUp(self):
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
self.application = session_application(self.temporary)
self.api = create_app(self.application, host="127.0.0.1")
self.client = self.enterContext(TestClient(self.api))
self.headers = {"X-UVM-Token": "test-token"}
self.base = "/vms/vm-test/sessions"
def create(self):
response = self.client.post(self.base, headers=self.headers, json={})
self.assertEqual(response.status_code, 201, response.text)
return response.json()
def connect(self, record, *, vm_id="vm-test", offset=0):
ws = self.enterContext(self.client.websocket_connect(f"/vms/{vm_id}/sessions/{record['id']}/terminal"))
ws.send_json({"type": "auth", "token": "test-token", "offset": offset})
return ws
def output(self, ws):
while True:
message = ws.receive_json()
if message["type"] == "output":
data = base64.b64decode(message["data"])
end = message["offset"] + len(data)
ws.send_json({"type": "ack", "offset": end})
return data, end
self.assertEqual(message["type"], "session", message)
def test_all_http_operations_and_websocket_output_require_authentication(self):
for method, path in (("GET", self.base), ("POST", self.base),
("GET", self.base + "/session-unknown"), ("POST", self.base + "/session-unknown/close"),
("PATCH", self.base + "/session-unknown"), ("DELETE", self.base + "/session-unknown")):
self.assertEqual(self.client.request(method, path, json={}).status_code, 401)
for token in ("wrong", "", "\u00e9"):
with self.client.websocket_connect(self.base + "/session-unknown/terminal") as ws:
ws.send_json({"type": "auth", "token": token})
with self.assertRaises(WebSocketDisconnect) as raised:
ws.receive_json()
self.assertEqual(raised.exception.code, 4401)
self.assertEqual(self.application.terminals, [])
self.assertFalse((Path(self.temporary) / "nest").exists())
def test_foreign_browser_origin_is_rejected(self):
with self.assertRaises(WebSocketDisconnect):
with self.client.websocket_connect(self.base + "/unknown/terminal", headers={"Origin": "https://foreign.example"}):
self.fail("foreign origin accepted")
def test_create_rejects_unknown_vm_aliases_and_invalid_sizes(self):
for body in ({"rows": 0}, {"cols": True}, {"cols": 401}, {"command": "id"}):
self.assertEqual(self.client.post(self.base, headers=self.headers, json=body).status_code, 422)
self.assertEqual(self.client.post("/vms/name-alias/sessions", headers=self.headers, json={}).status_code, 404)
self.assertEqual(self.application.terminals, [])
def test_sessions_are_nested_under_the_selected_vm(self):
record = self.create()
self.assertEqual(record["username"], "worker")
other = "/vms/vm-other/sessions"
self.assertEqual(self.client.get(other, headers=self.headers).json(), [])
self.assertEqual(self.client.get(other + "/" + record["id"], headers=self.headers).status_code, 404)
self.assertEqual(self.client.post(other + "/" + record["id"] + "/close", headers=self.headers).status_code, 404)
self.assertEqual(self.client.patch(other + "/" + record["id"], headers=self.headers, json={"name": "Other"}).status_code, 404)
self.assertEqual(self.client.delete(other + "/" + record["id"], headers=self.headers).status_code, 404)
ws = self.connect(record, vm_id="vm-other")
self.assertEqual(ws.receive_json()["type"], "error")
self.assertFalse(self.application.terminals[0].closed)
def test_disconnect_reconnect_input_resize_and_closed_history(self):
record = self.create()
ws = self.connect(record)
output, offset = self.output(ws)
self.assertEqual(output, b"guest$ ")
ws.send_json({"type": "resize", "rows": 35, "cols": 120})
ws.send_json({"type": "input", "data": base64.b64encode(b"pwd\r").decode()})
output, offset = self.output(ws)
self.assertEqual(output, b"pwd\r\r\nguest$ ")
self.assertEqual(self.application.terminals[0].sizes, [(35, 120)])
ws.close()
self.assertFalse(self.application.terminals[0].closed)
reopened = self.connect(record, offset=offset)
self.assertEqual(reopened.receive_json()["session"]["status"], "active")
self.assertEqual(len(self.application.terminals), 1)
closed = self.client.post(self.base + "/" + record["id"] + "/close", headers=self.headers)
self.assertEqual(closed.json()["status"], "closed")
self.assertEqual(closed.json()["exit_code"], 0)
replay = self.connect(record)
transcript, _end = self.output(replay)
self.assertEqual(transcript, b"guest$ pwd\r\r\nguest$ ")
self.assertEqual(self.client.get(self.base, headers=self.headers).json()[0]["status"], "closed")
self.assertTrue(self.application.terminals[0].closed)
def test_malformed_input_ends_only_the_browser_attachment(self):
record = self.create()
ws = self.connect(record)
self.output(ws)
ws.send_json({"type": "input", "data": "not base64!"})
while (message := ws.receive_json())["type"] == "session":
pass
self.assertEqual(message["type"], "error")
self.assertEqual(self.application.terminals[0].input, [])
self.assertFalse(self.application.terminals[0].closed)
self.assertEqual(self.client.get(self.base + "/" + record["id"], headers=self.headers).json()["status"], "active")
def test_rename_is_validated_persisted_and_sent_to_attached_terminals(self):
record = self.create()
url = self.base + "/" + record["id"]
for body in ({}, {"name": " "}, {"name": 123}, {"name": "x" * 81},
{"name": "two\nlines"}, {"name": "bad\x00name"}, {"name": "New", "vm_id": "vm-other"}):
with self.subTest(body=body):
self.assertEqual(self.client.patch(url, headers=self.headers, json=body).status_code, 422)
ws = self.connect(record)
self.output(ws)
response = self.client.patch(url, headers=self.headers, json={"name": " Déploiement "})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["name"], "Déploiement")
while (message := ws.receive_json()).get("session", {}).get("name") != "Déploiement":
self.assertEqual(message["type"], "session")
self.assertEqual(message["session"]["status"], "active")
self.assertEqual(self.client.get(url, headers=self.headers).json()["name"], "Déploiement")
self.assertFalse(self.application.terminals[0].closed)
def test_delete_live_session_returns_no_content_and_notifies_attached_browser(self):
record = self.create()
ws = self.connect(record)
self.output(ws)
url = self.base + "/" + record["id"]
response = self.client.delete(url, headers=self.headers)
self.assertEqual(response.status_code, 204, response.text)
self.assertEqual(response.content, b"")
while (message := ws.receive_json())["type"] == "session":
pass
self.assertEqual(message, {"type": "deleted", "session_id": record["id"]})
self.assertTrue(self.application.terminals[0].closed)
self.assertEqual(self.client.get(url, headers=self.headers).status_code, 404)
self.assertEqual(self.client.get(self.base, headers=self.headers).json(), [])
+11 -26
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmResources, VmSpec, new_vm_id
from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmSpec, new_vm_id
from uvm.errors import ConflictError, NotFoundError, StateError
from uvm.state import StateStore
@@ -61,13 +61,13 @@ class StateStoreTests(unittest.TestCase):
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
self.base.mkdir(parents=True)
for version in (None, 1, 2):
for version in (None, 1):
with self.subTest(version=version):
raw_vm = make_vm().to_dict()
for field in (
"disk", "username", "password", "updated_at", "name", "ports",
"disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id",
"spawn_pending", "network", "bridge", "storage_mib", "pending_resize",
"spawn_pending", "network", "bridge",
):
raw_vm.pop(field)
legacy = {"vms": {"vm-test": raw_vm}}
@@ -77,7 +77,7 @@ class StateStoreTests(unittest.TestCase):
loaded = self.store.load()
vm = loaded.vms["vm-test"]
self.assertEqual(loaded.schema_version, 3)
self.assertEqual(loaded.schema_version, 2)
self.assertEqual(vm.disk, "")
self.assertEqual(vm.username, "root")
self.assertIsNone(vm.password)
@@ -85,8 +85,6 @@ class StateStoreTests(unittest.TestCase):
self.assertIsNone(vm.ports)
self.assertIsNone(vm.network)
self.assertIsNone(vm.bridge)
self.assertIsNone(vm.storage_mib)
self.assertIsNone(vm.pending_resize)
self.assertFalse(vm.disk_ready)
self.assertTrue(vm.runtime_owned)
self.assertFalse(vm.tap_owned)
@@ -101,7 +99,7 @@ class StateStoreTests(unittest.TestCase):
state.vms["vm-test"].status = "stopped"
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(persisted["schema_version"], 3)
self.assertEqual(persisted["schema_version"], 2)
self.assertEqual(persisted["next_mac_index"], 2)
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
@@ -116,17 +114,16 @@ class StateStoreTests(unittest.TestCase):
runtime_owned=True, tap_owned=True, operation="configuring",
boot_id="host-boot-id", spawn_pending=True, pid=123,
process_start_time="456789", network="10.42.0.0/24", bridge="uvm0",
last_error="interrupted boot", storage_mib=30 * 1024,
pending_resize=VmResources(cpu=2, ram_mib=4096, storage_mib=50 * 1024),
last_error="interrupted boot",
)
with self.store.transaction() as state:
state.vms[vm.id] = vm
loaded = self.store.load()
self.assertEqual(loaded.schema_version, 3)
self.assertEqual(loaded.schema_version, 2)
self.assertEqual(loaded.vms[vm.id], vm)
raw = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(raw["schema_version"], 3)
self.assertEqual(raw["schema_version"], 2)
self.assertEqual(
raw["vms"][vm.id]["ports"],
None if ports is None else [port.to_dict() for port in ports],
@@ -154,7 +151,7 @@ class StateStoreTests(unittest.TestCase):
def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None:
self.base.mkdir(parents=True)
for version in (0, 1, 2, 3):
for version in (0, 1, 2):
with self.subTest(version=version):
raw = {
"schema_version": version,
@@ -305,23 +302,11 @@ class DomainStateTests(unittest.TestCase):
self.assertIs(vm.spawn_pending, operation == "spawning")
self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm)
def test_new_state_uses_schema_three_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 3)
def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 2)
with self.assertRaisesRegex(StateError, "newer than"):
State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}})
def test_storage_and_pending_resize_reject_malformed_persisted_values(self) -> None:
raw = make_vm().to_dict()
for value in (0, 1024, True, "30720", 30720.0, -1, {}, [], 2**63):
with self.subTest(storage=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "storage_mib": value})
valid = {"cpu": 2, "ram_mib": 4096, "storage_mib": 50 * 1024}
for value in ({}, [], "resize", {**valid, "cpu": True}, {**valid, "cpu": float("nan")},
{**valid, "ram_mib": 127}, {**valid, "storage_mib": 29 * 1024},
{**valid, "password": "not-a-resource"}):
with self.subTest(pending=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "pending_resize": value})
def test_generated_ids_have_the_reserved_uuid_format(self) -> None:
first, second = new_vm_id(), new_vm_id()
self.assertNotEqual(first, second)
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
import hashlib
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import MIN_STORAGE_MIB, Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import ImageStore
from uvm.system import CommandResult, CommandRunner
MIB = 1024 * 1024
class DiskResizeTests(unittest.TestCase):
def test_grow_extends_the_file_first_and_shrink_truncates_only_after_verified_resize(self) -> None:
for original_mib, target_mib in ((8, 16), (32, 16)):
with self.subTest(original=original_mib), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(original_mib * MIB)
sizes = []
def run(command, **_options):
sizes.append((command[0], disk.stat().st_size))
return CommandResult(args=tuple(map(str, command)), returncode=1 if command[0] == "e2fsck" else 0)
runner = Mock(spec=CommandRunner)
runner.run.side_effect = run
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=target_mib * MIB):
store._resize_filesystem(disk, target_mib)
self.assertEqual(sizes, [("e2fsck", original_mib * MIB), ("resize2fs", max(original_mib, target_mib) * MIB)])
self.assertEqual(disk.stat().st_size, target_mib * MIB)
def test_failed_or_interrupted_staging_never_changes_the_original_disk(self) -> None:
for failure in (UvmError("filesystem cannot fit"), KeyboardInterrupt()):
with self.subTest(failure=type(failure)), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"data, password hash, and SSH host keys")
disk.chmod(0o600)
before = (disk.stat().st_ino, disk.read_bytes())
store = ImageStore(Settings(base=Path(temporary)), Mock(spec=CommandRunner))
def fail(candidate, _capacity):
self.assertNotEqual(candidate, disk)
candidate.write_bytes(b"partly resized staging image")
raise failure
with (
patch.object(store, "_copy_sparse", side_effect=shutil.copyfile),
patch.object(store, "_resize_filesystem", side_effect=fail),
self.assertRaises(type(failure)),
):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
self.assertEqual((disk.stat().st_ino, disk.read_bytes()), before)
self.assertEqual(list(disk.parent.iterdir()), [disk])
def test_filesystem_check_errors_prevent_resize_and_truncation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"original")
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("e2fsck",), 4, stderr="unrepaired errors")
store = ImageStore(Settings(), runner)
with self.assertRaisesRegex(UvmError, "filesystem check failed"):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.read_bytes(), b"original")
self.assertEqual(runner.run.call_count, 1)
def test_geometry_mismatch_prevents_truncating_a_larger_filesystem(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(32 * MIB)
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("tool",), 0)
store = ImageStore(Settings(), runner)
with (
patch.object(store, "_filesystem_size", return_value=32 * MIB),
self.assertRaisesRegex(UvmError, "does not match"),
):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.stat().st_size, 32 * MIB)
def test_retry_after_disk_publication_recognizes_the_completed_capacity(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(MIN_STORAGE_MIB * MIB)
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=MIN_STORAGE_MIB * MIB):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
runner.run.assert_not_called()
def test_resize_rejects_subminimum_capacity_and_shared_or_linked_disks(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"template")
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = Path(temporary) / "linked.ext4"
linked.hardlink_to(disk)
symlink = Path(temporary) / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
with self.assertRaises(ValidationError):
store.resize_vm_disk(disk, MIN_STORAGE_MIB - 1)
for target in (settings.rootfs_image, linked, symlink):
with self.subTest(target=target), self.assertRaisesRegex(UvmError, "unsafe"):
store.resize_vm_disk(target, MIN_STORAGE_MIB)
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "resize2fs", "dumpe2fs", "debugfs", "cp")),
"e2fsprogs and coreutils are required for offline disk checks")
class OfflineExt4ResizeTests(unittest.TestCase):
def test_real_sparse_disk_grows_and_shrinks_with_guest_data_and_keys_intact(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "template.ext4"
root = base / "guest"
root.mkdir()
contents = {
"data": "retained application data\n",
"shadow": "root:$6$unchanged-password-hash:1:0:99999:7:::\n",
"host-key": "unchanged-private-host-key\n",
}
for name, value in contents.items():
(root / name).write_text(value, encoding="utf-8")
with source.open("wb") as image:
image.truncate(32 * MIB)
runner = CommandRunner(emit=None)
runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-N", "128", "-d", root, source), capture=True)
template_digest = hashlib.sha256(source.read_bytes()).hexdigest()
store = ImageStore(Settings(base=base), runner)
disk = store.create_vm_disk(source, base / "rootfs.ext4")
for capacity in (MIN_STORAGE_MIB, 31 * 1024, MIN_STORAGE_MIB):
store.resize_vm_disk(disk, capacity)
self.assertEqual(disk.stat().st_size, capacity * MIB)
self.assertEqual(store._filesystem_size(disk), capacity * MIB)
self.assertEqual(disk.stat().st_mode & 0o777, 0o600)
self.assertLess(disk.stat().st_blocks * 512, disk.stat().st_size // 2)
runner.run(("e2fsck", "-f", "-n", disk), capture=True)
for name, value in contents.items():
result = runner.run(("debugfs", "-R", f"cat /{name}", disk), capture=True)
self.assertEqual(result.stdout, value)
self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), template_digest)
-113
View File
@@ -1,113 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from tests.test_server import FASTAPI_AVAILABLE, FakeLifecycle
from uvm.config import Settings
from uvm.server import create_api
from uvm.state import StateStore
from uvm.tools import ToolService
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
class ToolApiTests(unittest.TestCase):
def setUp(self):
temporary = self.enterContext(tempfile.TemporaryDirectory())
settings = replace(Settings(), base=Path(temporary), api_token="test-token")
state = StateStore(settings)
self.lifecycle = FakeLifecycle()
self.lifecycle.vm.runtime_owned = True
settings.vm_dir(self.lifecycle.vm.id).mkdir(parents=True, mode=0o700)
with state.transaction() as document:
document.vms[self.lifecycle.vm.id] = self.lifecycle.vm
self.tools = ToolService(settings, state, self.lifecycle)
self.tools.start = Mock()
self.tools.shutdown = Mock()
self.tools.notify = Mock()
self.application = SimpleNamespace(settings=settings, state_store=state, lifecycle=self.lifecycle, tools=self.tools)
self.api = create_api(self.application, host="127.0.0.1")
self.client = TestClient(self.api)
self.addCleanup(self.client.close)
self.headers = {"X-UVM-Token": "test-token"}
def job(self):
with self.application.state_store.operation_lock():
return self.tools.store.enqueue_locked("vm-test", [{"id": "git"}], request_id="request-1")
def test_catalog_and_job_routes_require_the_management_token(self):
for method, path in (("GET", "/tools"), ("GET", "/vms/vm-test/tools"),
("POST", "/vms/vm-test/tools/install"),
("GET", "/vms/vm-test/tools/jobs/tools-missing")):
with self.subTest(path=path):
response = self.client.request(method, path, json={"tools": [{"id": "git"}]})
self.assertEqual(response.status_code, 401)
self.tools.start.assert_not_called()
def test_catalog_has_python_options_and_create_passes_normalized_selections(self):
result = self.client.get("/tools", headers=self.headers)
self.assertEqual(result.status_code, 200)
python = next(item for item in result.json() if item["id"] == "python")
self.assertEqual(python["versions"], ["3.11", "3.12", "3.13"])
created = self.client.post("/vms", headers=self.headers, json={"tools": [
{"id": "python", "version": "3.12"}, {"id": "git"}, {"id": "git"},
]})
self.assertEqual(created.status_code, 201, created.text)
self.assertEqual([item.key for item in self.lifecycle.last_spec.tools], ["git", "python-3.12"])
self.tools.start.assert_called_once()
self.tools.notify.assert_called_once()
self.assertNotIn("password", created.json())
def test_invalid_selections_and_options_are_rejected_before_submission(self):
self.tools.submit = Mock()
invalid = [
{"tools": []}, {"tools": None}, {"tools": ["git"]}, {"tools": [{"id": "unknown"}]},
{"tools": [{"id": "python", "version": "3.14"}]}, {"tools": [{"id": "python"}]},
{"tools": [{"id": "git", "script": "id"}]}, {"tools": [{"id": "git"}], "start_if_stopped": "true"},
{"tools": [{"id": "git"}], "restart": 1}, {"tools": [{"id": "git"}], "request_id": "../../x"},
{"tools": [{"id": "git"}], "sudo": False},
]
for body in invalid:
with self.subTest(body=body):
response = self.client.post("/vms/vm-test/tools/install", headers=self.headers, json=body)
self.assertEqual(response.status_code, 422, response.text)
self.tools.submit.assert_not_called()
for body in ({"tools": None}, {"tools": [{"id": "not-in-catalog"}]}):
response = self.client.post("/vms", headers=self.headers, json=body)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.lifecycle.last_spec)
def test_install_returns_accepted_job_and_whitelists_its_response(self):
job = self.job()
job.update(password="secret", host_path="/host/private")
self.tools.submit = Mock(return_value=job)
result = self.client.post("/vms/vm-test/tools/install", headers=self.headers,
json={"tools": [{"id": "git"}], "start_if_stopped": True, "request_id": "request-1"})
self.assertEqual(result.status_code, 202, result.text)
self.assertEqual(result.json()["id"], job["id"])
self.assertNotIn("password", result.json())
self.assertNotIn("host_path", result.json())
self.assertTrue(self.tools.submit.call_args.kwargs["start_if_stopped"])
self.assertEqual(self.tools.submit.call_args.args[0], "vm-test")
def test_job_history_remains_exact_vm_scoped(self):
job = self.job()
result = self.client.get(f"/vms/vm-test/tools/jobs/{job['id']}", headers=self.headers)
self.assertEqual(result.status_code, 200, result.text)
self.assertEqual(result.json()["items"][0]["status"], "queued")
for identifier in ("test-vm", "10.42.0.2", "vm-other"):
response = self.client.get(f"/vms/{identifier}/tools/jobs/{job['id']}", headers=self.headers)
self.assertEqual(response.status_code, 404)
def test_server_lifespan_starts_and_stops_workers(self):
with TestClient(self.api) as client:
self.tools.start.assert_called_once()
self.assertEqual(client.get("/tools", headers=self.headers).status_code, 200)
self.tools.shutdown.assert_called_once()
-162
View File
@@ -1,162 +0,0 @@
from __future__ import annotations
import ast
import hashlib
import io
import shlex
import tarfile
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from uvm import guest_tools
from uvm.errors import ValidationError
from uvm.tool_catalog import CATALOG, ToolSelection, all_selections, normalize_tools, recipe
from uvm.tools import guest_command
class ToolCatalogTests(unittest.TestCase):
def test_requested_catalog_and_every_guest_payload_fit_the_agent_protocol(self):
self.assertEqual({tool["id"] for tool in CATALOG},
{"git", "nodejs", "python", "jdk", "wget", "curl", "htop", "php", "conda", "micromamba"})
for arch in ("x86_64", "aarch64"):
recipes = [recipe(item, arch) for item in all_selections()]
command = guest_command({"action": "probe", "tools": recipes, "username": "worker", "architecture": arch})
self.assertLess(len(command.encode()), 65536)
argv = shlex.split(command)
self.assertEqual(argv[:3], ["/usr/bin/python3", "-I", "-c"])
compile(argv[3], "guest-tools", "exec")
ast.parse(argv[3], feature_version=(3, 10))
for item in recipes:
if "url" in item:
self.assertTrue(item["url"].startswith("https://"))
self.assertNotIn("/latest", item["url"])
self.assertRegex(item["sha256"], r"^[0-9a-f]{64}$")
self.assertEqual(len(all_selections()), 12)
def test_selection_validation_is_allowlisted_strict_and_deduplicated(self):
expected = (ToolSelection("git"), ToolSelection("python", "3.12"))
self.assertEqual(normalize_tools([{"id": "python", "version": "3.12"}, {"id": "git"}, {"id": "git"}]), expected)
for value in (None, "git", [], ["git"], [{"id": "git; id"}], [{"id": "python"}],
[{"id": "python", "version": "3.14"}], [{"id": "git", "version": "latest"}],
[{"id": "git", "command": "id"}], [{"id": ["git"]}], [{"id": "git"}] * 13):
with self.subTest(value=value), self.assertRaises(ValidationError):
normalize_tools(value)
self.assertEqual(normalize_tools([], allow_empty=True), ())
def test_versioned_python_commands_and_jdk_compiler_are_verified(self):
for version in ("3.11", "3.12", "3.13"):
item = recipe(ToolSelection("python", version), "aarch64")
self.assertEqual(set(item["links"]), {"python" + version})
self.assertIn("venv", item["checks"][0][-1])
self.assertEqual(item["checks"][1][:3], ["python" + version, "-m", "pip"])
jdk = recipe(ToolSelection("jdk"), "aarch64")
self.assertEqual(jdk["checks"], [["javac", "-version"], ["java", "-version"]])
self.assertIn("arm64", jdk["java_home"])
class GuestToolRecipeTests(unittest.TestCase):
def setUp(self):
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
self.root = Path(self.temporary)
def test_download_mismatch_removes_artifact_and_https_redirect_is_checked(self):
target = self.root / "download"
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "https://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
with self.assertRaisesRegex(guest_tools.ToolError, "checksum"):
guest_tools.download("https://publisher.test/tool", target, "0" * 64)
self.assertFalse(target.exists())
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "https://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
guest_tools.download("https://publisher.test/tool", target, hashlib.sha256(b"publisher payload").hexdigest())
self.assertEqual(target.read_bytes(), b"publisher payload")
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
guest_tools.download("http://publisher.test/tool", target)
response = io.BytesIO(b"publisher payload")
response.geturl = lambda: "http://publisher.test/tool"
with patch.object(guest_tools.urllib.request, "urlopen", return_value=response):
with self.assertRaisesRegex(guest_tools.ToolError, "HTTPS"):
guest_tools.download("https://publisher.test/tool", target)
def test_archive_traversal_links_and_devices_cannot_escape_staging(self):
cases = [("../outside", tarfile.REGTYPE, ""), ("/outside", tarfile.REGTYPE, ""),
("link", tarfile.SYMTYPE, "../../outside"), ("link", tarfile.LNKTYPE, "../outside"),
("device", tarfile.CHRTYPE, "")]
for name, kind, link in cases:
with self.subTest(name=name, kind=kind):
archive = self.root / "archive.tar"
with tarfile.open(archive, "w") as stream:
item = tarfile.TarInfo(name)
item.type = kind
item.linkname = link
stream.addfile(item)
output = self.root / "output"
output.mkdir(exist_ok=True)
with self.assertRaises(guest_tools.ToolError):
guest_tools.extract_archive(archive, output)
self.assertFalse((self.root / "outside").exists())
def test_internal_archive_symlinks_are_supported(self):
archive = self.root / "archive.tar"
with tarfile.open(archive, "w") as stream:
item = tarfile.TarInfo("python/bin/python3.12")
item.size = 4
item.mode = 0o755
stream.addfile(item, io.BytesIO(b"test"))
link = tarfile.TarInfo("python/bin/python3")
link.type = tarfile.SYMTYPE
link.linkname = "python3.12"
stream.addfile(link)
output = self.root / "output"
output.mkdir()
guest_tools.extract_archive(archive, output)
self.assertEqual((output / "python/bin/python3").read_bytes(), b"test")
def test_probe_uses_guest_account_and_rejects_wrong_runtime_or_missing_companion(self):
node = recipe(ToolSelection("nodejs"), "x86_64")
account = SimpleNamespace(pw_dir="/home/worker", pw_uid=1001, pw_gid=1002)
with (patch.object(guest_tools.pwd, "getpwnam", return_value=account),
patch.object(guest_tools.os, "getgrouplist", return_value=[1002, 1003]),
patch.object(guest_tools.shutil, "which", return_value="/usr/local/bin/node"),
patch.object(guest_tools.subprocess, "run") as run):
run.return_value = SimpleNamespace(returncode=0, stdout=b"v22.10.0\n")
result = guest_tools.probe(node, "worker")
self.assertFalse(result["installed"])
self.assertEqual(result["detected_version"], "22.10.0")
self.assertEqual(run.call_args.kwargs["user"], 1001)
self.assertEqual(run.call_args.kwargs["env"]["HOME"], "/home/worker")
run.side_effect = [SimpleNamespace(returncode=0, stdout=b"v24.21.0\n"),
SimpleNamespace(returncode=1, stdout=b"broken npm")]
self.assertFalse(guest_tools.probe(node, "worker")["installed"])
def test_managed_files_preserve_edits_and_can_finish_interrupted_publication(self):
installer = guest_tools.Installer("root")
path = self.root / "profile"
# Isolated directory fixtures stand in for the root-owned guest hierarchy.
with (patch.object(guest_tools, "ROOT", self.root),
patch.object(guest_tools, "safe_directory"),
patch.object(guest_tools, "regular_file")):
installer.write_managed(path, b"initial")
installer.write_managed(path, b"updated")
self.assertEqual(path.read_bytes(), b"updated")
path.write_bytes(b"guest edit")
with self.assertRaisesRegex(guest_tools.ToolError, "customized"):
installer.write_managed(path, b"next")
self.assertEqual(path.read_bytes(), b"guest edit")
path.write_bytes(b"updated")
installer.remember(path, hashlib.sha256(b"next").hexdigest())
installer.write_managed(path, b"next")
self.assertEqual(path.read_bytes(), b"next")
def test_log_capture_retains_a_bounded_tail(self):
installer = guest_tools.Installer("root")
installer.append(b"a" * 20000)
installer.append(b"last failure")
self.assertEqual(len(installer.log), guest_tools.LOG_LIMIT)
self.assertTrue(installer.truncated)
self.assertTrue(installer.log.endswith(b"last failure"))
-73
View File
@@ -1,73 +0,0 @@
from __future__ import annotations
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from tests.test_server import FASTAPI_AVAILABLE
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
from tests.browser_tool_server import fixture_application
from uvm.server import create_api
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
class ToolFlowTests(unittest.TestCase):
def setUp(self):
temporary = self.enterContext(tempfile.TemporaryDirectory())
self.application = fixture_application(Path(temporary), delay=0.01)
self.enterContext(patch("uvm.tools.require_root"))
self.headers = {"X-UVM-Token": "browser-test-token"}
def wait_for(self, condition):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if value := condition():
return value
time.sleep(0.01)
self.fail("background tool worker did not reach the expected state")
def test_create_and_later_install_use_real_workers_and_durable_job_responses(self):
with TestClient(create_api(self.application, host="127.0.0.1")) as client:
response = client.post("/vms", headers=self.headers, json={"name": "tools-vm", "tools": [
{"id": "git"}, {"id": "python", "version": "3.12"},
]})
self.assertEqual(response.status_code, 201, response.text)
vm_id = response.json()["id"]
self.wait_for(lambda: self.application.tools.store.read(vm_id)["jobs"][-1]["status"] == "succeeded")
snapshot = client.get(f"/vms/{vm_id}/tools?refresh=true", headers=self.headers)
self.assertEqual(snapshot.status_code, 200, snapshot.text)
installed = {item["key"] for item in snapshot.json()["inventory"] if item["installed"]}
self.assertEqual(installed, {"git", "python-3.12", "curl"})
self.assertEqual(snapshot.json()["latest_job"]["source"], "create")
later = client.post("/vms/vm-other/tools/install", headers=self.headers,
json={"tools": [{"id": "htop"}], "start_if_stopped": True})
self.assertEqual(later.status_code, 202, later.text)
job_id = later.json()["id"]
self.wait_for(lambda: self.application.tools.store.get_job("vm-other", job_id)["status"] == "succeeded")
self.assertEqual(self.application.state_store.get("vm-other").status, "running")
self.assertEqual(client.get(f"/vms/{vm_id}/tools/jobs/{job_id}", headers=self.headers).status_code, 404)
def test_queued_jobs_are_dispatched_on_server_start(self):
with self.application.state_store.operation_lock():
job = self.application.tools.store.enqueue_locked("vm-test", [{"id": "git"}])
with TestClient(create_api(self.application, host="127.0.0.1")):
self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job["id"])["status"] == "succeeded")
self.assertEqual(self.application.lifecycle.attempts[("vm-test", "git")], 1)
def test_shutdown_wakes_command_waiter_and_keeps_an_interrupted_job(self):
self.application.lifecycle.delay = 30
with TestClient(create_api(self.application, host="127.0.0.1")) as client:
response = client.post("/vms/vm-test/tools/install", headers=self.headers, json={"tools": [{"id": "git"}]})
job_id = response.json()["id"]
self.wait_for(lambda: self.application.tools.store.get_job("vm-test", job_id)["status"] == "installing"
and bool(self.application.tools._sessions))
started = time.monotonic()
self.assertLess(time.monotonic() - started, 3)
job = self.application.tools.store.get_job("vm-test", job_id)
self.assertEqual(job["status"], "interrupted")
self.assertTrue(job["outcome_unknown"])
self.assertTrue(all(not thread.is_alive() for thread in self.application.tools._threads))
-149
View File
@@ -1,149 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock, patch
from tests.test_server import FakeLifecycle
from uvm.config import Settings
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError
from uvm.state import StateStore
from uvm.tool_catalog import ToolSelection
from uvm.tool_store import ToolJobStore
from uvm.tools import ToolService
class ToolJobTests(unittest.TestCase):
def setUp(self):
temporary = self.enterContext(tempfile.TemporaryDirectory())
self.settings = replace(Settings(), base=Path(temporary), tool_workers=1)
self.state = StateStore(self.settings)
self.vm = replace(FakeLifecycle().vm, runtime_owned=True, pid=123, process_start_time="1", boot_id="host-boot")
self.settings.vm_dir(self.vm.id).mkdir(parents=True, mode=0o700)
with self.state.transaction() as state:
state.vms[self.vm.id] = self.vm
self.store = ToolJobStore(self.settings, self.state)
def enqueue(self, selections=None, **options):
with self.state.operation_lock():
return self.store.enqueue_locked(self.vm.id, selections or [ToolSelection("git")], **options)
def test_duplicate_concurrent_submissions_have_one_durable_job(self):
with ThreadPoolExecutor(max_workers=4) as executor:
jobs = list(executor.map(lambda _: self.enqueue(request_id="request-1"), range(4)))
self.assertEqual(len({job["id"] for job in jobs}), 1)
self.assertEqual(len(self.store.read(self.vm.id)["jobs"]), 1)
path = self.settings.vm_dir(self.vm.id) / "tools.json"
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotIn("guest-secret", path.read_text())
with self.assertRaises(ConflictError):
self.enqueue([ToolSelection("htop")])
def test_history_is_vm_scoped_and_deletion_is_never_undone(self):
job = self.enqueue()
with self.assertRaises(NotFoundError):
self.store.get_job(self.vm.name, job["id"])
with self.state.transaction() as state:
del state.vms[self.vm.id]
with self.assertRaises(NotFoundError):
self.store.update_job(self.vm.id, job["id"], lambda current, data: current.update(status="succeeded"))
self.assertNotIn(self.vm.id, self.state.load().vms)
def test_recovery_preserves_queued_work_and_marks_inflight_outcomes_unknown(self):
job = self.enqueue()
self.store.recover()
self.assertEqual(self.store.get_job(self.vm.id, job["id"])["status"], "queued")
self.store.claim()
self.store.update_job(self.vm.id, job["id"], lambda current, data: current["items"][0].update(status="installing"))
self.store.recover()
recovered = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(recovered["status"], "interrupted")
self.assertTrue(recovered["outcome_unknown"])
self.assertEqual(recovered["items"][0]["status"], "interrupted")
self.assertIsNone(self.store.claim())
def test_symlinks_and_foreign_history_are_rejected(self):
outside = self.settings.base / "outside"
outside.write_text("private data")
path = self.settings.vm_dir(self.vm.id) / "tools.json"
path.symlink_to(outside)
with self.assertRaises((StateError, OSError)):
self.store.read(self.vm.id)
self.assertEqual(outside.read_text(), "private data")
path.unlink()
self.enqueue()
data = json.loads(path.read_text())
data["jobs"][0]["vm_id"] = "vm-other"
path.write_text(json.dumps(data))
with self.assertRaises(StateError):
self.store.read(self.vm.id)
def test_only_one_worker_can_own_this_registry_and_shutdown_releases_it(self):
first = ToolService(self.settings, self.state, Mock())
second = ToolService(self.settings, self.state, Mock())
self.addCleanup(first.shutdown)
self.addCleanup(second.shutdown)
first.start()
with self.assertRaises(ConflictError):
second.start()
first.shutdown()
second.start()
def make_service(self):
lifecycle = Mock()
lifecycle.prepare_tool_install.return_value = self.vm
service = ToolService(self.settings, self.state, lifecycle)
return service
@staticmethod
def observation(selection, installed=False):
return {**selection.to_dict(), "key": selection.key, "installed": installed,
"detected_version": "2.0.0" if installed else None, "usage": "tool --version", "error": None}
def test_worker_reports_partial_failure_and_retains_successful_tools(self):
selections = [ToolSelection("git"), ToolSelection("htop")]
job = self.enqueue(selections)
claimed = self.store.claim()
service = self.make_service()
results = [
{"ok": True, "busy": False, "boot_id": "guest-boot", "tools": [self.observation(item) for item in selections]},
{"ok": True, "status": "installed", "tool": self.observation(selections[0], True), "output": "git installed"},
{"ok": False, "error": "mirror unavailable", "output": "apt download failed"},
]
with patch.object(service, "_execute", side_effect=results) as execute:
service._run_job(claimed)
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(result["status"], "failed")
self.assertEqual([item["status"] for item in result["items"]], ["installed", "failed"])
self.assertEqual(result["items"][1]["output"], "apt download failed")
self.assertEqual(self.state.get(self.vm.id).status, "running")
self.assertEqual(execute.call_args.args[1]["boot_id"], "guest-boot")
def test_lost_result_is_never_replayed_and_remaining_tools_are_skipped(self):
selections = [ToolSelection("git"), ToolSelection("htop")]
job = self.enqueue(selections)
service = self.make_service()
with patch.object(service, "_execute", side_effect=[
{"ok": True, "busy": False, "boot_id": "boot", "tools": [self.observation(item) for item in selections]},
GuestExecutionError("connection lost", outcome_unknown=True),
]) as execute:
service._run_job(self.store.claim())
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(execute.call_count, 2)
self.assertEqual(result["status"], "interrupted")
self.assertTrue(result["outcome_unknown"])
self.assertEqual([item["status"] for item in result["items"]], ["interrupted", "skipped"])
def test_prior_guest_installation_blocks_a_retry_before_submission(self):
job = self.enqueue()
service = self.make_service()
with patch.object(service, "_execute", return_value={"ok": True, "busy": True}) as execute:
service._run_job(self.store.claim())
self.assertEqual(execute.call_count, 1)
result = self.store.get_job(self.vm.id, job["id"])
self.assertEqual(result["status"], "interrupted")
self.assertFalse(result["outcome_unknown"])
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase
from uvm.errors import ConflictError, NotFoundError, UvmError, ValidationError
from uvm.tool_catalog import ToolSelection
class ToolLifecycleTests(LifecycleTestCase):
def test_create_persists_tool_intent_before_boot(self):
def configure(config):
vm_id = next(iter(self.store.load().vms))
jobs = self.service._tool_store._read(vm_id)["jobs"]
self.assertEqual(jobs[0]["selections"], [{"id": "python", "version": "3.12"}])
self.assertEqual(jobs[0]["status"], "queued")
original = self.service._client_factory
def client(socket, timeout):
result = original(socket, timeout)
result.configure_and_start = configure
return result
self.service._client_factory = client
vm = self.create_vm(tools=(ToolSelection("python", "3.12"),))
self.assertEqual(vm.status, "running")
self.assertEqual(self.service._tool_store.read(vm.id)["jobs"][0]["source"], "create")
def test_invalid_tools_fail_before_reserving_a_vm_and_failed_boot_removes_intent(self):
with self.assertRaises(ValidationError):
self.create_vm(tools=({"id": "invalid"},))
self.assertEqual(self.store.load().vms, {})
self.host.failures["client.configure", None] = UvmError("failed boot")
with self.assertRaises(UvmError):
self.create_vm(tools=(ToolSelection("git"),))
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_stopped_installation_starts_only_when_requested_and_aliases_are_rejected(self):
vm = self.create_vm(name="worker")
self.service.stop(vm.id)
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.queue_tools(alias, [ToolSelection("git")], start_if_stopped=True)
with self.assertRaises(ConflictError):
self.service.queue_tools(vm.id, [ToolSelection("git")])
job = self.service.queue_tools(vm.id, [ToolSelection("git")], start_if_stopped=True)
self.assertEqual(self.store.get(vm.id).status, "stopped")
ready = self.service.prepare_tool_install(vm.id, start_if_stopped=job["start_if_stopped"])
self.assertEqual(ready.status, "running")
self.assertEqual(ready.guest_ip, vm.guest_ip)
def test_a_tool_step_cannot_reconnect_to_a_restarted_vm(self):
vm = self.create_vm()
identity = vm.pid, vm.process_start_time, vm.boot_id
self.service.restart(vm.id)
self.service._guest_connector = Mock()
with self.assertRaisesRegex(ConflictError, "restarted"):
self.service.execute(vm.id, "id", expected_identity=identity)
self.service._guest_connector.assert_not_called()
+1 -16
View File
@@ -5,26 +5,11 @@ import unittest
from uvm.domain import PortRule
from uvm.errors import ValidationError
from uvm.validation import (
parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_storage, parse_timeout, parse_username,
parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_timeout, parse_username,
)
class ValidationTests(unittest.TestCase):
def test_storage_defaults_to_gib_and_supports_larger_and_fractional_units(self) -> None:
for value, expected in (
("30", 30720), ("30G", 30720), ("30GB", 30720), ("30GiB", 30720),
(" 50 g ", 51200), ("30720M", 30720), ("30.5G", 31232),
("1T", 1048576), ("0.5TiB", 524288), ("30.0001G", 30721),
):
with self.subTest(value=value):
self.assertEqual(parse_storage(value), expected)
def test_storage_rejects_subminimum_invalid_and_unrepresentable_capacity(self) -> None:
for value in ("0", "29G", "29.999999G", "30719M", "30M", "", "-30G", "nan",
"inf", "True", "30G garbage", "100000000000000000000000T"):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_storage(value)
def test_parse_ram_uses_mib_by_default(self) -> None:
self.assertEqual(parse_ram("512"), 512)
self.assertEqual(parse_ram("1G"), 1024)
+1 -149
View File
@@ -5,23 +5,15 @@ from __future__ import annotations
from ipaddress import IPv4Address
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from .domain import VmRecord
from .errors import ValidationError
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
from .guest_exec import validate_exec
from .tool_catalog import MAX_SELECTIONS, ToolSelection
class HealthResponse(BaseModel):
status: str = "ok"
class ServerExitResponse(BaseModel):
status: str = "stopping"
class InstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -43,47 +35,17 @@ class PortRuleModel(BaseModel):
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
class ToolSelectionModel(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(strict=True, min_length=1, max_length=32)
version: str | None = Field(default=None, strict=True, max_length=16,
description="Required for Python: 3.11, 3.12, or 3.13. Omit for other tools.")
@model_validator(mode="after")
def validate_selection(self):
ToolSelection(self.id, self.version)
return self
class VmCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None
username: str = "root"
password: SecretStr = SecretStr("root")
name: str | None = None
ports: list[PortRuleModel] | None = None
no_default_ports: bool = Field(default=False, strict=True)
tools: list[ToolSelectionModel] = Field(default_factory=list, max_length=MAX_SELECTIONS,
description="Optional guest tools to install asynchronously after boot")
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel):
@@ -91,8 +53,6 @@ class VmResponse(BaseModel):
name: str | None
cpu: float
ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str
gateway: str
mac: str
@@ -131,112 +91,6 @@ class VmLogResponse(BaseModel):
truncated: bool
class VmExecRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command: str = Field(strict=True, min_length=1, max_length=MAX_COMMAND_BYTES, repr=False,
description="Command or multiline script interpreted by /bin/sh inside the guest; at most 64 KiB UTF-8")
sudo: bool = Field(default=False, strict=True, description="Run as guest root instead of the VM's configured login user")
timeout_seconds: float = Field(default=DEFAULT_TIMEOUT_SECONDS, strict=True, gt=0,
le=MAX_TIMEOUT_SECONDS, allow_inf_nan=False)
@model_validator(mode="before")
@classmethod
def validate_execution(cls, value: object) -> object:
# Use the domain error envelope: echoing NaN or unpaired surrogates in
# FastAPI's validation-error input can itself fail JSON serialization.
if not isinstance(value, dict) or set(value) - {"command", "sudo", "timeout_seconds"}:
raise ValidationError("execution requires an object with command, sudo, and timeout_seconds fields only")
validate_exec(value.get("command"), value.get("sudo", False), value.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS))
return value
class VmExecResponse(BaseModel):
exit_code: int | None
stdout: str
stderr: str
timed_out: bool
truncated: bool
class VmAgentInstallResponse(BaseModel):
id: str
status: Literal["installed"] = "installed"
restart_required: bool = True
class ToolCatalogResponse(BaseModel):
id: str
name: str
description: str
version_label: str
versions: list[str]
source: str
class ToolInstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tools: list[ToolSelectionModel] = Field(min_length=1, max_length=MAX_SELECTIONS)
start_if_stopped: bool = Field(default=False, strict=True)
restart: bool = Field(default=False, strict=True, description="Restart first to refresh the guest agent")
request_id: str | None = Field(default=None, strict=True, max_length=80, pattern=r"^[A-Za-z0-9_-]{1,80}$",
description="Optional idempotency key, retained with the most recent 10 jobs")
class ToolInventoryResponse(BaseModel):
key: str
id: str
version: str | None
installed: bool
detected_version: str | None
error: str | None
usage: str
class ToolJobItemResponse(BaseModel):
key: str
id: str
version: str | None
status: Literal["queued", "installing", "installed", "already_installed", "failed", "interrupted", "skipped"]
detected_version: str | None
error: str | None
output: str
truncated: bool
class ToolJobSummaryResponse(BaseModel):
id: str
status: Literal["queued", "starting", "waiting", "installing", "succeeded", "failed", "interrupted"]
created_at: float
completed_at: float | None
class ToolJobResponse(ToolJobSummaryResponse):
vm_id: str
source: Literal["create", "user"]
updated_at: float
start_if_stopped: bool
restart: bool
request_id: str | None
error: str | None
outcome_unknown: bool
items: list[ToolJobItemResponse]
class VmToolsResponse(BaseModel):
vm_id: str
inventory: list[ToolInventoryResponse]
checked_at: float | None
agent_ready: bool
guest_busy: bool
supported: bool | None
error: str | None
stale: bool
latest_job: ToolJobResponse | None
jobs: list[ToolJobSummaryResponse]
class DiagnosticResponse(BaseModel):
name: str
ok: bool
@@ -270,8 +124,6 @@ def vm_response(
name=vm.name,
cpu=vm.cpu,
ram_mib=vm.ram_mib,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip,
gateway=vm.gateway,
mac=vm.mac,
+1 -6
View File
@@ -9,12 +9,10 @@ from .config import Settings
from .firecracker.process import FirecrackerProcessManager
from .images import ImageStore
from .install import Installer
from .known_hosts import KnownHostsManager
from .lifecycle import LifecycleService
from .network import NetworkManager
from .state import StateStore
from .system import CommandRunner
from .tools import ToolService
@dataclass(slots=True)
@@ -25,7 +23,6 @@ class Application:
state_store: StateStore
installer: Installer
lifecycle: LifecycleService
tools: ToolService | None = None
def build_application(emit: Callable[[str], None] | None = print) -> Application:
@@ -35,13 +32,11 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
images = ImageStore(settings, runner)
network = NetworkManager(settings, runner)
process = FirecrackerProcessManager(settings)
known_hosts = KnownHostsManager(settings)
lifecycle = LifecycleService(settings, state_store, images, network, process, known_hosts=known_hosts)
lifecycle = LifecycleService(settings, state_store, images, network, process)
installer = Installer(settings, runner, state_store, emit=emit)
return Application(
settings=settings,
state_store=state_store,
installer=installer,
lifecycle=lifecycle,
tools=ToolService(settings, state_store, lifecycle),
)
+8 -47
View File
@@ -1,4 +1,4 @@
"""Command-line interface for UVM's local machine management services."""
"""The sole public interface of this project: the local uvm CLI."""
from __future__ import annotations
@@ -22,7 +22,6 @@ from .validation import (
parse_password,
parse_ports,
parse_ram,
parse_storage,
parse_timeout,
parse_username,
)
@@ -56,7 +55,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
parser.add_argument(
"--serve",
action="store_true",
help="launch the UVM management API; use nest --serve for the workspace dashboard",
help="launch the FastAPI management server instead of running a CLI command",
)
parser.add_argument(
"--host",
@@ -74,9 +73,8 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
install = subcommands.add_parser(
"install",
description=(
"Set UVM_FIRECRACKER_SHA256 and UVM_KERNEL_SHA256 to trusted digests. "
"The default Ubuntu rootfs is built from signed packages. A custom "
"UVM_ROOTFS_URL also requires UVM_ROOTFS_SHA256. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 "
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
"development-only opt-out."
),
help="install dependencies and assets; SHA-256 values are required by default",
@@ -84,7 +82,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
install.add_argument(
"--force",
action="store_true",
help="refresh downloads and rebuild the default Ubuntu rootfs even when assets exist",
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
)
create = subcommands.add_parser("create", help="create and boot a microVM detached")
@@ -99,10 +97,6 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=str(application.settings.default_ram_mib),
help="RAM, e.g. 512, 1G, 512M",
)
create.add_argument(
"--storage", default=f"{application.settings.default_storage_mib}M",
help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)",
)
create.add_argument(
"--guest-ip",
"--host-ip",
@@ -112,7 +106,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
create.add_argument(
"--username",
default=application.settings.default_ssh_user,
help="guest administrator account; created if missing, with sudo access (defaults to root)",
help="existing guest account to configure; defaults to root",
)
credentials = create.add_mutually_exclusive_group()
credentials.add_argument(
@@ -136,14 +130,6 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
)
create.add_argument("--json", action="store_true")
resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM")
resize.add_argument("vm", help="VM ID, name, or guest IP")
resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained")
resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G")
resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB")
resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)")
resize.add_argument("--json", action="store_true")
listing = subcommands.add_parser("list", help="list VMs")
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
output = listing.add_mutually_exclusive_group()
@@ -307,7 +293,6 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
VmSpec(
cpu=parse_cpu(args.cpu),
ram_mib=parse_ram(args.ram),
storage_mib=parse_storage(args.storage),
guest_ip=guest_ip,
username=parse_username(args.username),
password=parse_password(password),
@@ -336,7 +321,6 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" Name: {vm.name}")
print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}")
@@ -345,27 +329,6 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
return 0
if args.command == "resize":
if args.cpu is None and args.ram is None and args.storage is None:
raise ValidationError("resize requires at least one of --cpu, --ram, or --storage")
vm = application.lifecycle.resize(
args.vm,
cpu=parse_cpu(args.cpu) if args.cpu is not None else None,
ram_mib=parse_ram(args.ram) if args.ram is not None else None,
storage_mib=parse_storage(args.storage) if args.storage is not None else None,
timeout=parse_timeout(args.timeout) if args.timeout is not None else None,
)
if args.json:
_print_json(_vm_output(vm, policy_status="enforced"))
else:
print(f"VM resized and started: {vm.id}")
print(f" CPU: {vm.cpu}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" IP: {vm.guest_ip}")
print(f" Username: {vm.username}")
return 0
if args.command == "list":
vms = [
listed for listed in application.lifecycle.list_vms()
@@ -386,14 +349,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
return 0
print(
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'DISK(MiB)':<12} {'STATUS':<12} {'POLICY':<12} PID"
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID"
)
for listed in vms:
vm = listed.vm
pid = vm.pid if vm.pid is not None else ""
print(
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {vm.storage_mib or '-':<12} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
)
return 0
@@ -580,8 +543,6 @@ def _vm_output(
"name": vm.name,
"cpu": vm.cpu,
"ram_mib": vm.ram_mib,
"storage_mib": vm.storage_mib,
"pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None,
"guest_ip": vm.guest_ip,
"gateway": vm.gateway,
"tap": vm.tap,
+13 -51
View File
@@ -2,20 +2,22 @@
from __future__ import annotations
import math
import os
from dataclasses import dataclass, field
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from .errors import ConfigurationError
# Pin published kernels with built-in virtio-rng support. The artifact series
# is independent of the VMM release; the old ARM quickstart kernel lacks this driver.
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15"
MIN_STORAGE_MIB = 30 * 1024
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
DEFAULT_KERNEL_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/kernels/vmlinux.bin"
)
DEFAULT_ROOTFS_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/rootfs/bionic.rootfs.ext4"
)
@dataclass(frozen=True, slots=True)
@@ -27,14 +29,12 @@ class Settings:
network: IPv4Network = IPv4Network("10.42.0.0/24")
gateway: IPv4Address = IPv4Address("10.42.0.1")
bridge: str = "uvm0"
architecture: str = field(default_factory=lambda: os.uname().machine)
firecracker_version: str = "v1.16.1"
default_ram_mib: int = 512
default_storage_mib: int = MIN_STORAGE_MIB
default_vcpu: float = 1
default_ssh_user: str = "root"
kernel_url: str = ""
rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
kernel_url: str = DEFAULT_KERNEL_URL
rootfs_url: str = DEFAULT_ROOTFS_URL
firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None
@@ -43,33 +43,11 @@ class Settings:
api_token: str | None = None
api_tls_cert: Path | None = None
api_tls_key: Path | None = None
known_hosts_files: tuple[Path, ...] | None = None
api_timeout_s: float = 2.0
api_socket_timeout_s: float = 5.0
terminate_timeout_s: float = 5.0
tool_workers: int = 2
tool_ready_timeout_s: float = 120.0
tool_install_timeout_s: float = 1200.0
def __post_init__(self) -> None:
architecture = self.architecture.lower()
architecture = {"amd64": "x86_64", "arm64": "aarch64"}.get(architecture, architecture)
if architecture not in ("x86_64", "aarch64"):
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
object.__setattr__(self, "architecture", architecture)
if type(self.tool_workers) is not int or not 1 <= self.tool_workers <= 8:
raise ConfigurationError("tool_workers must be an integer between 1 and 8")
for name in ("tool_ready_timeout_s", "tool_install_timeout_s"):
value = getattr(self, name)
if type(value) not in (int, float) or not 0 < value <= 3600 or not math.isfinite(value):
raise ConfigurationError(f"{name} must be finite, greater than zero, and at most 3600 seconds")
# Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url:
object.__setattr__(self, "kernel_url", f"{asset_base}/vmlinux-6.1.155")
if self.gateway not in self.network:
raise ConfigurationError(
f"gateway {self.gateway} is outside configured network {self.network}"
@@ -78,8 +56,6 @@ class Settings:
raise ConfigurationError("gateway must be a usable host address")
if len(self.bridge) > 15:
raise ConfigurationError("bridge name must be 15 characters or fewer")
if self.known_hosts_files is not None and any(not path.is_absolute() for path in self.known_hosts_files):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must contain absolute file paths")
if self.api_token is not None:
if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
raise ConfigurationError(
@@ -133,9 +109,6 @@ class Settings:
def vm_dir(self, vm_id: str) -> Path:
return self.vms_dir / vm_id
def guest_socket_path(self, vm_id: str) -> Path:
return self.vm_dir(vm_id) / "vsock.sock"
@classmethod
def from_environment(cls) -> "Settings":
"""Load optional deployment overrides while retaining script defaults."""
@@ -151,8 +124,8 @@ class Settings:
network=network,
gateway=gateway,
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
firecracker_binary_sha256=_optional_environment_value(
"UVM_FIRECRACKER_BINARY_SHA256"
@@ -165,7 +138,6 @@ class Settings:
api_token=_optional_environment_value("UVM_API_TOKEN"),
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
known_hosts_files=_known_hosts_environment_paths(),
)
@@ -179,16 +151,6 @@ def _optional_environment_path(name: str) -> Path | None:
return Path(value).expanduser() if value else None
def _known_hosts_environment_paths() -> tuple[Path, ...] | None:
value = _optional_environment_value("UVM_KNOWN_HOSTS_FILES")
if value is None:
return None
parts = value.split(os.pathsep)
if any(not part for part in parts):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must be a colon-separated list of file paths")
return tuple(Path(part).expanduser() for part in parts)
def _boolean_environment_value(name: str, *, default: bool) -> bool:
value = os.environ.get(name)
if value is None:
+1
View File
@@ -0,0 +1 @@
# UVM Dashboard static assets package.
@@ -469,8 +469,7 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; }
.ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; }
.ssh-command .icon-button { width: 27px; height: 27px; }
.ports-panel, .log-panel, .resources-panel { margin-top: 15px; }
.storage-field { margin-top: 18px; }
.ports-panel, .log-panel { margin-top: 15px; }
.port-editor { padding-top: 5px; }
.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; }
@@ -879,8 +878,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.field input { font-size: 12px; }
.detail-delete-row { align-items: flex-start; }
.detail-delete-row > span { max-width: 170px; }
.ports-panel > .section-heading, .log-panel > .section-heading, .resources-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child, .resources-panel > .section-heading > div:first-child { flex: 1; }
.ports-panel > .section-heading, .log-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child { flex: 1; }
.auth-screen { padding: 20px; }
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
.auth-story { min-height: 240px; padding: 26px; border-right: 0; }
@@ -5,19 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#e8e7ed" />
<meta name="color-scheme" content="light" />
<title>Nest</title>
<meta name="description" content="Nest — a local workspace for your machines, network, and infrastructure." />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 22V10l14 12V10' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
<title>UVM Control</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 11v7a7 7 0 0 0 14 0v-7' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=2" />
<link rel="stylesheet" href="/dashboard/static/css/tools.css?v=1" />
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
<script src="/dashboard/static/js/sessions.js?v=3" defer></script>
<script src="/dashboard/static/js/tools.js?v=1" defer></script>
<script src="/dashboard/static/js/dashboard.js?v=6" defer></script>
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=2" />
<script src="/dashboard/static/js/dashboard.js?v=2" defer></script>
</head>
<body>
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
@@ -55,8 +47,8 @@
<section id="auth-screen" class="auth-screen">
<div class="auth-window">
<div class="auth-story">
<a href="#overview" class="brand" aria-label="Nest workspace"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your workspace.<br />Right at home.</h1><p>A home for your machines,<br />your network, and your next idea.</p></div>
<a href="#overview" class="brand" aria-label="UVM Control"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your compute.<br />A little calmer.</h1><p>A considered workspace for your machines,<br />your network, and everything in between.</p></div>
<div class="auth-sculpture" aria-hidden="true">
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
@@ -64,13 +56,13 @@
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
</div>
<span class="auth-story-footer">Your infrastructure, thoughtfully managed.</span>
<span class="auth-story-footer">MicroVMs, thoughtfully managed.</span>
</div>
<main class="auth-panel">
<div class="auth-panel-inner">
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
<span class="subtle-label">Your local workspace</span>
<h2>Welcome to Nest</h2>
<h2>Welcome to UVM</h2>
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
<form id="auth-form">
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
@@ -88,8 +80,8 @@
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
<div class="sidebar">
<a class="brand" href="#overview" aria-label="Nest workspace overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Workspace</span><span id="sidebar-subtitle">Overview</span></h2></div>
<a class="brand" href="#overview" aria-label="UVM Control overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Management</span><span id="sidebar-subtitle">Overview</span></h2></div>
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
<nav class="primary-nav" aria-label="Primary">
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
@@ -100,7 +92,7 @@
</nav>
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
<div class="sidebar-bottom">
<div class="workspace-identity"><span class="workspace-avatar">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>
<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>
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
</div>
</div>
@@ -129,7 +121,7 @@
<div class="workspace">
<header class="topbar">
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Nest</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Workspace</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
<div class="global-search">
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
@@ -142,7 +134,7 @@
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Nest 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">Workspace overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
<div class="overview-metrics">
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
@@ -169,7 +161,7 @@
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
<div id="network-summary" class="compact-metrics"></div>
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>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>
<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>
</section>
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
@@ -178,75 +170,20 @@
<div id="detail-content" hidden>
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div>
<nav class="machine-tabs" aria-label="Selected machine views">
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
<a id="vm-tools-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-download"/></svg>Tools</a>
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
</nav>
<div id="vm-overview-panel">
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel resources-panel">
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
<p id="resize-note" class="panel-footnote">Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.</p>
<form id="resize-form" hidden>
<div class="form-grid"><label class="field" for="resize-cpu"><span>CPU capacity</span><input id="resize-cpu" type="number" min="0.01" step="any" placeholder="Keep current CPU" /></label><label class="field" for="resize-ram"><span>Memory</span><input id="resize-ram" type="text" placeholder="Keep current memory" /><small>At least 128 MiB. For example, 4G.</small></label></div>
<label class="field storage-field" for="resize-storage"><span>Private storage</span><input id="resize-storage" type="text" placeholder="Keep current size (at least 30G)" /><small>Minimum 30 GiB. A smaller disk must still fit the existing filesystem data.</small></label>
<p id="resize-error" class="form-error" role="alert"></p>
<div class="button-row"><button id="resize-submit" class="button button-primary button-small" type="submit">Resize &amp; restart</button><button class="button button-quiet button-small" type="button" data-action="cancel-resize">Cancel</button></div>
</form>
</article>
<article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article>
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
</div>
<section id="vm-tools-panel" aria-label="Selected machine tools" hidden>
<header class="section-heading tools-section-heading"><div><h2>Tools for your next idea</h2><span>One click. Ready inside this machine.</span></div><div class="button-row"><span id="tools-selected-count" class="muted">0 selected</span><button id="tools-install-selected" class="button button-primary" type="button" disabled>Install selected</button><button id="tools-refresh" class="icon-button" type="button" aria-label="Check installed tools" title="Check installed tools"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header>
<p id="tools-note" class="panel-footnote tools-note" role="status"></p>
<p id="tools-error" class="form-error tools-error" role="alert"></p>
<div id="tools-list" class="tools-grid"></div>
<article id="tools-job" class="soft-panel tool-job" hidden>
<header class="section-heading"><h3 id="tools-job-title">Installation</h3><label class="select-wrap"><span class="sr-only">Installation history</span><select id="tools-job-select"></select></label></header>
<p id="tools-job-message" class="tool-job-message" role="status"></p>
<progress id="tools-progress" class="tool-progress" max="1" value="0" aria-label="Installation progress"></progress>
<ol id="tools-job-items" class="tool-job-items"></ol>
</article>
<div class="button-row tools-recovery"><button id="tools-retry" class="button button-small" type="button" hidden>Retry unfinished tools</button><button id="tools-restart" class="button button-small" type="button" hidden>Restart &amp; install</button></div>
</section>
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
<p id="sessions-error" class="form-error" role="alert"></p>
<div class="session-workspace">
<aside class="session-history soft-panel" aria-label="Session history">
<header><h3>Session history</h3><button id="sessions-refresh" class="icon-button" type="button" aria-label="Refresh sessions"><svg class="icon"><use href="#i-refresh"/></svg></button></header>
<div id="session-list" class="session-list"></div>
<p class="panel-footnote">Saved for this microVM.</p>
</aside>
<article class="terminal-panel">
<header class="terminal-toolbar"><div><span class="terminal-lights" aria-hidden="true"><i></i><i></i><i></i></span><strong id="session-title">Terminal</strong><span id="session-status" class="terminal-status">Select a session</span></div><div class="button-row"><button id="session-reconnect" class="button button-small" type="button" hidden>Reconnect</button><button id="session-rename" class="button button-small" type="button" disabled>Rename</button><button id="session-end" class="button button-small" type="button" disabled>End session</button><button id="session-delete" class="button button-small" type="button" disabled>Delete</button></div></header>
<div id="session-blank" class="terminal-blank"><svg class="icon"><use href="#i-terminal"/></svg><h3>A little room to work.</h3><p>Select a saved session, or launch a fresh terminal<br />inside this microVM.</p><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></div>
<div id="session-terminal" class="session-terminal" aria-label="MicroVM terminal" hidden></div>
<footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer>
</article>
</div>
<details id="terminal-support" class="terminal-support soft-panel">
<summary>Guest terminal support</summary>
<p>Vsock and the guest agent are set up automatically when a microVM is created or started. Restart an older VM once to apply the setup. Manual repair is available here while the machine is stopped.</p>
<button id="session-install-agent" class="button button-small" type="button">Repair / update terminal support</button>
<span id="session-install-hint" class="muted"></span>
</details>
</section>
</div>
</section>
<section id="page-create" class="page" aria-labelledby="create-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div>
<form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section>
<section class="soft-panel form-section create-tools-section"><header class="section-heading"><div><h2><span class="step-number">04</span>Pick your tools</h2><span>Optional. Install automatically after your machine boots.</span></div><button id="create-tools-refresh" class="icon-button" type="button" aria-label="Reload tool catalog"><svg class="icon"><use href="#i-refresh"/></svg></button></header><div id="create-tools-list" class="tool-choices"><span class="muted">Loading available tools…</span></div><p id="create-tools-error" class="form-error" role="alert"></p><p class="panel-footnote">Python versions can coexist. You can add more tools later from the machine’s Tools tab.</p></section></div>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
</form>
</section>
@@ -254,7 +191,7 @@
<section id="page-system" class="page" aria-labelledby="system-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
<div id="health-summary" class="compact-metrics"></div>
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install Firecracker, a guest kernel, and a root filesystem. The default Ubuntu 22.04 image includes SSH, sudo, and apt.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Refresh downloads and rebuild the Ubuntu image.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install the Firecracker binary, guest kernel, and root filesystem, ready for your next machine.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Reinstall with force enabled.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
</section>
</main>
</div>
@@ -263,15 +200,6 @@
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
<dialog id="session-rename-dialog" class="confirm-dialog session-rename-dialog" aria-labelledby="session-rename-title">
<form id="session-rename-form">
<span class="subtle-label">A name for this workspace</span>
<h2 id="session-rename-title">Rename session</h2>
<label class="field" for="session-name-input"><span>Session name</span><input id="session-name-input" type="text" maxlength="80" autocomplete="off" spellcheck="false" required aria-describedby="session-rename-error" /></label>
<p id="session-rename-error" class="form-error" role="alert"></p>
<div class="dialog-actions"><button id="session-rename-cancel" class="button button-quiet" type="button">Cancel</button><button id="session-rename-save" class="button button-primary" type="submit">Save name</button></div>
</form>
</dialog>
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
<noscript><p class="noscript-message">Enable JavaScript to connect to your UVM workspace.</p></noscript>
</body>
</html>
@@ -5,7 +5,7 @@
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
const renderCache = new WeakMap();
const pages = {
overview: { label: 'Overview', title: 'Workspace', subtitle: 'Overview', icon: 'grid' },
overview: { label: 'Overview', title: 'Management', subtitle: 'Overview', icon: 'grid' },
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
@@ -26,10 +26,7 @@
page: 'overview',
detailId: null,
detail: null,
detailTab: 'overview',
terminalId: null,
portEditing: false,
resizeEditing: false,
selected: new Set(),
filter: 'all',
query: '',
@@ -47,18 +44,6 @@
menuTrigger: null,
};
const sessions = new window.NestSessions({
api, getToken: () => state.token,
isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id,
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
esc, formatDate, toast, confirmAction,
});
const tools = new window.NestTools({
api, esc, toast, formatDate,
onSelectionChange: updateCreateSummary,
onMutation: refreshAfterMutation,
});
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -101,10 +86,6 @@
return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`;
}
function formatStorage(value) {
return value == null ? 'Not recorded' : formatRam(value);
}
function timestamp(value) {
if (value == null || value === '') return NaN;
if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) {
@@ -175,7 +156,7 @@
return `The server returned HTTP ${status}.`;
}
async function api(path, { method = 'GET', body, timeout = 20000 } = {}) {
async function api(path, { method = 'GET', body } = {}) {
const session = state.session;
const controller = new AbortController();
state.controllers.add(controller);
@@ -184,7 +165,7 @@
const timer = method === 'GET' ? window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeout) : null;
}, 20000) : null;
try {
const response = await fetch(path, {
method,
@@ -415,7 +396,7 @@
return `<tr class="${state.selected.has(vm.id) ? 'selected' : ''}">
<td class="check-cell"><input type="checkbox" data-select="${id}" aria-label="Select ${esc(machineName(vm))}" ${state.selected.has(vm.id) ? 'checked' : ''} /></td>
<td><div class="machine-name"><span class="round-icon">${icon('server')}</span><div><a href="${esc(vmHref(vm.id))}" title="${esc(machineName(vm))}">${esc(machineName(vm))}</a><small>${esc(vm.guest_ip || vm.id)}</small></div></div></td>
<td>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))} RAM</small><small>${esc(formatStorage(vm.storage_mib))} storage</small></td>
<td>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))}</small></td>
<td><div class="table-ports">${portsMarkup(vm.ports, 2)}</div></td><td>${badge(vm.policy_status || 'unknown')}</td>
<td><div class="row-controls"><button class="icon-button" type="button" data-action="vm-action" data-operation="${running ? 'stop' : 'start'}" data-id="${id}" aria-label="${running ? 'Stop' : 'Start'} ${esc(machineName(vm))}" title="${running ? 'Stop' : 'Start'} machine" ${busy ? 'disabled' : ''}>${icon(running ? 'stop' : 'play')}</button><button class="icon-button" type="button" data-action="machine-menu" data-id="${id}" aria-label="Actions for ${esc(machineName(vm))}" aria-haspopup="menu" aria-expanded="false">${icon('more')}</button></div></td>
</tr>`;
@@ -608,33 +589,16 @@
text('#detail-heading', machineName(vm));
text('#detail-id', vm.id);
html('#detail-status', badge(runtimeStatus(vm)));
document.title = `${machineName(vm)} · Nest`;
document.title = `${machineName(vm)} · UVM Control`;
const busy = state.busy.has(vm.id);
const running = runtimeStatus(vm) === 'running';
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
html('#detail-actions', (running
html('#detail-actions', running
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
: actionButton('start', 'Start machine', 'play', true))
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
const showingSessions = state.detailTab === 'sessions';
const showingTools = state.detailTab === 'tools';
$('#vm-overview-panel').hidden = showingSessions || showingTools;
$('#vm-sessions-panel').hidden = !showingSessions;
$('#vm-tools-panel').hidden = !showingTools;
$('#vm-overview-tab').href = vmHref(vm.id);
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
$('#vm-tools-tab').href = `${vmHref(vm.id)}/tools`;
[['#vm-overview-tab', !showingSessions && !showingTools], ['#vm-sessions-tab', showingSessions], ['#vm-tools-tab', showingTools]].forEach(([selector, active]) => {
$(selector).classList.toggle('active', active);
if (active) $(selector).setAttribute('aria-current', 'page');
else $(selector).removeAttribute('aria-current');
});
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : showingTools ? ' / Tools' : ''}`);
sessions.sync(vm, state.terminalId, showingSessions);
tools.sync(vm, showingTools);
: actionButton('start', 'Start machine', 'play', true));
html('#detail-config', detailRows([
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Guest user', vm.username],
['Created', formatDate(vm.created_at)], ['Last updated', formatDate(vm.updated_at)],
]));
html('#detail-network', detailRows([
@@ -661,19 +625,6 @@
$('#sync-ports-btn').disabled = busy;
$('#open-ports-btn').hidden = vm.ports === null;
$('#close-ports-btn').hidden = vm.ports === null;
if (!state.resizeEditing) {
const resources = vm.pending_resize || vm;
$('#resize-cpu').value = resources.cpu;
$('#resize-ram').value = `${resources.ram_mib}M`;
$('#resize-storage').value = resources.storage_mib == null ? '' : `${resources.storage_mib / 1024}G`;
}
$('#resize-form').hidden = !state.resizeEditing;
$('#edit-resources-btn').hidden = state.resizeEditing;
$('#edit-resources-btn').disabled = busy || runtimeStatus(vm) === 'unknown' || vm.ports === null || vm.operation === 'delete' || vm.status === 'terminating';
$('#resize-submit').disabled = busy;
text('#resize-note', vm.pending_resize
? `Pending resize: ${number(vm.pending_resize.cpu)} CPU, ${formatRam(vm.pending_resize.ram_mib)} memory, ${formatStorage(vm.pending_resize.storage_mib)} storage. Retry the resize or start the stopped machine to finish it.`
: 'Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.');
}
async function loadDetail(identifier) {
@@ -686,15 +637,13 @@
state.detail = vm;
state.detailId = vm.id;
renderDetail();
if (state.detailTab === 'overview') await loadLogs();
await loadLogs();
} catch (error) {
if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
if (state.detail && error.status !== 404) {
text('#detail-error', error.message);
$('#detail-error').hidden = false;
} else {
sessions.leave();
tools.leave();
state.detail = null;
$('#detail-content').hidden = true;
$('#detail-loading').hidden = false;
@@ -807,67 +756,6 @@
return mib;
}
function parseStorage(value) {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?$/i);
if (!match) throw new Error('Use a storage amount such as 30G, 50G, or 1T.');
const factors = { b: 1 / (1024 * 1024), k: 1 / 1024, kb: 1 / 1024, ki: 1 / 1024, kib: 1 / 1024, m: 1, mb: 1, mi: 1, mib: 1, g: 1024, gb: 1024, gi: 1024, gib: 1024, t: 1024 * 1024, tb: 1024 * 1024, ti: 1024 * 1024, tib: 1024 * 1024 };
const amount = Number(match[1]) * factors[(match[2] || 'g').toLowerCase()];
if (!Number.isFinite(amount) || amount < 30 * 1024) throw new Error('Give this machine at least 30 GiB of storage.');
if (amount > 8796093022207) throw new Error('That storage capacity exceeds the supported limit.');
return Math.ceil(amount);
}
async function submitResize(event) {
event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return;
const id = state.detail.id;
const session = state.session;
const payload = {};
try {
const cpu = $('#resize-cpu').value.trim();
const ram = $('#resize-ram').value.trim();
const storage = $('#resize-storage').value.trim();
if (cpu) {
payload.cpu = Number(cpu);
if (!Number.isFinite(payload.cpu) || payload.cpu <= 0) throw new Error('CPU capacity must be a positive number.');
}
if (ram) { parseRam(ram); payload.ram = ram; }
if (storage) { parseStorage(storage); payload.storage = storage; }
if (!Object.keys(payload).length) throw new Error('Enter at least one resource to resize.');
} catch (error) {
text('#resize-error', error.message);
return;
}
text('#resize-error', '');
const button = $('#resize-submit');
const previous = button.textContent;
const unlock = lockForm($('#resize-form'));
state.busy.add(id);
button.textContent = 'Resizing and restarting…';
renderDetail();
try {
await api(vmPath(id, '/resize'), { method: 'POST', body: payload });
if (session !== state.session) return;
if (state.detailId === id) state.resizeEditing = false;
toast('Machine resized and started.');
await refreshAfterMutation(id);
} catch (error) {
if (!isAborted(error) && session === state.session) {
if (state.detailId === id) text('#resize-error', error.message);
else toast(error.message, true);
await refreshAfterMutation(id);
}
} finally {
unlock();
button.textContent = previous;
if (session === state.session) {
state.busy.delete(id);
renderMachines();
renderDetail();
}
}
}
async function submitPorts(event) {
event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return;
@@ -922,10 +810,8 @@
function updateCreateSummary() {
let memory = '—';
let storage = '—';
let ports = 'Check port rules';
try { memory = formatRam(parseRam($('#create-ram').value)); } catch { /* The form reports validation on submit. */ }
try { storage = formatStorage(parseStorage($('#create-storage').value)); } catch { /* Report invalid capacity on submit. */ }
try {
const rules = parsePorts($('#create-ports').value);
if ($('#create-default-ssh').checked && !rules.some((rule) => rule.port === 22)) rules.unshift({ port: 22, protocol: 'TCP' });
@@ -935,9 +821,8 @@
text('#create-summary-name', $('#create-name').value.trim() || 'Room for an idea.');
html('#create-summary-details', detailRows([
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
['Memory', memory], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
['Tools', tools.createSummary()],
]));
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
let mib = null;
@@ -964,20 +849,17 @@
let payload;
try {
parseRam($('#create-ram').value);
parseStorage($('#create-storage').value);
const cpu = Number($('#create-cpu').value);
if (!Number.isFinite(cpu) || cpu <= 0) throw new Error('CPU capacity must be a positive number.');
payload = {
name: $('#create-name').value.trim() || null,
cpu,
ram: $('#create-ram').value.trim(),
storage: $('#create-storage').value.trim(),
guest_ip: $('#create-guest-ip').value.trim() || null,
username: $('#create-username').value.trim(),
password: $('#create-password').value,
ports: parsePorts($('#create-ports').value),
no_default_ports: !$('#create-default-ssh').checked,
tools: tools.createSelections(),
};
if (!payload.password) throw new Error('Add a password for the guest account.');
} catch (error) {
@@ -989,25 +871,22 @@
const previous = button.innerHTML;
const unlock = lockForm($('#create-form'));
state.busy.add('create');
tools.setCreateBusy(true);
button.textContent = 'Creating your machine…';
try {
const vm = await api('/vms', { method: 'POST', body: payload });
if (session !== state.session) return;
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
$('#create-form').reset();
tools.resetCreate();
resetPasswordVisibility();
updateCreateSummary();
toast(payload.tools.length ? 'Machine created. Your selected tools are queued for installation.' : 'A new little machine, ready for you.');
location.hash = vmHref(vm.id) + (payload.tools.length ? '/tools' : '');
toast('A new little machine, ready for you.');
location.hash = vmHref(vm.id);
await loadVms();
} catch (error) {
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
} finally {
unlock();
button.innerHTML = previous;
tools.setCreateBusy(false);
if (session === state.session) state.busy.delete('create');
}
}
@@ -1157,19 +1036,8 @@
if (!state.authenticated) return;
let value = location.hash.slice(1) || 'overview';
let identifier = null;
let detailTab = 'overview';
let terminalId = null;
if (value.startsWith('vm/')) {
const parts = value.split('/');
try {
identifier = decodeURIComponent(parts[1]);
if (parts[2] === 'sessions' && parts.length <= 4) {
detailTab = 'sessions';
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
} else if (parts[2] === 'tools' && parts.length === 3) {
detailTab = 'tools';
} else if (parts.length !== 2) identifier = null;
} catch { identifier = null; }
try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; }
value = identifier ? 'vm-detail' : 'overview';
}
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
@@ -1178,14 +1046,8 @@
state.requests.detail += 1;
state.requests.logs += 1;
state.detailId = identifier;
state.detailTab = detailTab;
state.terminalId = terminalId;
sessions.leave();
tools.leave();
state.detail = null;
state.portEditing = false;
state.resizeEditing = false;
text('#resize-error', '');
closeMenu();
closeSearch();
setMobileNav(false);
@@ -1199,7 +1061,7 @@
text('#sidebar-title', pages[value].title);
text('#sidebar-subtitle', pages[value].subtitle);
text('#breadcrumb', pages[value].label);
document.title = `${pages[value].label} · Nest`;
document.title = `${pages[value].label} · UVM Control`;
$('#main-content').scrollTop = 0;
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
if (value === 'vm-detail') {
@@ -1210,14 +1072,14 @@
loadDetail(identifier);
}
if (value === 'system') loadDiagnostics();
if (value === 'create') { updateCreateSummary(); tools.mountCreate(); }
if (value === 'create') updateCreateSummary();
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
}
function storeToken(token) {
try {
if (token) sessionStorage.setItem('nest_token', token);
else sessionStorage.removeItem('nest_token');
if (token) sessionStorage.setItem('uvm_token', token);
else sessionStorage.removeItem('uvm_token');
} catch { /* A restricted browser can still use an in-memory session. */ }
}
@@ -1270,8 +1132,6 @@
}
function logout(message = '') {
sessions.leave();
tools.reset();
state.session += 1;
state.controllers.forEach((controller) => controller.abort());
state.controllers.clear();
@@ -1296,7 +1156,6 @@
state.detail = null;
state.detailId = null;
state.portEditing = false;
state.resizeEditing = false;
state.query = '';
state.filter = 'all';
state.sort = 'recent';
@@ -1317,13 +1176,12 @@
$('#token-input').disabled = false;
text('#create-error', '');
text('#port-error', '');
text('#resize-error', '');
text('#auth-error', message);
$('#main-app').hidden = true;
$('#auth-screen').hidden = false;
$('#refresh-btn').classList.remove('is-loading');
$('#toast-container').replaceChildren();
document.title = 'Nest';
document.title = 'UVM Control';
$('#token-input').focus();
}
@@ -1340,7 +1198,6 @@
if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); }
if (action === 'dismiss-results') $('#bulk-results').hidden = true;
if (action === 'install') installAssets();
if (action === 'launch-session' && state.detail) sessions.launch(state.detail);
if (action === 'refresh-logs') loadLogs();
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
@@ -1352,13 +1209,6 @@
$('#port-input').focus();
}
if (action === 'cancel-ports') { state.portEditing = false; text('#port-error', ''); renderDetail(); }
if (action === 'edit-resources') {
state.resizeEditing = true;
text('#resize-error', '');
renderDetail();
$('#resize-cpu').focus();
}
if (action === 'cancel-resize') { state.resizeEditing = false; text('#resize-error', ''); renderDetail(); }
if (action === 'toggle-password') {
const visible = $('#create-password').type === 'password';
$('#create-password').type = visible ? 'text' : 'password';
@@ -1374,7 +1224,6 @@
$('#create-form').addEventListener('submit', submitCreate);
$('#create-form').addEventListener('input', updateCreateSummary);
$('#port-editor').addEventListener('submit', submitPorts);
$('#resize-form').addEventListener('submit', submitResize);
$('#bulk-run-btn').addEventListener('click', runBulkAction);
$('#log-tail').addEventListener('change', loadLogs);
$('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); });
@@ -1444,10 +1293,9 @@
if (target && !target.disabled) handleAction(target);
});
document.addEventListener('keydown', (event) => {
if (event.target.closest('.xterm')) return;
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) {
if (document.querySelector('dialog[open]')) return;
if ($('#confirm-dialog').open) return;
event.preventDefault();
focusSearch();
}
@@ -1484,7 +1332,7 @@
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
updateCreateSummary();
let stored = '';
try { stored = sessionStorage.getItem('nest_token') || ''; } catch { /* Session storage is optional. */ }
try { stored = sessionStorage.getItem('uvm_token') || ''; } catch { /* Session storage is optional. */ }
if (stored) connect(stored);
}
+1 -51
View File
@@ -2,19 +2,16 @@
from __future__ import annotations
import math
import time
import uuid
from dataclasses import dataclass, field
from ipaddress import IPv4Address
from typing import Any
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError
from .tool_catalog import ToolSelection
STATE_SCHEMA_VERSION = 3
STATE_SCHEMA_VERSION = 2
@dataclass(frozen=True, slots=True)
@@ -55,41 +52,6 @@ class VmSpec:
password: str = field(default="root", repr=False)
name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),)
storage_mib: int = MIN_STORAGE_MIB
tools: tuple[ToolSelection, ...] = ()
@dataclass(frozen=True, slots=True)
class VmResources:
"""Complete resource target retained while a stop/resize/start is pending."""
cpu: float
ram_mib: int
storage_mib: int
def __post_init__(self) -> None:
try:
valid_cpu = type(self.cpu) in (int, float) and math.isfinite(self.cpu) and self.cpu > 0
except OverflowError:
valid_cpu = False
if not valid_cpu:
raise ValidationError("CPU must be a finite value greater than 0")
if type(self.ram_mib) is not int or self.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
if type(self.storage_mib) is not int or not MIN_STORAGE_MIB <= self.storage_mib <= MAX_STORAGE_MIB:
raise ValidationError(f"storage must be between {MIN_STORAGE_MIB} and {MAX_STORAGE_MIB} MiB (minimum 30 GiB)")
def to_dict(self) -> dict[str, Any]:
return {"cpu": self.cpu, "ram_mib": self.ram_mib, "storage_mib": self.storage_mib}
@classmethod
def from_dict(cls, value: object) -> "VmResources":
if not isinstance(value, dict) or set(value) != {"cpu", "ram_mib", "storage_mib"}:
raise StateError("invalid pending resize resources")
try:
return cls(**value)
except ValidationError as error:
raise StateError(f"invalid pending resize resources: {error}") from error
@dataclass(slots=True)
@@ -125,16 +87,12 @@ class VmRecord:
spawn_pending: bool = False
network: str | None = None
bridge: str | None = None
storage_mib: int | None = None
pending_resize: VmResources | None = None
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
"id": self.id,
"cpu": self.cpu,
"ram_mib": self.ram_mib,
"storage_mib": self.storage_mib,
"pending_resize": self.pending_resize.to_dict() if self.pending_resize is not None else None,
"guest_ip": self.guest_ip,
"gateway": self.gateway,
"tap": self.tap,
@@ -197,12 +155,6 @@ class VmRecord:
if ports is not None and len({port.port for port in ports}) != len(ports):
raise StateError("VM has duplicate TCP port rules")
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
storage_mib = value.get("storage_mib")
if storage_mib is not None and (
type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB
):
raise StateError("VM storage_mib must be a supported integer capacity of at least 30 GiB or null")
pending_resize = value.get("pending_resize")
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
if name in value and type(value[name]) is not bool:
raise StateError(f"VM {name} must be a boolean")
@@ -248,8 +200,6 @@ class VmRecord:
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
network=str(value["network"]) if value.get("network") is not None else None,
bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
storage_mib=storage_mib,
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
)
except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error
-12
View File
@@ -39,18 +39,6 @@ class FirecrackerError(UvmError):
"""Raised when Firecracker cannot be started or configured."""
class GuestAgentUnavailableError(UvmError):
"""The VM's execution agent could not be reached before submission."""
class GuestExecutionError(UvmError):
"""Guest execution failed; a lost response does not prove the command failed."""
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.outcome_unknown = outcome_unknown
class TapCreationError(UvmError):
"""Raised when TAP setup fails after creating a host interface."""
-10
View File
@@ -35,20 +35,12 @@ class FirecrackerClient:
boot = config["boot-source"]
drives = config["drives"]
interfaces = config["network-interfaces"]
entropy = config.get("entropy")
vsock = config.get("vsock")
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
raise FirecrackerError("invalid Firecracker configuration")
if not isinstance(drives, list) or not drives:
raise FirecrackerError("Firecracker configuration has no root drive")
if not isinstance(interfaces, list) or not interfaces:
raise FirecrackerError("Firecracker configuration has no network interface")
if not isinstance(entropy, Mapping):
raise FirecrackerError("Firecracker configuration has no valid entropy device")
if (not isinstance(vsock, Mapping) or type(vsock.get("guest_cid")) is not int
or not 3 <= vsock["guest_cid"] < 2**32 - 1
or not isinstance(vsock.get("uds_path"), str) or not vsock["uds_path"]):
raise FirecrackerError("Firecracker configuration has no valid vsock device")
self._request(
"PUT",
@@ -62,8 +54,6 @@ class FirecrackerClient:
self._request("PUT", "/boot-source", boot)
self._request("PUT", "/drives/rootfs", drives[0])
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
self._request("PUT", "/vsock", vsock)
self._request("PUT", "/entropy", entropy)
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
def _request(self, method: str, path: str, body: object) -> None:
-7
View File
@@ -44,8 +44,6 @@ def build_config(
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
)
)
if settings.architecture == "aarch64":
boot_args = f"keep_bootcon {boot_args}"
return {
"boot-source": {
"kernel_image_path": str(kernel),
@@ -70,11 +68,6 @@ def build_config(
"host_dev_name": vm.tap,
}
],
# Supply host-backed randomness before guest services such as sshd start.
"entropy": {},
# Each Firecracker instance has its own Unix-socket-backed vsock device;
# CIDs need not be allocated across these independent devices.
"vsock": {"guest_cid": 3, "uds_path": str(settings.guest_socket_path(vm.id))},
}
+6 -12
View File
@@ -124,7 +124,7 @@ class FirecrackerProcessManager:
) from error
socket_path = Path(vm.socket)
self._remove_runtime_sockets(vm)
self._remove_socket(socket_path)
log_path = Path(vm.log)
process: _PopenLike | None = None
@@ -275,16 +275,14 @@ class FirecrackerProcessManager:
if vm.pid is None:
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
return "unknown"
for socket_path in (Path(vm.socket), self._settings.guest_socket_path(vm.id)):
try:
socket_path.lstat()
Path(vm.socket).lstat()
except FileNotFoundError:
continue
return "gone"
except OSError:
return "unknown"
# An unowned socket may belong to a child whose identity was never saved.
return "unknown"
return "gone"
stat = self._process_stat(vm.pid)
if stat is None:
@@ -332,14 +330,14 @@ class FirecrackerProcessManager:
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
)
if observation == "gone":
self._remove_runtime_sockets(vm)
self._remove_socket(Path(vm.socket))
return
try:
pidfd = _open_pidfd(vm.pid)
except ProcessLookupError:
self._reap_child(vm.pid)
self._remove_runtime_sockets(vm)
self._remove_socket(Path(vm.socket))
return
except (OSError, NotImplementedError) as error:
raise FirecrackerError(
@@ -371,7 +369,7 @@ class FirecrackerProcessManager:
"process identity and socket retained"
)
self._reap_child(vm.pid)
self._remove_runtime_sockets(vm)
self._remove_socket(Path(vm.socket))
except (OSError, NotImplementedError) as error:
raise FirecrackerError(
f"could not safely stop Firecracker process {vm.pid}: {error}"
@@ -457,10 +455,6 @@ class FirecrackerProcessManager:
if process is not None and process.poll() is not None:
self._children.pop(pid, None)
def _remove_runtime_sockets(self, vm: VmRecord) -> None:
self._remove_socket(self._settings.guest_socket_path(vm.id))
self._remove_socket(Path(vm.socket))
@staticmethod
def _remove_socket(socket_path: Path) -> None:
try:
+19 -31
View File
@@ -161,8 +161,8 @@ class FirewallManager:
address = str(address)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "-D", *selector), check=False)
remaining = self._run(("conntrack", "-L", *selector))
self._run(("conntrack", "--delete", *selector), check=False)
remaining = self._run(("conntrack", "--list", *selector))
if remaining.stdout.strip():
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
@@ -408,9 +408,8 @@ class FirewallManager:
def _normalize(objects: list[dict[str, Any]]) -> str:
"""Canonical nft JSON, retaining every effective object, rule and verdict.
Normalize handles, empty set presentation and implied protocol dependencies
without losing address-family constraints. Rule order is significant;
pure conjunctions and sets are not.
Ignore only handles, empty set presentation and nft's redundant protocol
dependencies. Rule order is significant; pure conjunctions and sets are not.
Unknown expressions/properties remain different rather than being ignored.
"""
def canonical(value: Any) -> Any:
@@ -443,18 +442,6 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
return result
return value
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
definitions, rules = [], {}
for item in objects:
kind, raw = next(iter(item.items()))
@@ -503,24 +490,25 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
left["&"][1] = bitmask(left["&"][1], tcp_flags)
match["right"] = bitmask(match["right"], tcp_flags)
# nft's replayable JSON omits dependencies implied by named payloads.
encoded = json.dumps(expressions, sort_keys=True)
has_tcp = '"protocol": "tcp"' in encoded
has_icmp = '"protocol": "icmp"' in encoded
expressions = [expr for expr in expressions if not (
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
encoded = json.dumps(expressions, sort_keys=True)
expressions = [expr for expr in expressions if not (
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
or (('"protocol": "ip"' in encoded or has_icmp)
and expr == _match(_payload("ether", "type"), "ip"))
)]
count = len(expressions)
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
count -= 1
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions
rules.setdefault(obj.get("chain", ""), []).append(obj)
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
-473
View File
@@ -1,473 +0,0 @@
"""Standalone, stdlib-only guest execution agent (Python 3.10+).
This file is copied into guest images and must not import the host application.
The host also imports its wire-format helpers; importing it never starts a server.
"""
from __future__ import annotations
import base64
import errno
import fcntl
import json
import math
import os
import pwd
import re
import selectors
import signal
import socket
import stat
import struct
import subprocess
import sys
import termios
import threading
import time
import uuid
from pathlib import Path
PROTOCOL_VERSION = 1
AGENT_PORT = 1070
DEFAULT_TIMEOUT_SECONDS = 60.0
MAX_TIMEOUT_SECONDS = 3600.0
MAX_COMMAND_BYTES = 65536
MAX_OUTPUT_BYTES = 1024 * 1024 # Combined stdout and stderr, before UTF-8 decoding.
MAX_REQUEST_BYTES = 6 * MAX_COMMAND_BYTES + 4096
MAX_RESPONSE_BYTES = 6 * MAX_OUTPUT_BYTES + 4096
CONNECT_TIMEOUT_SECONDS = 5.0
CLEANUP_SECONDS = 5.0
MAX_CONCURRENT_COMMANDS = 8
MAX_TERMINAL_BYTES = 8192
MAX_TERMINAL_FRAME = 16384
CONFIG_PATH = Path("/etc/uvm-agent.json")
class ProtocolError(ValueError):
"""An invalid or oversized wire message; never include command text."""
class ExecutionFailure(Exception):
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.outcome_unknown = outcome_unknown
def validate_terminal_size(rows: object, cols: object) -> None:
if type(rows) is not int or type(cols) is not int or not (2 <= rows <= 200 and 2 <= cols <= 400):
raise ProtocolError("terminal dimensions must be integers: 2–200 rows and 2–400 columns")
def terminal_data(value: object) -> bytes:
if not isinstance(value, str) or len(value) > 4 * ((MAX_TERMINAL_BYTES + 2) // 3):
raise ProtocolError("terminal data exceeds its size limit")
try:
data = base64.b64decode(value, validate=True)
except (ValueError, UnicodeError) as error:
raise ProtocolError("invalid terminal data") from error
if len(data) > MAX_TERMINAL_BYTES:
raise ProtocolError("terminal data exceeds its size limit")
return data
def validate_command(command: object, sudo: object, timeout_seconds: object) -> None:
if not isinstance(command, str) or not command.strip() or "\0" in command:
raise ProtocolError("command must be non-empty text without NUL characters")
try:
size = len(command.encode("utf-8"))
except UnicodeError as error:
raise ProtocolError("command must be valid UTF-8 text") from error
if size > MAX_COMMAND_BYTES:
raise ProtocolError(f"command exceeds {MAX_COMMAND_BYTES} UTF-8 bytes")
if type(sudo) is not bool:
raise ProtocolError("sudo must be a boolean")
try:
valid_timeout = (
type(timeout_seconds) in (int, float)
and math.isfinite(timeout_seconds)
and 0 < timeout_seconds <= MAX_TIMEOUT_SECONDS
)
except OverflowError:
valid_timeout = False
if not valid_timeout:
raise ProtocolError(f"timeout_seconds must be finite and greater than 0, up to {MAX_TIMEOUT_SECONDS:g}")
def _remaining(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("guest protocol deadline exceeded")
return remaining
def receive_exact(connection: socket.socket, size: int, deadline: float) -> bytes:
parts = bytearray()
while len(parts) < size:
connection.settimeout(_remaining(deadline))
chunk = connection.recv(size - len(parts))
if not chunk:
raise EOFError("guest connection closed")
parts.extend(chunk)
return bytes(parts)
def receive_frame(connection: socket.socket, limit: int, deadline: float) -> dict:
size, = struct.unpack("!I", receive_exact(connection, 4, deadline))
if not 0 < size <= limit:
raise ProtocolError("invalid guest message length")
try:
message = json.loads(receive_exact(connection, size, deadline))
except (ValueError, UnicodeError, RecursionError) as error:
raise ProtocolError("invalid guest JSON message") from error
if not isinstance(message, dict):
raise ProtocolError("guest message must be an object")
return message
def send_frame(connection: socket.socket, message: dict, limit: int, deadline: float) -> None:
payload = json.dumps(message, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
if len(payload) > limit:
raise ProtocolError("guest message exceeds its size limit")
connection.settimeout(_remaining(deadline))
connection.sendall(struct.pack("!I", len(payload)) + payload)
class JobScope:
"""A delegated guest cgroup, keeping detached descendants in the same job."""
def __init__(self) -> None:
entries = Path("/proc/self/cgroup").read_text(encoding="ascii").splitlines()
unified = next((entry[3:] for entry in entries if entry.startswith("0::")), None)
if unified is None or ".." in Path(unified).parts:
raise ExecutionFailure("guest execution requires a delegated cgroup v2 hierarchy")
parent = Path("/sys/fs/cgroup") / unified.lstrip("/")
self.path = parent / f"uvm-exec-{uuid.uuid4().hex}"
self.path.mkdir(mode=0o700)
if not (self.path / "cgroup.kill").is_file():
self.path.rmdir()
raise ExecutionFailure("guest execution requires cgroup.kill (Linux 5.14 or newer)")
def kill(self) -> None:
(self.path / "cgroup.kill").write_text("1", encoding="ascii")
def close(self) -> None:
# cgroup.events reports descendants as well as processes in this cgroup.
deadline = time.monotonic() + 1.0
while "populated 1" in (self.path / "cgroup.events").read_text(encoding="ascii"):
if time.monotonic() >= deadline:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
time.sleep(0.01)
for child in sorted(self.path.rglob("*"), key=lambda path: len(path.parts), reverse=True):
if child.is_dir():
child.rmdir()
self.path.rmdir()
def _signal_group(process: subprocess.Popen, number: int) -> None:
# Do not reap the group leader until after signaling: its PID pins the PGID.
try:
os.killpg(process.pid, number)
except ProcessLookupError:
pass
def _exited(process: subprocess.Popen) -> bool:
return os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None
def _cleanup_job(process: subprocess.Popen | None, scope: JobScope) -> None:
failed = False
try:
scope.kill()
except (OSError, ExecutionFailure):
failed = True
if process is not None:
try:
_signal_group(process, signal.SIGKILL)
process.wait(timeout=1.0)
except (OSError, subprocess.TimeoutExpired):
failed = True
finally:
for name in ("stdout", "stderr"):
stream = getattr(process, name)
if stream is not None:
stream.close()
try:
scope.close()
except (OSError, ExecutionFailure):
failed = True
if failed:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=process is not None)
def run_command(command: str, username: str, timeout_seconds: float, *, scope_factory=JobScope) -> dict:
"""Run one non-interactive guest job, drain both pipes, and bound its lifetime."""
scope = scope_factory()
process = None
buffers = {"stdout": bytearray(), "stderr": bytearray()}
captured = 0
truncated = False
timed_out = False
try:
# The fresh child joins its scope BEFORE dropping credentials or executing
# user text. Avoid preexec_fn in the threaded agent.
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--child", str(scope.path), username, command],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
deadline = time.monotonic() + timeout_seconds
terminating = None
killed = False
with selectors.DefaultSelector() as selector:
for name in buffers:
stream = getattr(process, name)
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, name)
while True:
now = time.monotonic()
exited = _exited(process)
if terminating is None and (exited or now >= deadline):
timed_out = not exited
terminating = now
_signal_group(process, signal.SIGTERM if timed_out else signal.SIGKILL)
if terminating is not None:
if not killed and (exited or now >= terminating + 0.5):
scope.kill()
_signal_group(process, signal.SIGKILL)
killed = True
if killed and exited and not selector.get_map():
break
if now >= terminating + 3.0:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
for key, _events in selector.select(0.05):
chunk = os.read(key.fileobj.fileno(), 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
keep = min(len(chunk), MAX_OUTPUT_BYTES - captured)
buffers[key.data].extend(chunk[:keep])
captured += keep
truncated |= keep < len(chunk)
# Finally below kills the scope and reaps the leader before returning.
except OSError as error:
raise ExecutionFailure("guest command execution failed", outcome_unknown=process is not None) from error
finally:
_cleanup_job(process, scope)
return {
"exit_code": None if timed_out else process.returncode,
"stdout": buffers["stdout"].decode("utf-8", errors="replace"),
"stderr": buffers["stderr"].decode("utf-8", errors="replace"),
"timed_out": timed_out,
"truncated": truncated,
}
def child_main(scope: Path, username: str, command: str | None) -> None:
"""Enter the job cgroup and account in a fresh interpreter, then replace it."""
(scope / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
if command is None:
# Popen's start_new_session already performed setsid in this fresh child.
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
account = pwd.getpwnam(username)
if os.geteuid() == 0:
os.initgroups(account.pw_name, account.pw_gid)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
elif (account.pw_uid, account.pw_gid) != (os.geteuid(), os.getegid()):
raise PermissionError("guest agent cannot assume the requested account")
os.chdir(account.pw_dir)
os.umask(0o022)
environment = {
"HOME": account.pw_dir, "USER": account.pw_name, "LOGNAME": account.pw_name,
"SHELL": "/bin/sh", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
}
if command is None:
shell = account.pw_shell or "/bin/sh"
environment.update(SHELL=shell, TERM="xterm-256color", COLORTERM="truecolor")
os.execve(shell, [shell, "-i"], environment)
else:
os.execve("/bin/sh", ["sh", "-c", command], environment)
def run_terminal(connection: socket.socket, username: str, rows: int, cols: int, *, scope_factory=JobScope) -> None:
"""Bridge one real guest PTY until shell exit, explicit close, or host loss."""
validate_terminal_size(rows, cols)
scope = scope_factory()
process = None
master = slave = None
try:
master, slave = os.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--terminal-child", str(scope.path), username],
stdin=slave, stdout=slave, stderr=slave, start_new_session=True,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
os.close(slave)
slave = None
os.set_blocking(master, False)
send_frame(connection, {"type": "ready"}, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
pending = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(connection, selectors.EVENT_READ, "host")
selector.register(master, selectors.EVENT_READ, "pty")
finished = False
while not finished:
for key, events in selector.select(0.1):
if key.data == "host":
message = receive_frame(connection, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if message == {"type": "close"}:
finished = True
break
if set(message) == {"type", "data"} and message["type"] == "input":
pending.extend(terminal_data(message["data"]))
if len(pending) > 65536:
raise ProtocolError("terminal input backlog exceeded")
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
validate_terminal_size(message["rows"], message["cols"])
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", message["rows"], message["cols"], 0, 0))
else:
raise ProtocolError("invalid terminal control message")
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
else:
if events & selectors.EVENT_READ:
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except OSError as error:
if error.errno != errno.EIO:
raise
data = b""
if not data:
finished = True
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if events & selectors.EVENT_WRITE and pending:
try:
del pending[:os.write(master, pending)]
except BlockingIOError:
pass
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
if _exited(process):
# Drain the leader's final output before terminating any background jobs.
for _ in range(16):
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except (BlockingIOError, OSError):
break
if not data:
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
break
finally:
try:
_cleanup_job(process, scope)
finally:
for descriptor in (master, slave):
if descriptor is not None:
os.close(descriptor)
send_frame(connection, {"type": "exit", "exit_code": process.returncode}, MAX_TERMINAL_FRAME,
time.monotonic() + CONNECT_TIMEOUT_SECONDS)
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command, terminal_runner=run_terminal) -> None:
with connection:
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(connection, {"version": PROTOCOL_VERSION, **config}, 4096, deadline)
request = receive_frame(connection, MAX_REQUEST_BYTES, deadline)
if set(request) == {"version", "terminal"}:
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
size = request["terminal"]
if not isinstance(size, dict) or set(size) != {"rows", "cols"}:
raise ProtocolError("invalid terminal request")
validate_terminal_size(size["rows"], size["cols"])
terminal_runner(connection, config["username"], size["rows"], size["cols"])
return
if set(request) != {"version", "command", "sudo", "timeout_seconds"}:
raise ProtocolError("invalid execution request fields")
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
validate_command(request["command"], request["sudo"], request["timeout_seconds"])
username = "root" if request["sudo"] else config["username"]
result = runner(request["command"], username, request["timeout_seconds"])
send_frame(connection, result, MAX_RESPONSE_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (ProtocolError, ExecutionFailure, OSError, KeyError) as error:
# Never log request text, guest output, or credentials to the console.
try:
send_frame(connection, {
"error": "guest execution failed: " + str(error),
"outcome_unknown": isinstance(error, ExecutionFailure) and error.outcome_unknown,
}, 4096, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (OSError, ProtocolError):
pass
except EOFError:
pass
def load_config(path: Path = CONFIG_PATH) -> dict:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(descriptor, encoding="utf-8") as source:
metadata = os.fstat(source.fileno())
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
or metadata.st_mode & 0o077 or metadata.st_size > 4096):
raise ValueError("guest agent configuration must be a root-owned private regular file")
config = json.load(source)
if (not isinstance(config, dict) or set(config) != {"vm_id", "username"}
or not isinstance(config["vm_id"], str)
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", config["vm_id"]) is None
or not isinstance(config["username"], str)
or re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", config["username"]) is None):
raise ValueError("invalid guest agent configuration")
pwd.getpwnam(config["username"])
return config
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--terminal-child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], None)
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest terminal: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) == 5 and sys.argv[1] == "--child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest command: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) != 1 or os.geteuid() != 0:
raise SystemExit("uvm-agent must run as a root systemd service")
config = load_config()
slots = threading.BoundedSemaphore(MAX_CONCURRENT_COMMANDS)
def handle(connection: socket.socket) -> None:
try:
serve_connection(connection, config)
finally:
slots.release()
with socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) as listener:
listener.bind((socket.VMADDR_CID_ANY, AGENT_PORT))
listener.listen(MAX_CONCURRENT_COMMANDS)
while True:
connection, peer = listener.accept()
# Guest-local callers must not use the root agent to elevate themselves.
if peer[0] != socket.VMADDR_CID_HOST:
connection.close()
continue
if not slots.acquire(blocking=False):
connection.close()
continue
threading.Thread(target=handle, args=(connection,), daemon=True).start()
if __name__ == "__main__":
main()
-120
View File
@@ -1,120 +0,0 @@
"""Host-side, one-shot guest execution over Firecracker's vsock Unix socket."""
from __future__ import annotations
import socket
import stat
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from .guest_agent import (
AGENT_PORT, CLEANUP_SECONDS, CONNECT_TIMEOUT_SECONDS, MAX_OUTPUT_BYTES,
MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, ProtocolError,
receive_exact, receive_frame, send_frame, validate_command,
)
@dataclass(frozen=True, slots=True)
class ExecResult:
exit_code: int | None
stdout: str
stderr: str
timed_out: bool
truncated: bool
def validate_exec(command: object, sudo: object, timeout_seconds: object) -> None:
try:
validate_command(command, sudo, timeout_seconds)
except ProtocolError as error:
raise ValidationError(str(error)) from error
class GuestSession:
"""A connected session pins the selected VMM; never reconnect after submission."""
def __init__(self, connection: socket.socket) -> None:
self._connection = connection
@classmethod
def connect(cls, path: Path, vm_id: str, username: str, pid: int) -> GuestSession:
connection = None
try:
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
metadata = path.lstat()
if not stat.S_ISSOCK(metadata.st_mode):
raise ProtocolError("guest socket is not a Unix socket")
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
connection.settimeout(CONNECT_TIMEOUT_SECONDS)
connection.connect(str(path))
peer_pid, _uid, _gid = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if peer_pid != pid:
raise ProtocolError("guest socket does not belong to the selected VM process")
connection.sendall(f"CONNECT {AGENT_PORT}\n".encode("ascii"))
acknowledgement = bytearray()
while not acknowledgement.endswith(b"\n") and len(acknowledgement) < 32:
acknowledgement.extend(receive_exact(connection, 1, deadline))
parts = bytes(acknowledgement).split()
if (not acknowledgement.endswith(b"\n") or len(parts) != 2
or parts[0] != b"OK" or not parts[1].isdigit()):
raise ProtocolError("invalid Firecracker vsock acknowledgement")
hello = receive_frame(connection, 4096, deadline)
if (set(hello) != {"version", "vm_id", "username"}
or type(hello["version"]) is not int or hello["version"] != PROTOCOL_VERSION
or hello["vm_id"] != vm_id or hello["username"] != username):
raise ProtocolError("guest agent identity or protocol version does not match this VM")
return cls(connection)
except (OSError, EOFError, ProtocolError) as error:
if connection is not None:
connection.close()
raise GuestAgentUnavailableError(
"guest agent is unavailable or not ready; wait for guest startup, or restart the VM "
"to apply automatic vsock guest-agent setup (vsock-capable kernel required)"
) from error
def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
validate_exec(command, sudo, timeout_seconds)
try:
send_frame(self._connection, {
"version": PROTOCOL_VERSION, "command": command,
"sudo": sudo, "timeout_seconds": timeout_seconds,
}, MAX_REQUEST_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
result = receive_frame(
self._connection, MAX_RESPONSE_BYTES,
time.monotonic() + timeout_seconds + CLEANUP_SECONDS + CONNECT_TIMEOUT_SECONDS,
)
if "error" in result:
if (set(result) != {"error", "outcome_unknown"} or not isinstance(result["error"], str)
or len(result["error"]) > 4096 or type(result["outcome_unknown"]) is not bool):
raise ProtocolError("invalid guest failure response")
result["error"].encode("utf-8") # Reject invalid Unicode before rendering an HTTP error.
raise GuestExecutionError(result["error"], outcome_unknown=result["outcome_unknown"])
if set(result) != {"exit_code", "stdout", "stderr", "timed_out", "truncated"}:
raise ProtocolError("invalid guest result fields")
if (type(result["timed_out"]) is not bool or type(result["truncated"]) is not bool
or not isinstance(result["stdout"], str) or not isinstance(result["stderr"], str)
or (result["timed_out"] and result["exit_code"] is not None)
or (not result["timed_out"] and (
type(result["exit_code"]) is not int or not -64 <= result["exit_code"] <= 255))):
raise ProtocolError("invalid guest result types")
if sum(len(result[name].encode("utf-8")) for name in ("stdout", "stderr")) > 3 * MAX_OUTPUT_BYTES:
raise ProtocolError("guest output exceeds its limit")
return ExecResult(**result)
except (OSError, EOFError, ProtocolError, UnicodeError) as error:
raise GuestExecutionError(
"guest execution connection was lost or returned an invalid result; "
"the command may have run and will not be retried",
outcome_unknown=True,
) from error
def close(self) -> None:
# Wake a thread waiting for a long-running tool command on server shutdown.
# The guest still owns its bounded command and package-manager cleanup.
try:
self._connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self._connection.close()
-89
View File
@@ -1,89 +0,0 @@
"""Interactive, process-pinned guest terminal transport over the existing vsock agent."""
from __future__ import annotations
import asyncio
import base64
import json
import struct
import time
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from .guest_agent import (
CONNECT_TIMEOUT_SECONDS, MAX_TERMINAL_FRAME, PROTOCOL_VERSION, ProtocolError,
receive_frame, send_frame, terminal_data, validate_terminal_size,
)
from .guest_exec import GuestSession
def validate_size(rows: object, cols: object) -> None:
try:
validate_terminal_size(rows, cols)
except ProtocolError as error:
raise ValidationError(str(error)) from error
class GuestTerminal(GuestSession):
def start(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(self._connection, {"version": PROTOCOL_VERSION, "terminal": {"rows": rows, "cols": cols}},
MAX_TERMINAL_FRAME, deadline)
response = receive_frame(self._connection, MAX_TERMINAL_FRAME, deadline)
if response != {"type": "ready"}:
raise ProtocolError("terminal support is unavailable")
self._connection.setblocking(False)
self._write_lock = asyncio.Lock()
except (OSError, EOFError, ProtocolError) as error:
self.close()
raise GuestAgentUnavailableError(
"Interactive terminal support is unavailable. Restart this VM to apply automatic guest-agent setup, "
"then wait for the agent to start and launch a new session."
) from error
async def _receive_exact(self, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = await asyncio.get_running_loop().sock_recv(self._connection, size - len(data))
if not chunk:
raise EOFError("guest terminal connection closed")
data.extend(chunk)
return bytes(data)
async def read(self) -> dict:
try:
# An idle shell has no output deadline; partial messages are bounded.
first = await self._receive_exact(1)
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
size, = struct.unpack("!I", first + await self._receive_exact(3))
if not 0 < size <= MAX_TERMINAL_FRAME:
raise ProtocolError("invalid terminal frame length")
message = json.loads(await self._receive_exact(size))
if isinstance(message, dict):
if set(message) == {"type", "data"} and message["type"] == "output":
return {"type": "output", "data": terminal_data(message["data"])}
if (set(message) == {"type", "exit_code"} and message["type"] == "exit"
and type(message["exit_code"]) is int and -64 <= message["exit_code"] <= 255):
return message
raise ProtocolError("invalid guest terminal response")
except (OSError, EOFError, ValueError, UnicodeError, RecursionError) as error:
raise GuestExecutionError("The guest terminal connection ended unexpectedly.", outcome_unknown=True) from error
async def _send(self, message: dict) -> None:
payload = json.dumps(message, separators=(",", ":")).encode("ascii")
if len(payload) > MAX_TERMINAL_FRAME:
raise ValidationError("terminal input exceeds its size limit")
async with self._write_lock:
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
await asyncio.get_running_loop().sock_sendall(self._connection, struct.pack("!I", len(payload)) + payload)
async def write(self, data: bytes) -> None:
await self._send({"type": "input", "data": base64.b64encode(data).decode("ascii")})
async def resize(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
await self._send({"type": "resize", "rows": rows, "cols": cols})
async def end(self) -> None:
await self._send({"type": "close"})
-400
View File
@@ -1,400 +0,0 @@
"""Standalone guest-only tool installer, submitted through the execution agent.
Importing this module on the host has no side effects. It uses only Python 3.10
stdlib; all package commands, downloads, and filesystem changes run in the guest.
"""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import pwd
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.request
from pathlib import Path
ROOT = Path("/opt/uvm-tools")
LOCK = Path("/run/uvm-tools.lock")
BIN_DIR = Path("/usr/local/bin")
PROFILE_DIR = Path("/etc/profile.d")
PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
LOG_LIMIT = 16384
PHP_KEY = "B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6"
class ToolError(Exception):
pass
def atomic_write(path, data, mode=0o644):
descriptor, name = tempfile.mkstemp(prefix=".uvm-tools-", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
Path(name).unlink(missing_ok=True)
def safe_directory(path):
for parent in reversed((path, *path.parents)):
if not parent.exists():
parent.mkdir(mode=0o755)
metadata = parent.lstat()
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
raise ToolError(f"tool installation needs a root-owned directory: {parent}")
def regular_file(path):
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_nlink != 1 or metadata.st_mode & 0o022:
raise ToolError(f"refusing to replace an unmanaged tool file: {path}")
def download(url, destination, digest=None, *, max_bytes=1024 * 1024 * 1024):
if not url.startswith("https://"):
raise ToolError("tool downloads require HTTPS")
checksum = hashlib.sha256()
size = 0
with urllib.request.urlopen(url, timeout=30) as response, destination.open("wb") as output:
if not response.geturl().startswith("https://"):
raise ToolError("tool download redirected away from HTTPS")
while True:
chunk = response.read(65536)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
raise ToolError("tool download exceeds its size limit")
checksum.update(chunk)
output.write(chunk)
if digest is not None and checksum.hexdigest() != digest:
destination.unlink(missing_ok=True)
raise ToolError("tool download checksum did not match the pinned release")
def extract_archive(archive_path, destination):
"""Bound extraction and check each path, including previously extracted links."""
root = destination.resolve()
with tarfile.open(archive_path) as archive:
members = archive.getmembers()
if len(members) > 50000 or sum(member.size for member in members) > 4 * 1024**3:
raise ToolError("tool archive exceeds its extraction limit")
for member in members:
name = Path(member.name)
target = destination / name
if name.is_absolute() or ".." in name.parts or not target.resolve().is_relative_to(root):
raise ToolError("unsafe path in tool archive")
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
raise ToolError("unsupported file in tool archive")
if member.issym() or member.islnk():
link = (target.parent if member.issym() else destination) / member.linkname
if Path(member.linkname).is_absolute() or not link.resolve().is_relative_to(root):
raise ToolError("unsafe link in tool archive")
member.mode &= 0o755
member.uid = member.gid = 0
member.uname = member.gname = "root"
options = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
archive.extract(member, destination, **options)
def platform_info():
values = {}
for line in Path("/etc/os-release").read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key] = value.strip('"')
arch = os.uname().machine
return {"os": values.get("PRETTY_NAME", "Unknown OS"), "architecture": arch,
"supported": values.get("ID") == "ubuntu" and values.get("VERSION_ID") == "22.04"
and arch in ("x86_64", "aarch64"),
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}
def guest_busy():
try:
descriptor = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW)
except FileNotFoundError:
return False
try:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
return False
except BlockingIOError:
return True
finally:
os.close(descriptor)
def probe(tool, username):
account = pwd.getpwnam(username)
environment = {"PATH": PATH, "HOME": account.pw_dir, "USER": username, "LOGNAME": username,
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"}
result = {"key": tool["key"], "id": tool["id"], "version": tool["version"], "installed": False,
"detected_version": None, "error": None, "usage": tool["usage"]}
for index, command in enumerate(tool["checks"]):
if shutil.which(command[0], path=PATH) is None:
return result
try:
process = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=3, env=environment, cwd=account.pw_dir,
user=account.pw_uid, group=account.pw_gid,
extra_groups=os.getgrouplist(username, account.pw_gid))
output = process.stdout[:4096].decode("utf-8", errors="replace")
except (OSError, subprocess.TimeoutExpired) as error:
result["error"] = f"could not verify {command[0]}: {error}"[:512]
return result
if process.returncode != 0:
result["error"] = f"{command[0]} verification failed: {output}"[:512]
return result
match = re.search(tool["match"], output)
if index == 0:
if not match:
result["error"] = f"could not determine the installed {tool['id']} version"
return result
result["detected_version"] = match.group(1)
if not result["detected_version"].startswith(tool["prefix_match"]):
return result
elif tool["id"] == "jdk" and (not match or not match.group(1).startswith("21.")):
return result
result["installed"] = True
return result
class Installer:
def __init__(self, username):
self.username = username
self.log = bytearray()
self.truncated = False
self.lock = None
self.managed = {}
def append(self, data):
self.log.extend(data)
if len(self.log) > LOG_LIMIT:
del self.log[:-LOG_LIMIT]
self.truncated = True
def run(self, command):
self.append(("+ " + shlex.join(map(str, command)) + "\n").encode())
environment = {**os.environ, "PATH": PATH, "LC_ALL": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, env=environment, pass_fds=(self.lock,)) as process:
while True:
chunk = process.stdout.read(8192)
if not chunk:
break
self.append(chunk)
if process.wait() != 0:
raise ToolError(f"{command[0]} failed with exit code {process.returncode}; see the installation output")
def remember(self, path, value):
name = str(path)
self.managed[name] = list(dict.fromkeys([*self.managed.get(name, []), value]))[-2:]
atomic_write(ROOT / "managed.json", json.dumps(self.managed).encode(), 0o600)
def write_managed(self, path, data, mode=0o644):
safe_directory(path.parent)
digest = hashlib.sha256(data).hexdigest()
if path.exists() or path.is_symlink():
regular_file(path)
current = hashlib.sha256(path.read_bytes()).hexdigest()
if current == digest:
return
if current not in self.managed.get(str(path), []):
raise ToolError(f"customized file needs attention before installing: {path}")
# Record intent first so an interrupted publication can be retried.
self.remember(path, digest)
atomic_write(path, data, mode)
def link(self, name, target):
path = BIN_DIR / name
safe_directory(path.parent)
target = str(target)
if path.exists() or path.is_symlink():
if path.is_symlink() and os.readlink(path) == target:
return
if not path.is_symlink() or os.readlink(path) not in self.managed.get(str(path), []):
raise ToolError(f"customized executable needs attention before installing: {path}")
self.remember(path, target)
temporary = path.with_name(".uvm-" + name)
temporary.unlink(missing_ok=True)
temporary.symlink_to(target)
os.replace(temporary, path)
def apt(self, packages, *, refresh=False):
stamp = ROOT / "apt-updated"
if refresh or not stamp.exists() or time.time() - stamp.stat().st_mtime > 300:
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
"-o", "APT::Update::Error-Mode=any", "update"])
atomic_write(stamp, b"updated\n", 0o600)
pending = ROOT / "apt-pending"
if pending.exists():
# Complete configuration left by an interrupted tool installation.
self.run(["/usr/bin/dpkg", "--configure", "-a"])
atomic_write(pending, b"pending\n", 0o600)
self.run(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=300", "-o", "Acquire::Retries=2",
"-o", "Dpkg::Options::=--force-confold", "-y", "--no-install-recommends", "install", *packages])
pending.unlink()
def php_repository(self, temporary):
self.apt(["ca-certificates", "gnupg"])
key = temporary / "php.asc"
download(f"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{PHP_KEY}", key, max_bytes=65536)
result = subprocess.run(["gpg", "--batch", "--homedir", str(temporary), "--with-colons", "--show-keys", str(key)],
capture_output=True, check=True)
fingerprints = [line.split(":")[9] for line in result.stdout.decode().splitlines() if line.startswith("fpr:")]
if not fingerprints or fingerprints[0] != PHP_KEY:
raise ToolError("PHP repository signing key did not match its pinned fingerprint")
binary_key = temporary / "php.gpg"
self.run(["gpg", "--batch", "--homedir", str(temporary), "--dearmor", "--output", str(binary_key), str(key)])
self.write_managed(Path("/etc/apt/keyrings/uvm-php.gpg"), binary_key.read_bytes())
self.write_managed(Path("/etc/apt/sources.list.d/uvm-php.list"),
b"deb [signed-by=/etc/apt/keyrings/uvm-php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main\n")
def payload(self, tool, temporary):
prefix = ROOT / tool["release"]
marker = prefix / ".uvm-tools.json"
identity = {"release": tool["release"], "sha256": tool["sha256"]}
if prefix.exists() or prefix.is_symlink():
safe_directory(prefix)
if not marker.exists():
raise ToolError(f"installation directory is already in use: {prefix}")
regular_file(marker)
previous = json.loads(marker.read_bytes())
if not isinstance(previous, dict) or any(previous.get(key) != value for key, value in identity.items()):
raise ToolError(f"installation directory belongs to a different release: {prefix}")
if tool["kind"] != "conda" or previous.get("complete"):
return prefix
artifact = temporary / "download"
self.append(f"Downloading {tool['release']} and verifying SHA-256…\n".encode())
download(tool["url"], artifact, tool["sha256"])
if tool["kind"] == "archive":
extraction = temporary / "extract"
extraction.mkdir()
extract_archive(artifact, extraction)
source = extraction / tool["archive_root"]
if not source.is_dir() or source.is_symlink():
raise ToolError("tool archive has an unexpected layout")
atomic_write(source / ".uvm-tools.json", json.dumps(identity).encode(), 0o600)
os.replace(source, prefix)
else:
prefix.mkdir(exist_ok=True, mode=0o755)
atomic_write(marker, json.dumps(identity).encode(), 0o600)
if tool["kind"] == "conda":
self.run(["/bin/bash", str(artifact), "-b", "-f", "-p", str(prefix)])
else:
artifact.chmod(0o755)
os.replace(artifact, prefix / "micromamba")
return prefix
def configure(self, tool, prefix):
for name, target in tool["links"].items():
self.link(name, Path(target) if target.startswith("/") else prefix / target)
if tool["id"] == "python":
executable = prefix / "bin" / f"python{tool['version']}"
self.write_managed(BIN_DIR / f"pip{tool['version']}",
f'#!/bin/sh\nexec {shlex.quote(str(executable))} -m pip "$@"\n'.encode(), 0o755)
elif tool["id"] == "jdk":
self.write_managed(PROFILE_DIR / "uvm-java.sh",
f"export JAVA_HOME={shlex.quote(tool['java_home'])}\n".encode())
elif tool["id"] == "conda":
for key, value in (("auto_activate_base", "false"),):
self.run([str(prefix / "bin/conda"), "config", "--system", "--set", key, value])
for key, value in (("envs_dirs", "~/.conda/envs"), ("pkgs_dirs", "~/.conda/pkgs")):
self.run([str(prefix / "bin/conda"), "config", "--system", "--prepend", key, value])
self.write_managed(PROFILE_DIR / "uvm-conda.sh",
f". {shlex.quote(str(prefix / 'etc/profile.d/conda.sh'))}\n".encode())
elif tool["id"] == "micromamba":
self.write_managed(PROFILE_DIR / "uvm-micromamba.sh",
b'export MAMBA_ROOT_PREFIX="${MAMBA_ROOT_PREFIX:-$HOME/micromamba}"\n'
b'eval "$(/usr/local/bin/micromamba shell hook --shell posix)"\n')
def install(self, tool):
safe_directory(LOCK.parent)
self.lock = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(self.lock)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
raise ToolError("unsafe guest tool installation lock")
try:
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ToolError("another installation is still running inside this guest; wait before retrying")
existing = probe(tool, self.username)
if existing["installed"]:
return {"status": "already_installed", "tool": existing}
safe_directory(ROOT)
manifest = ROOT / "managed.json"
if manifest.exists():
regular_file(manifest)
self.managed = json.loads(manifest.read_bytes())
if (not isinstance(self.managed, dict) or any(
not isinstance(values, list) or len(values) > 2 or any(not isinstance(value, str) for value in values)
for values in self.managed.values())):
raise ToolError("invalid managed tool file manifest")
with tempfile.TemporaryDirectory(prefix=".install-", dir=ROOT) as name:
temporary = Path(name)
if tool["kind"] == "php":
self.php_repository(temporary)
self.apt(tool["packages"], refresh=tool["kind"] == "php")
prefix = self.payload(tool, temporary) if "url" in tool else None
self.configure(tool, prefix)
installed = probe(tool, self.username)
if not installed["installed"]:
raise ToolError(installed["error"] or f"{tool['key']} did not pass its version check after installation")
if prefix is not None:
atomic_write(prefix / ".uvm-tools.json", json.dumps({"release": tool["release"],
"sha256": tool["sha256"], "complete": True}).encode(), 0o600)
return {"status": "installed", "tool": installed}
finally:
os.close(self.lock)
self.lock = None
def main(payload):
installer = Installer(payload["username"])
info = {}
try:
if os.geteuid() != 0:
raise ToolError("tool installation and inspection require guest root")
info = platform_info()
info["supported"] = info["supported"] and info["architecture"] == payload["architecture"]
if not info["supported"]:
raise ToolError("one-click tools require an Ubuntu 22.04 guest on the selected x86_64/ARM64 architecture")
if payload.get("boot_id") and payload["boot_id"] != info["boot_id"]:
raise ToolError("the guest rebooted during installation; retry on the current boot")
if payload["action"] == "probe":
result = {**info, "busy": guest_busy(), "tools": [probe(tool, payload["username"]) for tool in payload["tools"]]}
elif payload["action"] == "install":
result = installer.install(payload["tools"][0])
else:
raise ToolError("unsupported tool operation")
result["ok"] = True
except (ToolError, OSError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError) as error:
result = {"ok": False, "error": str(error)[:2048], "supported": info.get("supported")}
result.update(output=installer.log.decode("utf-8", errors="replace"), truncated=installer.truncated)
print(json.dumps(result, ensure_ascii=True))
if __name__ == "__main__":
main(json.loads(sys.argv[1]))
+11 -541
View File
@@ -2,23 +2,16 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import shutil
import tempfile
import time
from dataclasses import dataclass
from ipaddress import IPv4Address
from pathlib import Path
from . import guest_agent
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError, ValidationError
from .config import Settings
from .errors import UvmError
from .integrity import load_manifest, verify_file
from .system import CommandRunner
from .validation import parse_username
_FIRECRACKER_DEMO_PUBLIC_KEY = (
@@ -39,69 +32,6 @@ _HOST_KEY_TYPES = (
("ed25519", None),
)
# Keep the kernel's proto-boot default route until networkd takes over. On
# Ubuntu 22.04, KeepConfiguration=static preserves addresses but drops that route.
GUEST_NETWORK_TEMPLATE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=yes\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
_LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
"KeepConfiguration=yes", "KeepConfiguration=static",
)
GUEST_AGENT_SERVICE = """\
[Unit]
Description=UVM guest command execution agent
After=local-fs.target network.target
ConditionPathExists=/etc/uvm-agent.json
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStartPre=/bin/sh /usr/lib/uvm/prepare-agent.sh
ExecStart=/usr/bin/python3 -I /usr/lib/uvm/agent.py
Restart=on-failure
RestartSec=5
TimeoutStartSec=600
TimeoutStopSec=5
KillMode=control-group
Delegate=yes
UMask=0077
[Install]
WantedBy=multi-user.target
"""
# Runs only INSIDE the VM. Old Jammy guests can acquire the runtime without SSH
# or executing a retained guest's binaries in a privileged host chroot.
GUEST_AGENT_PREPARE = """\
set -eu
check_python() {
/usr/bin/python3 -I -c 'import sys, json, math, pwd, selectors, socket, struct, subprocess, threading, uuid; assert sys.version_info >= (3, 10)' 2>/dev/null
}
if check_python; then
exit 0
fi
export DEBIAN_FRONTEND=noninteractive LC_ALL=C
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -y --no-install-recommends install python3
check_python
"""
# The original v4 template shipped this exact agent without an ownership
# manifest. Recognize that payload when creating from a cached v4 template.
_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948"
def guest_agent_files() -> dict[str, bytes]:
"""The same standalone payload is used by the builder and offline upgrades."""
return {
"/usr/lib/uvm/agent.py": Path(guest_agent.__file__).read_bytes(),
"/usr/lib/uvm/prepare-agent.sh": GUEST_AGENT_PREPARE.encode("utf-8"),
"/etc/systemd/system/uvm-agent.service": GUEST_AGENT_SERVICE.encode("utf-8"),
}
@dataclass(frozen=True, slots=True)
class GuestAssets:
@@ -158,336 +88,26 @@ class ImageStore:
allow_unverified=self._settings.allow_unverified_downloads)
return kernel
def create_vm_disk(
self, source: Path, destination: Path, *, storage_mib: int = MIN_STORAGE_MIB,
) -> Path:
def create_vm_disk(self, source: Path, destination: Path) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM."""
self._validate_storage(storage_mib)
if destination.exists():
raise UvmError(f"VM disk already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
self._copy_sparse(source, destination)
shutil.copy2(source, destination)
destination.chmod(0o600)
self._resize_filesystem(destination, storage_mib)
except BaseException as error:
except OSError as error:
try:
destination.unlink()
except FileNotFoundError:
pass
if isinstance(error, OSError):
raise UvmError(f"could not create VM disk {destination}: {error}") from error
raise
return destination
def disk_size_bytes(self, disk: Path) -> int:
"""Read logical disk capacity, including a resize published before state was saved."""
try:
return disk.stat().st_size
except OSError as error:
raise UvmError(f"could not inspect private disk capacity: {disk}: {error}") from error
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
"""Resize a proven-offline private disk on a copy, then publish atomically."""
self._validate_storage(storage_mib)
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
target_bytes = storage_mib * 1024 * 1024
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
return # A previous resize may have published its disk before saving metadata.
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(name)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
self._resize_filesystem(temporary, storage_mib)
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
finally:
temporary.unlink(missing_ok=True)
def configure_guest_network(self, disk: Path, guest_ip: str) -> bool:
"""Specialize an unedited UVM profile on a proven-offline private disk.
Lifecycle owns the operation lock and confirms process exit. Stage and
check a sparse copy so failed migrations retain the original disk. A
completed migration, custom image, or guest-edited profile is a no-op.
"""
try:
address = IPv4Address(guest_ip)
except ValueError as error:
raise ValidationError("guest network configuration requires a valid IPv4 address") from error
if address not in self._settings.network or address in {
self._settings.network.network_address,
self._settings.network.broadcast_address,
self._settings.gateway,
}:
raise ValidationError("guest network configuration requires a usable IP inside the UVM subnet")
try:
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to configure: {disk}")
with tempfile.TemporaryDirectory(prefix="uvm-guest-network-") as name:
files = Path(name)
if self._unconfigured_guest_network(disk, files) is None:
return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-network-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(candidate)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before guest network configuration: {detail}")
# Journal replay may reveal guest edits not visible to the
# initial debugfs read. Recheck ownership on the recovered copy.
profile = self._unconfigured_guest_network(temporary, files)
if profile is None:
return False
profile.path.write_text(
GUEST_NETWORK_TEMPLATE
+ f"Address={address}/{self._settings.network.prefixlen}\n"
+ f"Gateway={self._settings.gateway}\n",
encoding="utf-8",
)
self._write_guest_file(temporary, "/etc/systemd/network/10-uvm.network", profile)
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
with temporary.open("rb") as image:
os.fsync(image.fileno())
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return True
except (OSError, UnicodeError) as error:
raise UvmError(f"could not configure guest networking: {error}") from error
def _unconfigured_guest_network(self, disk: Path, temporary: Path) -> _GuestFile | None:
marker = self._read_guest_file(
disk, "/etc/uvm-image", temporary / "marker", required=False, regular_only=True,
)
if marker is None or marker.path.read_text(encoding="utf-8") not in {
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
"uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n",
}:
return None
profile = self._read_guest_file(
disk, "/etc/systemd/network/10-uvm.network", temporary / "network", required=False, regular_only=True,
)
if (profile is None or (profile.uid, profile.gid, profile.mode, profile.links) != (0, 0, 0o644, 1)
or profile.has_extended_attributes):
return None
if profile.path.read_text(encoding="utf-8") not in {
_LEGACY_GUEST_NETWORK_TEMPLATE, GUEST_NETWORK_TEMPLATE,
}:
return None
return profile
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool:
"""Ensure every guest has its vsock agent before booting its private disk.
The lifecycle lock and confirmed VMM exit are required. A checked sparse
copy preserves the original disk on failure and makes retries atomic.
Return False only when the managed files and service are already current.
An explicit install request always stages and rechecks the installation.
"""
parse_username(username)
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
raise ValidationError("invalid VM ID for guest agent configuration")
try:
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError("private VM disk is unsafe for guest agent installation")
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
files = Path(name)
if not install and self._guest_agent_current(disk, files, vm_id, username):
return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(candidate)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
if checked.returncode not in (0, 1):
raise UvmError("filesystem check failed before guest agent installation")
# All ownership/content checks happen AFTER journal replay.
self._install_agent_files(temporary, files, vm_id, username)
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
with temporary.open("rb") as image:
os.fsync(image.fileno())
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return True
except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest agent: {error}") from error
@staticmethod
def _agent_payloads(vm_id: str, username: str) -> dict[str, bytes]:
payloads = guest_agent_files()
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
return payloads
def _guest_agent_current(self, disk: Path, files: Path, vm_id: str, username: str) -> bool:
"""Read-only fast path; changed installations are checked again after journal replay."""
payloads = self._agent_payloads(vm_id, username)
for index, (path, content) in enumerate(payloads.items()):
existing = self._agent_file(disk, path, files / f"current-{index}")
mode = 0o600 if path.endswith(".json") else 0o644
if (existing is None or (existing.mode, existing.gid) != (mode, 0)
or existing.path.read_bytes() != content):
return False
manifest = self._agent_file(disk, "/etc/uvm-agent-files.json", files / "current-manifest")
if manifest is None or (manifest.mode, manifest.gid) != (0o600, 0):
return False
try:
hashes = json.loads(manifest.path.read_bytes())
except (ValueError, UnicodeError):
return False
if hashes != {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}:
return False
service = self._runner.run((
"debugfs", "-R", "stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service", disk,
), capture=True)
return (re.search(r"Type:\s+symlink\b", service.stdout) is not None
and 'Fast link dest: "/etc/systemd/system/uvm-agent.service"' in service.stdout)
def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None:
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
if re.search(r"Type:\s+regular\b", result.stdout) is None:
raise UvmError(
"automatic vsock guest-agent setup requires a systemd/apt image (Ubuntu 22.04 or newer); "
"use the default Ubuntu rootfs or a compatible custom image"
)
for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
self._agent_directory(disk, directory)
payloads = self._agent_payloads(vm_id, username)
manifest_path = "/etc/uvm-agent-files.json"
previous = self._agent_file(disk, manifest_path, files / "manifest")
old_hashes = {}
if previous is not None:
try:
old_hashes = json.loads(previous.path.read_bytes())
except (ValueError, UnicodeError) as error:
raise UvmError("invalid guest agent ownership manifest") from error
if (not isinstance(old_hashes, dict) or set(old_hashes) - set(payloads)
or any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
for value in old_hashes.values())):
raise UvmError("invalid guest agent ownership manifest")
hashes = {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}
for index, (path, content) in enumerate(payloads.items()):
existing = self._agent_file(disk, path, files / f"existing-{index}")
if existing is not None:
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
allowed = {hashes[path], old_hashes.get(path)}
if not old_hashes and path == "/usr/lib/uvm/agent.py":
allowed.add(_V4_TEMPLATE_AGENT_SHA256)
if digest not in allowed:
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
source = files / f"payload-{index}"
source.write_bytes(content)
self._write_guest_file(disk, path, _GuestFile(source, 0o600 if path.endswith(".json") else 0o644, 0, 0))
source = files / "new-manifest"
source.write_text(json.dumps(hashes, sort_keys=True) + "\n", encoding="utf-8")
self._write_guest_file(disk, manifest_path, _GuestFile(source, 0o600, 0, 0))
link = "/etc/systemd/system/multi-user.target.wants/uvm-agent.service"
target = "/etc/systemd/system/uvm-agent.service"
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
self._runner.run(("debugfs", "-w", "-R", f"symlink {link} {target}", disk), capture=True)
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
if (re.search(r"Type:\s+symlink\b", result.stdout) is None
or f'Fast link dest: "{target}"' not in result.stdout):
raise UvmError("could not enable guest agent without replacing a customized service link")
def _agent_directory(self, disk: Path, path: str) -> None:
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
self._create_guest_directory(disk, path, 0o755, 0, 0)
return
mode, owner = _MODE_PATTERN.search(result.stdout), _OWNER_PATTERN.search(result.stdout)
if (re.search(r"Type:\s+directory\b", result.stdout) is None or mode is None or owner is None
or int(owner.group(1)) != 0 or int(mode.group(1), 8) & 0o022):
raise UvmError(f"unsafe guest agent directory: {path}")
def _agent_file(self, disk: Path, path: str, destination: Path) -> _GuestFile | None:
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
return None
if re.search(r"Type:\s+regular\b", result.stdout) is None:
raise UvmError(f"unsafe guest agent file: {path}")
source = self._read_guest_file(disk, path, destination)
if (source.uid != 0 or source.mode & 0o022 or source.links != 1 or source.has_extended_attributes):
raise UvmError(f"unsafe guest agent file: {path}")
return source
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
def _copy_sparse(self, source: Path, destination: Path) -> None:
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
def _filesystem_size(self, disk: Path) -> int:
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
if blocks is None or block_size is None:
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
return int(blocks.group(1)) * int(block_size.group(1))
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
"""Grow the file before ext4; shrink ext4 before truncating the file."""
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
target_bytes = storage_mib * 1024 * 1024
if disk.stat().st_size < target_bytes:
with disk.open("r+b") as image:
image.truncate(target_bytes)
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
if self._filesystem_size(disk) != target_bytes:
raise UvmError("resized ext4 filesystem does not match the requested capacity")
with disk.open("r+b") as image:
image.truncate(target_bytes)
image.flush()
os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Create a missing guest account and set its password in an offline ext4 disk."""
"""Set an existing guest account password in an offline ext4 disk."""
parse_username(username)
password_hash = self._password_hash(password)
try:
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
@@ -515,7 +135,7 @@ class ImageStore:
if ":" in line
}
if username not in account_names:
self._create_guest_user(disk, username, passwd, shadow, temporary)
raise UvmError(f"guest user does not exist in rootfs: {username}")
shadow.path.write_text(
_set_shadow_password(
@@ -526,8 +146,6 @@ class ImageStore:
encoding="utf-8",
)
shadow.path.chmod(shadow.mode)
if username != "root":
self._provision_guest_sudo(disk, username, temporary)
sshd_config.path.write_text(
_enable_ssh_password_authentication(
@@ -546,136 +164,6 @@ class ImageStore:
except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest credentials: {error}") from error
def _provision_guest_sudo(self, disk: Path, username: str, temporary: Path) -> None:
sudoers = self._read_guest_file(disk, "/etc/sudoers", temporary / "sudoers", required=False)
sudo = self._runner.run(("debugfs", "-R", "stat /usr/bin/sudo", disk), capture=True)
mode = _MODE_PATTERN.search(sudo.stdout)
owner = _OWNER_PATTERN.search(sudo.stdout)
if (
sudoers is None
or sudoers.uid != 0 or sudoers.mode & 0o022
or re.search(r"Type:\s+regular\b", sudo.stdout) is None
or mode is None or not int(mode.group(1), 8) & 0o4000
or not int(mode.group(1), 8) & 0o111
or owner is None or int(owner.group(1)) != 0
):
raise UvmError(
"guest image needs setuid-root sudo and /etc/sudoers for a custom administrator; "
"run uvm install --force to build the complete Ubuntu image"
)
contents = sudoers.path.read_text(encoding="utf-8").rstrip()
sudoers.path.write_text(
contents + "\n\n# Managed by uvm: guest administrator\n"
+ f"Defaults:{username} !rootpw, !runaspw, !targetpw\n"
+ f"{username} ALL=(ALL:ALL) PASSWD: ALL\n",
encoding="utf-8",
)
sudoers.path.chmod(sudoers.mode)
self._write_guest_file(disk, "/etc/sudoers", sudoers)
def _create_guest_user(
self,
disk: Path,
username: str,
passwd: _GuestFile,
shadow: _GuestFile,
temporary: Path,
) -> None:
if username == "root":
raise UvmError("guest rootfs is missing the root account")
group = self._read_guest_file(disk, "/etc/group", temporary / "group")
gshadow = self._read_guest_file(
disk, "/etc/gshadow", temporary / "gshadow", required=False,
)
assert group is not None
passwd_entries = [
line.split(":")
for line in passwd.path.read_text(encoding="utf-8").splitlines()
if ":" in line
]
group_entries = [
line.split(":")
for line in group.path.read_text(encoding="utf-8").splitlines()
if ":" in line
]
try:
used_uids = {int(fields[2]) for fields in passwd_entries}
used_gids = {int(fields[3]) for fields in passwd_entries}
used_gids.update(int(fields[2]) for fields in group_entries)
except (IndexError, ValueError) as error:
raise UvmError("guest rootfs has invalid passwd or group entries") from error
uid = _next_guest_id(used_uids)
existing_group = next((fields for fields in group_entries if fields[0] == username), None)
if existing_group is not None:
gid = int(existing_group[2])
else:
gid = uid if uid not in used_gids else _next_guest_id(used_gids)
home = f"/home/{username}"
updates = [
("/etc/passwd", passwd, f"{username}:x:{uid}:{gid}::{home}:/bin/sh"),
("/etc/shadow", shadow, f"{username}:!:{int(time.time() // 86400)}:0:99999:7:::"),
]
if existing_group is None:
updates.append(("/etc/group", group, f"{username}:x:{gid}:"))
if gshadow is not None:
updates.append(("/etc/gshadow", gshadow, f"{username}:!::"))
for guest_path, source, entry in updates:
contents = source.path.read_text(encoding="utf-8")
if any(line.split(":", 1)[0] == username for line in contents.splitlines()):
raise UvmError(f"guest rootfs already has an entry for {username} in {guest_path}")
separator = "" if not contents or contents.endswith("\n") else "\n"
source.path.write_text(f"{contents}{separator}{entry}\n", encoding="utf-8")
source.path.chmod(source.mode)
self._create_guest_directory(disk, "/home", 0o755, 0, 0, exist_ok=True)
self._create_guest_directory(disk, home, 0o700, uid, gid)
for guest_path, source, _entry in updates:
if guest_path != "/etc/shadow": # Published with its password below.
self._write_guest_file(disk, guest_path, source)
def _create_guest_directory(
self,
disk: Path,
guest_path: str,
mode: int,
uid: int,
gid: int,
*,
exist_ok: bool = False,
) -> None:
command = ("debugfs", "-R", f"stat {guest_path}", disk)
existing = self._runner.run(command, check=False, capture=True)
if exist_ok and re.search(r"Type:\s+directory\b", existing.stdout):
return
if "file not found" not in f"{existing.stdout}\n{existing.stderr}".lower():
raise UvmError(f"cannot create guest rootfs directory: {guest_path}")
self._runner.run(("debugfs", "-w", "-R", f"mkdir {guest_path}", disk), capture=True)
for field, value in (
("mode", f"0{0o40000 | mode:o}"),
("uid", str(uid)),
("gid", str(gid)),
):
self._runner.run(
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", disk),
capture=True,
)
# debugfs can report command failures while still returning exit status zero.
created = self._runner.run(command, capture=True)
mode_match = _MODE_PATTERN.search(created.stdout)
owner_match = _OWNER_PATTERN.search(created.stdout)
if (
re.search(r"Type:\s+directory\b", created.stdout) is None
or mode_match is None
or owner_match is None
or int(mode_match.group(1), 8) != mode
or (int(owner_match.group(1)), int(owner_match.group(2))) != (uid, gid)
):
raise UvmError(f"could not verify guest rootfs directory: {guest_path}")
def _password_hash(self, password: str) -> str:
result = self._runner.run(
("openssl", "passwd", "-6", "-stdin"),
@@ -697,16 +185,12 @@ class ImageStore:
destination: Path,
*,
required: bool = True,
regular_only: bool = False,
) -> _GuestFile | None:
stat_result = self._runner.run(
("debugfs", "-R", f"stat {guest_path}", disk),
check=False,
capture=True,
)
file_type = re.search(r"Type:\s+(\S+)", stat_result.stdout)
if regular_only and file_type is not None and file_type.group(1) != "regular":
return None
mode_match = _MODE_PATTERN.search(stat_result.stdout)
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
@@ -868,13 +352,6 @@ class ImageStore:
raise UvmError("could not remove the insecure Firecracker demo SSH key")
def _next_guest_id(used: set[int]) -> int:
for candidate in range(1000, 60001):
if candidate not in used:
return candidate
raise UvmError("guest rootfs has no available regular user/group IDs")
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
lines = contents.splitlines()
for index, line in enumerate(lines):
@@ -895,16 +372,12 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
}
if username == "root":
managed_directives.add("permitrootlogin")
global_lines = []
match_lines = []
in_match = False
unmanaged_lines = []
for line in contents.splitlines():
stripped = line.lstrip()
directive = stripped.split(None, 1)[0].lower() if stripped else ""
if directive == "match":
in_match = True
if stripped.startswith("#") or directive not in managed_directives:
(match_lines if in_match else global_lines).append(line)
unmanaged_lines.append(line)
directives = [
"HostKey /etc/ssh/ssh_host_rsa_key",
@@ -916,16 +389,13 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
]
if username == "root":
directives.append("PermitRootLogin yes")
# Global-only directives (e.g. UsePAM and Subsystem) must precede every Match block.
globals_text = "\n".join(global_lines).strip("\n")
return (
"# Managed by uvm\n"
+ "\n".join(directives)
+ (f"\n{globals_text}" if globals_text else "")
+ f"\nMatch User {username}\n"
+ " PasswordAuthentication yes\n"
+ "Match all\n"
+ "\n".join(match_lines).rstrip()
+ "\n".join(unmanaged_lines).rstrip()
+ "\n"
)
+26 -241
View File
@@ -2,10 +2,7 @@
from __future__ import annotations
import hashlib
import json
import os
import shlex
import shutil
import tarfile
import tempfile
@@ -14,45 +11,14 @@ import urllib.request
from collections.abc import Callable
from pathlib import Path
from .config import Settings
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
from .errors import UvmError
from .images import GUEST_NETWORK_TEMPLATE, GuestAssets, guest_agent_files
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
from .images import GuestAssets
from .integrity import require_checksum, sha256_file, verify_file, write_manifest
from .state import StateStore
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
_UBUNTU_RELEASE = "jammy"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v5\n"
_GUEST_PACKAGES = (
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", "python3",
)
# Keep the image mountable by the older Firecracker quickstart guest kernels.
_EXT4_FEATURES = (
"none,has_journal,ext_attr,resize_inode,dir_index,filetype,extent,64bit,"
"flex_bg,sparse_super,large_file,huge_file,dir_nlink,extra_isize,metadata_csum"
)
_ROOTFS_NAMESPACE = (
"unshare", "--mount", "--propagation", "private", "--pid", "--fork",
"--kill-child", "--mount-proc",
)
_GUEST_APT_SCRIPT = """\
root=$1
shift
mount --rbind /dev "$root/dev"
mount --make-rslave "$root/dev"
mount -t proc proc "$root/proc"
mount -t sysfs -o ro sysfs "$root/sys"
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -o APT::Update::Error-Mode=any update
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -y --no-install-recommends install "$@"
"""
class Installer:
"""Install exactly the local host dependencies used by the uvm CLI."""
@@ -76,6 +42,7 @@ class Installer:
return self._install_locked(force_assets=force_assets)
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
self._validate_guest_asset_architecture()
self._validate_integrity_policy()
if self._settings.allow_unverified_downloads and self._emit is not None:
self._emit(
@@ -89,6 +56,16 @@ class Installer:
self._write_integrity_manifest(firecracker, assets)
return firecracker, assets
def _validate_guest_asset_architecture(self) -> None:
architecture = os.uname().machine
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
if architecture == "aarch64" and (default_kernel or default_rootfs):
raise UvmError(
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
"and UVM_ROOTFS_URL to compatible aarch64 assets"
)
def _validate_integrity_policy(self) -> None:
require_checksum(
self._settings.firecracker_sha256,
@@ -102,18 +79,12 @@ class Installer:
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
if self._settings.rootfs_url:
require_checksum(
self._settings.rootfs_sha256,
"guest root filesystem",
"UVM_ROOTFS_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
elif self._settings.rootfs_sha256:
raise UvmError(
"UVM_ROOTFS_SHA256 requires UVM_ROOTFS_URL for a prebuilt image; "
"unset UVM_ROOTFS_SHA256 to build the default Ubuntu rootfs"
)
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
write_manifest(
@@ -128,7 +99,7 @@ class Installer:
(
self._settings.firecracker_sha256,
self._settings.kernel_sha256,
self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
self._settings.rootfs_sha256,
)
),
)
@@ -141,20 +112,19 @@ class Installer:
"iptables",
"nftables",
"conntrack",
"coreutils",
"e2fsprogs",
"util-linux",
"ca-certificates",
"openssl",
"openssh-client",
)
if not self._settings.rootfs_url:
packages += ("debootstrap", "ubuntu-keyring")
self._runner.run(("apt-get", "update"))
self._runner.run(("apt-get", "install", "-y", *packages))
def install_firecracker_binary(self, *, force: bool = False) -> Path:
architecture = self._settings.architecture
architecture = os.uname().machine
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {architecture}")
firecracker = self._settings.firecracker_binary
jailer = self._settings.jailer_binary
@@ -225,16 +195,7 @@ class Installer:
label="guest kernel",
environment_name="UVM_KERNEL_SHA256",
)
verify_file(
kernel,
self._settings.kernel_sha256,
"guest kernel",
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
if not self._settings.rootfs_url:
self._install_ubuntu_rootfs(rootfs, force=force)
elif force or not rootfs.exists():
if force or not rootfs.exists():
self.download(
self._settings.rootfs_url,
rootfs,
@@ -242,7 +203,13 @@ class Installer:
label="guest root filesystem",
environment_name="UVM_ROOTFS_SHA256",
)
if self._settings.rootfs_url:
verify_file(
kernel,
self._settings.kernel_sha256,
"guest kernel",
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
verify_file(
rootfs,
self._settings.rootfs_sha256,
@@ -252,188 +219,6 @@ class Installer:
)
return GuestAssets(kernel=kernel, rootfs=rootfs)
def _install_ubuntu_rootfs(self, destination: Path, *, force: bool) -> None:
if destination.is_file() and not force:
manifest = load_manifest(self._settings.integrity_manifest_path)
checksum = manifest.checksums.get("rootfs")
if manifest.verified and checksum:
verify_file(destination, checksum, "Ubuntu rootfs", "integrity manifest", allow_unverified=False)
marker = self._runner.run(
("debugfs", "-R", "cat /etc/uvm-image", destination), capture=True,
)
if marker.stdout == _ROOTFS_RECIPE:
return
# Rebuild old demo images and unverified caches rather than promoting their trust.
self._build_ubuntu_rootfs(destination)
def _build_ubuntu_rootfs(self, destination: Path) -> None:
architecture = "arm64" if self._settings.architecture == "aarch64" else "amd64"
mirror = (
"https://ports.ubuntu.com/ubuntu-ports" if architecture == "arm64"
else "https://archive.ubuntu.com/ubuntu"
)
if self._emit is not None:
self._emit("Building Ubuntu 22.04 rootfs with sudo, OpenSSH, and apt/dpkg metadata")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
temporary = Path(name)
root = temporary / "root"
build_log = temporary / "build.log"
try:
# Bootstrap one consistent release; apt resolves updated package versions later.
self._run_rootfs_stage("Ubuntu bootstrap", (
*_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive",
"debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
"--components=main", "--include=ca-certificates,ubuntu-keyring",
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
_UBUNTU_RELEASE, root, mirror,
), build_log)
self._install_ubuntu_packages(root, mirror, build_log)
self._configure_ubuntu_rootfs(root)
self._validate_ubuntu_rootfs(root)
disk = temporary / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(2 * 1024 * 1024 * 1024)
disk.chmod(0o600)
self._run_rootfs_stage("Ubuntu ext4 formatting", (
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
), build_log)
self._run_rootfs_stage("Ubuntu filesystem check", ("e2fsck", "-f", "-n", disk), build_log)
except (UvmError, OSError, UnicodeError) as error:
try:
log = self._save_rootfs_build_log(destination, root, build_log, error=error)
except OSError as log_error:
raise UvmError(f"Ubuntu rootfs build failed: {error}; could not save diagnostics: {log_error}") from error
raise UvmError(f"Ubuntu rootfs build failed: {error}; details saved to {log}") from error
self._save_rootfs_build_log(destination, root, build_log)
with disk.open("rb") as image:
os.fsync(image.fileno())
os.replace(disk, destination)
directory = os.open(destination.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except (OSError, UnicodeError) as error:
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error
def _install_ubuntu_packages(self, root: Path, mirror: str, build_log: Path) -> None:
sources = root / "etc/apt/sources.list"
sources.write_text("".join(
f"deb {mirror} {suite} main universe\n"
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security")
), encoding="utf-8")
sources.chmod(0o644)
# Ubuntu's debootstrap script removes its own policy-rc.d on success.
policy = root / "usr/sbin/policy-rc.d"
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
policy.chmod(0o755)
try:
self._run_rootfs_stage("Ubuntu guest package installation", (
*_ROOTFS_NAMESPACE, "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
"uvm-guest-packages", root, *_GUEST_PACKAGES,
), build_log)
finally:
policy.unlink(missing_ok=True)
def _run_rootfs_stage(self, stage: str, command: tuple[str | Path, ...], log: Path) -> None:
if self._emit is not None:
self._emit(stage)
with log.open("a", encoding="utf-8") as output:
output.write(f"\n=== {stage} ===\n+ {shlex.join(map(str, command))}\n")
result = self._runner.run(command, check=False, capture=True)
output.write(result.stdout)
output.write(result.stderr)
if result.returncode != 0:
raise UvmError(f"{stage} failed (exit {result.returncode})")
@staticmethod
def _save_rootfs_build_log(destination: Path, root: Path, build_log: Path, *, error: Exception | None = None) -> Path:
log = destination.with_name("ubuntu-build.log")
descriptor, name = tempfile.mkstemp(prefix=".ubuntu-log-", dir=destination.parent)
temporary = Path(name)
try:
with os.fdopen(descriptor, "wb") as output:
# Completed bootstrap details precede apt output; failed bootstrap details follow it.
for source in (root / "var/log/bootstrap.log", build_log, root / "debootstrap/debootstrap.log"):
if source.is_file():
output.write(f"\n=== {source.name} ===\n".encode("utf-8"))
with source.open("rb") as content:
shutil.copyfileobj(content, output)
if error is not None:
output.write(f"\nUbuntu rootfs build failed: {error}\n".encode("utf-8", errors="replace"))
output.flush()
os.fsync(output.fileno())
os.replace(temporary, log)
directory = os.open(log.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return log
def _configure_ubuntu_rootfs(self, root: Path) -> None:
files = {
"etc/fstab": "/dev/vda / ext4 defaults 0 1\n",
"etc/hostname": "uvm\n",
"etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
"etc/machine-id": "",
"etc/uvm-image": _ROOTFS_RECIPE,
"etc/systemd/network/10-uvm.network": GUEST_NETWORK_TEMPLATE,
}
for relative, contents in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.unlink(missing_ok=True) # In particular, replace a resolver symlink with guest DNS.
path.write_text(contents, encoding="utf-8")
path.chmod(0o644)
machine_id = root / "var/lib/dbus/machine-id"
machine_id.parent.mkdir(parents=True, exist_ok=True)
machine_id.unlink(missing_ok=True)
machine_id.symlink_to("../../../etc/machine-id")
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
key.unlink()
agent_payloads = guest_agent_files()
for guest_path, contents in agent_payloads.items():
path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.parent.chmod(0o755)
path.write_bytes(contents)
path.chmod(0o644)
manifest = root / "etc/uvm-agent-files.json"
manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()},
sort_keys=True) + "\n", encoding="utf-8")
manifest.chmod(0o600)
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service",
"uvm-agent.service"), capture=True)
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
@staticmethod
def _validate_ubuntu_rootfs(root: Path) -> None:
for relative in (
"etc/passwd", "etc/shadow", "etc/group", "etc/sudoers", "etc/ssh/sshd_config",
"usr/bin/sudo", "usr/bin/apt-get", "usr/sbin/sshd", "usr/lib/systemd/systemd",
"var/lib/dpkg/status", "var/lib/dpkg/info/apt.list", "var/lib/dpkg/info/sudo.list",
"usr/bin/python3", "usr/lib/uvm/agent.py", "etc/systemd/system/uvm-agent.service",
):
if not (root / relative).is_file():
raise UvmError(f"built Ubuntu rootfs is missing /{relative}")
installed = set()
for paragraph in (root / "var/lib/dpkg/status").read_text(encoding="utf-8").split("\n\n"):
fields = dict(line.split(": ", 1) for line in paragraph.splitlines() if ": " in line)
if fields.get("Status") == "install ok installed":
installed.add(fields.get("Package"))
if not {"apt", "sudo", "openssh-server", "systemd-sysv", "python3"}.issubset(installed):
raise UvmError("built Ubuntu rootfs has incomplete apt/dpkg package metadata")
if not (root / "usr/bin/sudo").stat().st_mode & 0o4000:
raise UvmError("built Ubuntu rootfs sudo is missing its setuid permission")
def download(
self,
url: str,
-169
View File
@@ -1,169 +0,0 @@
"""Remove a deleted VM's entries from local OpenSSH host-key caches."""
from __future__ import annotations
import base64
import binascii
import fcntl
import hmac
import os
import pwd
import re
import stat
import uuid
from ipaddress import IPv4Address
from pathlib import Path
from .config import Settings
from .domain import VmRecord
from .errors import UvmError
_HOST_LINE = re.compile(
rb"^[ \t]*(?:@(?:cert-authority|revoked)[ \t]+)?"
rb"(?P<hosts>[^ \t\r\n]+)[ \t]+[^ \t\r\n]+[ \t]+[^ \t\r\n]+"
)
_PORT_HOST = re.compile(rb"\[([^\]]+)\]:(\d+)\Z")
class KnownHostsManager:
"""Deletion-only cleanup; lifecycle owns the lock and confirms VM teardown."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
def remove(self, vm: VmRecord) -> None:
try:
address = str(IPv4Address(vm.guest_ip)).encode("ascii")
except ValueError as error:
raise UvmError("known_hosts cleanup requires a valid guest IPv4 address") from error
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
raise UvmError("known_hosts cleanup requires a valid VM ID")
alias = f"uvm-{vm.id}".encode("ascii")
hosts = {address, alias, alias.lower()}
ports = {22, *(rule.port for rule in vm.ports or ())}
hashed_candidates = hosts | {
b"[" + host + b"]:" + str(port).encode("ascii") for host in hosts for port in ports
}
for path, owner in self._files().items():
self._remove_file(path, hosts, hashed_candidates, owner)
def _files(self) -> dict[Path, int | None]:
if self._settings.known_hosts_files is not None:
return dict.fromkeys(self._settings.known_hosts_files)
try:
effective_uid = os.geteuid()
accounts = [pwd.getpwuid(effective_uid)]
if effective_uid == 0:
sudo_uid = os.environ.get("SUDO_UID")
sudo_user = os.environ.get("SUDO_USER")
if sudo_uid:
if not sudo_uid.isascii() or not sudo_uid.isdigit():
raise ValueError("invalid SUDO_UID")
accounts.append(pwd.getpwuid(int(sudo_uid)))
elif sudo_user:
accounts.append(pwd.getpwnam(sudo_user))
return {
Path(account.pw_dir) / ".ssh" / filename: account.pw_uid
for account in accounts for filename in ("known_hosts", "known_hosts2")
}
except (KeyError, ValueError, OverflowError) as error:
raise UvmError("could not determine SSH user homes; configure UVM_KNOWN_HOSTS_FILES") from error
@staticmethod
def _remove_file(path: Path, hosts: set[bytes], candidates: set[bytes], owner: int | None) -> None:
directory = None
temporary = None
try:
# Pin the directory and reject symlink/FIFO/hard-link targets when
# editing another local user's cache from a privileged process.
try:
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
except FileNotFoundError:
return
with os.fdopen(descriptor, "rb") as source:
metadata = os.fstat(source.fileno())
if not stat.S_ISREG(metadata.st_mode):
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
fcntl.flock(source.fileno(), fcntl.LOCK_EX)
metadata = os.fstat(source.fileno())
if metadata.st_nlink != 1:
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
if owner is not None and metadata.st_uid != owner:
raise UvmError(f"known_hosts has an unexpected owner: {path}")
contents = source.read()
updated = _without_hosts(contents, hosts, candidates)
if updated == contents:
return
candidate = f".{path.name}.uvm-{uuid.uuid4().hex}"
descriptor = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory)
temporary = candidate
with os.fdopen(descriptor, "wb") as output:
output.write(updated)
output.flush()
created = os.fstat(output.fileno())
if (created.st_uid, created.st_gid) != (metadata.st_uid, metadata.st_gid):
os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid)
os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode))
for attribute in os.listxattr(source.fileno()):
os.setxattr(output.fileno(), attribute, os.getxattr(source.fileno(), attribute))
os.fsync(output.fileno())
current = os.stat(path.name, dir_fd=directory, follow_symlinks=False)
fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode", "st_uid", "st_gid", "st_nlink")
if any(getattr(current, field) != getattr(metadata, field) for field in fields):
raise UvmError(f"known_hosts changed during cleanup; retry deletion: {path}")
os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory)
os.fsync(directory)
except OSError as error:
raise UvmError(f"could not clean SSH known_hosts {path}: {error}") from error
finally:
if directory is not None:
try:
if temporary is not None:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
finally:
os.close(directory)
def _without_hosts(contents: bytes, hosts: set[bytes], candidates: set[bytes]) -> bytes:
"""Preserve unrelated aliases, comments, markers, key types, and line endings."""
def matches(token: bytes) -> bool:
if token.startswith(b"|1|"):
try:
_, _version, salt_text, digest_text = token.split(b"|")
salt = base64.b64decode(salt_text, validate=True)
digest = base64.b64decode(digest_text, validate=True)
except (ValueError, binascii.Error):
return False
return len(salt) == len(digest) == 20 and any(
hmac.compare_digest(digest, hmac.digest(salt, candidate, "sha1")) for candidate in candidates
)
token = token.lower()
if token in hosts:
return True
bracketed = _PORT_HOST.fullmatch(token)
return (bracketed is not None and bracketed[1] in hosts
and len(bracketed[2]) <= 5 and 1 <= int(bracketed[2]) <= 65535)
retained = []
for line in contents.splitlines(keepends=True):
if line.lstrip().startswith(b"#"):
retained.append(line)
continue
match = _HOST_LINE.match(line)
if match is None:
retained.append(line)
continue
names = match["hosts"].split(b",")
remaining = [name for name in names if not matches(name)]
if remaining == names:
retained.append(line)
elif remaining:
retained.append(line[:match.start("hosts")] + b",".join(remaining) + line[match.end("hosts"):])
return b"".join(retained)
+22 -207
View File
@@ -11,24 +11,18 @@ from dataclasses import dataclass, replace
from ipaddress import IPv4Network
from pathlib import Path
from .config import MIN_STORAGE_MIB, Settings
from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
from .config import Settings
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
from .guest_agent import DEFAULT_TIMEOUT_SECONDS
from .guest_exec import ExecResult, GuestSession, validate_exec
from .guest_terminal import GuestTerminal, validate_size
from .images import ImageStore
from .integrity import load_manifest, verify_file
from .known_hosts import KnownHostsManager
from .network import NetworkManager
from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root
from .tool_catalog import normalize_tools
from .tool_store import ToolJobStore
from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username
@dataclass(frozen=True, slots=True)
@@ -61,10 +55,7 @@ class LifecycleService:
def __init__(
self, settings: Settings, state_store: StateStore, images: ImageStore,
network: NetworkManager, process: FirecrackerProcessManager, *,
known_hosts: KnownHostsManager,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
guest_connector: Callable[[Path, str, str, int], GuestSession] = GuestSession.connect,
terminal_connector: Callable[[Path, str, str, int], GuestTerminal] = GuestTerminal.connect,
clock: Callable[[], float] = time.time,
) -> None:
self._settings = settings
@@ -72,22 +63,19 @@ class LifecycleService:
self._images = images
self._network = network
self._process = process
self._known_hosts = known_hosts
self._client_factory = client_factory
self._guest_connector = guest_connector
self._terminal_connector = terminal_connector
self._clock = clock
self._tool_store = ToolJobStore(settings, state_store, clock=clock)
def create(self, spec: VmSpec) -> VmRecord:
require_root()
VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
parse_cpu(str(spec.cpu))
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
parse_username(spec.username)
parse_password(spec.password)
if spec.name is not None:
parse_name(spec.name)
ports = self._validated_ports(spec.ports)
tools = normalize_tools(spec.tools, allow_empty=True)
ensure_data_directories(self._settings)
with self._state_store.operation_lock():
self._check_boot()
@@ -98,14 +86,10 @@ class LifecycleService:
runtime.mkdir(mode=0o700, exist_ok=False)
vm.runtime_owned = True
self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
self._images.provision_credentials(disk, vm.username, vm.password)
self._images.configure_guest_network(disk, vm.guest_ip)
self._images.provision_guest_agent(disk, vm.id, vm.username)
vm.disk_ready = True
self._save(vm)
if tools:
self._tool_store.enqueue_locked(vm.id, tools, source="create")
self._boot(vm, assets.kernel)
return vm
except BaseException as error:
@@ -180,7 +164,7 @@ class LifecycleService:
with self._state_store.operation_lock():
return self._start_locked(self._load_for_operation(identifier))
def _start_locked(self, vm: VmRecord, *, kernel: Path | None = None) -> VmRecord:
def _start_locked(self, vm: VmRecord) -> VmRecord:
self._not_deleting(vm)
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
@@ -188,23 +172,22 @@ class LifecycleService:
if observation == "unknown":
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
if observation == "alive":
if vm.pending_resize is not None:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
self._validate_retained_network(vm)
vm = self._sync_ports_locked(vm)
self._network.ensure_bridge()
return vm
self._validate_resume(vm)
if kernel is None:
return self._sync_ports_locked(vm)
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
self._check_boot()
kernel = self._images.installed_kernel()
try:
self._cleanup_runtime(vm)
self._apply_pending_resize(vm)
self._images.configure_guest_network(Path(vm.disk), vm.guest_ip)
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel)
return vm
@@ -226,13 +209,12 @@ class LifecycleService:
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
deleting = vm.operation == "delete" or vm.status == "terminating"
pending = "delete" if deleting else "resize" if vm.pending_resize is not None else None
vm.status, vm.operation = "stopping", pending or "stop"
vm.status, vm.operation = "stopping", "delete" if deleting else "stop"
self._save(vm)
try:
self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped"
vm.operation = pending
vm.operation = "delete" if deleting else None
vm.last_error = None
self._save(vm)
return vm
@@ -250,76 +232,6 @@ class LifecycleService:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm)
def resize(
self, identifier: str, *, cpu: float | None = None, ram_mib: int | None = None,
storage_mib: int | None = None, timeout: float | None = None,
) -> VmRecord:
"""Stop, apply resource changes to the retained disk, then boot under one lock."""
require_root()
if cpu is None and ram_mib is None and storage_mib is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
VmResources(cpu if cpu is not None else 1, ram_mib if ram_mib is not None else 128,
storage_mib if storage_mib is not None else MIN_STORAGE_MIB)
if timeout is not None:
timeout = parse_timeout(str(timeout))
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._validate_resume(vm)
if self._process.observe(vm) == "unknown":
raise ConflictError(f"cannot resize {vm.id}: process identity is unknown")
# Verify boot prerequisites before stopping an otherwise healthy VM.
self._check_boot()
kernel = self._images.installed_kernel()
previous = vm.pending_resize
capacity = previous.storage_mib if previous is not None else vm.storage_mib
if capacity is None:
capacity = max(MIN_STORAGE_MIB, (self._images.disk_size_bytes(Path(vm.disk)) + 1024 * 1024 - 1) // (1024 * 1024))
vm.pending_resize = VmResources(
cpu=cpu if cpu is not None else previous.cpu if previous is not None else vm.cpu,
ram_mib=ram_mib if ram_mib is not None else previous.ram_mib if previous is not None else vm.ram_mib,
storage_mib=storage_mib if storage_mib is not None else capacity,
)
vm.operation = "resize"
self._save(vm)
try:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm, kernel=kernel)
except UvmError as error:
raise UvmError(f"failed to resize {vm.id}: {error}") from error
def _validate_resume(self, vm: VmRecord) -> None:
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
self._validate_retained_network(vm)
def _validate_retained_network(self, vm: VmRecord) -> None:
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
def _apply_pending_resize(self, vm: VmRecord) -> None:
target = vm.pending_resize
if target is None:
return
if self._process.observe(vm) != "gone":
raise ConflictError("cannot resize a disk without confirmed process exit")
# A prior attempt may have published a resized image without saving
# its allocation. Check the disk too, including when a retry changes
# the target back to the previously recorded capacity.
if (target.storage_mib != vm.storage_mib
or self._images.disk_size_bytes(Path(vm.disk)) != target.storage_mib * 1024 * 1024):
self._images.resize_vm_disk(Path(vm.disk), target.storage_mib)
vm.cpu, vm.ram_mib, vm.storage_mib = target.cpu, target.ram_mib, target.storage_mib
vm.pending_resize = None
self._save(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root()
with self._state_store.operation_lock():
@@ -331,7 +243,6 @@ class LifecycleService:
try:
self._cleanup_runtime(vm, timeout=timeout)
self._remove_runtime(vm)
self._known_hosts.remove(vm)
self._remove_record(vm.id)
return vm
except BaseException as error:
@@ -509,99 +420,6 @@ class LifecycleService:
def find_for_ssh(self, identifier: str) -> VmRecord:
return self._state_store.find_by_id_or_ip(identifier)
def execute(
self, vm_id: str, command: str, *, sudo: bool = False,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
expected_identity: tuple | None = None,
on_connect: Callable[[GuestSession], None] | None = None,
) -> ExecResult:
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
require_root()
validate_exec(command, sudo, timeout_seconds)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
if expected_identity is not None and expected_identity != (vm.pid, vm.process_start_time, vm.boot_id):
raise ConflictError("the VM restarted during tool installation; retry on the current boot")
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
if on_connect is not None:
on_connect(session)
# This session stays attached to that VMM even if the VM is stopped,
# deleted, or restarted concurrently. Never reconnect/retry a command.
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
finally:
session.close()
def _validate_tool_target(self, vm: VmRecord, *, start_if_stopped: bool, restart: bool) -> str:
self._not_deleting(vm)
observation = self._process.observe(vm)
if vm.operation is not None or vm.pending_resize is not None or not vm.disk_ready:
raise ConflictError("finish pending lifecycle operations before installing tools")
if vm.status == "running" and observation == "alive":
return "running"
if vm.status == "stopped" and observation == "gone" and (start_if_stopped or restart):
self._validate_resume(vm)
return "stopped"
raise ConflictError("tool installation requires a running VM; use Start & install for a stopped VM")
def queue_tools(self, vm_id: str, selections, *, start_if_stopped=False, restart=False, request_id=None) -> dict:
require_root()
selections = normalize_tools(selections)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
return self._tool_store.enqueue_locked(vm.id, selections, start_if_stopped=start_if_stopped,
restart=restart, request_id=request_id)
def prepare_tool_install(self, vm_id: str, *, start_if_stopped=False, restart=False) -> VmRecord:
"""Perform requested startup/agent refresh atomically before a tool worker connects."""
require_root()
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
status = self._validate_tool_target(vm, start_if_stopped=start_if_stopped, restart=restart)
if status == "running" and restart:
self._stop_locked(vm)
if status == "stopped" or restart:
# The normal retained-disk boot also provisions the current agent.
vm = self._start_locked(vm)
return vm
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
require_root()
validate_size(rows, cols)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("terminal sessions require a running VM with no pending lifecycle operation")
terminal = self._terminal_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
terminal.start(rows, cols)
return terminal, vm.username
except BaseException:
terminal.close()
raise
def install_guest_agent(self, vm_id: str) -> VmRecord:
"""Explicitly install/upgrade the agent on a cleanly stopped private disk."""
require_root()
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "stopped" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "gone"):
raise ConflictError("stop the VM and finish pending operations before installing the guest agent")
if not vm.disk_ready or not vm.disk:
raise ConflictError("guest agent installation requires a completely provisioned private disk")
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username, install=True)
return vm
def log_path(self, identifier: str) -> Path:
vm = self._state_store.find_by_id_or_ip(identifier)
self._validate_paths(vm)
@@ -654,8 +472,7 @@ class LifecycleService:
results.append({"name": name, "ok": True, "message": "available"})
except (UvmError, OSError) as error:
results.append({"name": name, "ok": False, "message": str(error)})
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "e2fsck", "resize2fs", "dumpe2fs",
"cp", "openssl", "ssh-keygen", "ssh"):
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results
@@ -674,8 +491,6 @@ class LifecycleService:
directory = self._settings.vm_dir(vm.id).absolute()
if directory.is_symlink():
raise ConflictError("VM directory must not be a symbolic link")
if self._settings.guest_socket_path(vm.id).is_symlink():
raise ConflictError("unsafe guest vsock path")
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
path = getattr(vm, field)
@@ -714,7 +529,7 @@ class LifecycleService:
state.next_mac_index += 1
directory = self._settings.vm_dir(vm_id)
vm = VmRecord(
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, storage_mib=spec.storage_mib,
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib,
guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
network=str(self._settings.network), bridge=self._settings.bridge,
tap=self._network.tap_name(vm_id), mac=mac,
+102 -29
View File
@@ -9,7 +9,7 @@ from ipaddress import IPv4Address
from .config import Settings
from .domain import VmRecord
from .errors import CommandError, ConflictError, UvmError, ValidationError
from .errors import ConflictError, UvmError, ValidationError
from .firewall import FirewallManager
from .system import CommandRunner
@@ -178,38 +178,111 @@ class NetworkManager:
raise UvmError(f"could not delete TAP device {name}{suffix}")
def _ensure_masquerade(self) -> None:
# Match the guest network, not a snapshot of the host's default uplink.
# Routing selects the current interface for each destination, even after
# an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN.
bridge = self._settings.bridge
network = str(self._settings.network)
owner = ("-m", "comment", "--comment", "uvm-egress")
self._ensure_iptables_rule(
"nat", "POSTROUTING",
("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"),
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
match = re.search(r"\bdev\s+(\S+)", route.stdout)
if match is None:
return
uplink = match.group(1)
rule = (
"iptables",
"-t",
"nat",
"-C",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
present = self._runner.run(rule, check=False)
if present.returncode != 0:
self._runner.run(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
# Native bridge policy still checks guest identity and private ports.
# This also permits peer traffic when br_netfilter is already enabled.
self._ensure_iptables_rule(
"filter", "FORWARD",
("-i", bridge, "-s", network, *owner, "-j", "ACCEPT"),
)
self._ensure_iptables_rule(
"filter", "FORWARD",
("-o", bridge, "-d", network, "-m", "conntrack",
"--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"),
(
"iptables",
"-C",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
)
def _ensure_iptables_rule(
self, table: str, chain: str, rule: tuple[str, ...],
self,
check_rule: tuple[str, ...],
add_rule: tuple[str, ...],
) -> None:
command = ("iptables", "--wait", "5", "-t", table)
present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True)
if present.returncode == 0:
return
if present.returncode != 1:
detail = present.stderr.strip() or present.stdout.strip()
raise CommandError(f"could not inspect UVM {table}/{chain} rule ({present.returncode}): {detail}")
# Scoped exceptions must precede terminal drops in the host's chains.
# Keep unrelated rules and the global forwarding policy intact.
self._runner.run((*command, "-I", chain, "1", *rule), capture=True)
present = self._runner.run(check_rule, check=False)
if present.returncode != 0:
self._runner.run(add_rule)
@@ -1,4 +1,4 @@
"""Nest dashboard document route."""
"""Dashboard frontend routes."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from fastapi.responses import HTMLResponse
router = APIRouter()
_DASHBOARD_HTML = Path(__file__).parent / "static" / "index.html"
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
@router.get("", response_class=HTMLResponse)
-35
View File
@@ -1,35 +0,0 @@
"""Authenticated graceful shutdown endpoint for the UVM API server."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from ..api_models import ServerExitResponse
from ..app import Application
from .dependencies import get_authorized_application
router = APIRouter(tags=["server"])
@router.post("/exit", response_model=ServerExitResponse, status_code=status.HTTP_202_ACCEPTED)
def exit_server(
background_tasks: BackgroundTasks,
request: Request,
_application: Application = Depends(get_authorized_application),
) -> ServerExitResponse:
"""Stop the Uvicorn process after this response has been delivered."""
server = getattr(request.app.state, "uvicorn_server", None)
if server is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="server shutdown is unavailable outside nest --serve or uvm --serve",
)
background_tasks.add_task(_request_server_exit, server)
return ServerExitResponse()
def _request_server_exit(server: Any) -> None:
server.should_exit = True
-46
View File
@@ -1,46 +0,0 @@
"""Authenticated tool catalog, installation jobs, and guest inventory."""
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from ..api_models import ToolCatalogResponse, ToolInstallRequest, ToolJobResponse, VmToolsResponse
from ..app import Application
from ..errors import UvmError
from ..tool_catalog import CATALOG, normalize_tools
from .dependencies import get_authorized_application
router = APIRouter(tags=["tools"])
def tool_service(application):
service = getattr(application, "tools", None)
if service is None:
raise UvmError("guest tool installation is unavailable in this application")
return service
@router.get("/tools", response_model=list[ToolCatalogResponse])
def catalog(application: Application = Depends(get_authorized_application)):
return list(CATALOG)
@router.get("/vms/{vm_id}/tools", response_model=VmToolsResponse)
def vm_tools(vm_id: str, refresh: bool = False, application: Application = Depends(get_authorized_application)):
"""Return cached observations; refresh=true probes the running guest without installing."""
return tool_service(application).snapshot(vm_id, refresh=refresh)
@router.post("/vms/{vm_id}/tools/install", response_model=ToolJobResponse, status_code=status.HTTP_202_ACCEPTED)
def install_tools(vm_id: str, request: ToolInstallRequest,
application: Application = Depends(get_authorized_application)):
return tool_service(application).submit(
vm_id, normalize_tools([item.model_dump() for item in request.tools]),
start_if_stopped=request.start_if_stopped, restart=request.restart, request_id=request.request_id,
)
@router.get("/vms/{vm_id}/tools/jobs/{job_id}", response_model=ToolJobResponse)
def tool_job(vm_id: str, job_id: str, application: Application = Depends(get_authorized_application)):
return tool_service(application).store.get_job(vm_id, job_id)
-60
View File
@@ -9,32 +9,24 @@ from ..api_models import (
PortSyncResponse,
PortUpdateRequest,
VmActionResultResponse,
VmAgentInstallResponse,
VmBulkActionRequest,
VmCreateRequest,
VmExecRequest,
VmExecResponse,
VmLogResponse,
VmResponse,
VmResizeRequest,
vm_response,
)
from ..app import Application
from ..domain import PortRule, VmRecord, VmSpec
from ..errors import ValidationError
from ..tool_catalog import normalize_tools
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_storage,
parse_timeout,
parse_username,
)
from .dependencies import get_authorized_application
from .tools import tool_service
router = APIRouter(prefix="/vms", tags=["vms"])
@@ -75,24 +67,17 @@ def create_vm(
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()])
if not request.no_default_ports and not any(rule.port == 22 for rule in ports):
ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port))
tools = normalize_tools([item.model_dump() for item in request.tools], allow_empty=True)
if tools:
tool_service(application).start()
vm = application.lifecycle.create(
VmSpec(
cpu=parse_cpu(str(request.cpu)),
ram_mib=parse_ram(str(request.ram)),
storage_mib=parse_storage(str(request.storage)),
guest_ip=request.guest_ip,
username=parse_username(request.username),
password=parse_password(request.password.get_secret_value()),
name=parse_name(request.name) if request.name is not None else None,
ports=ports,
tools=tools,
)
)
if tools:
tool_service(application).notify()
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@@ -156,25 +141,6 @@ def stop_vm(
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/resize", response_model=VmResponse)
def resize_vm(
vm_id: str,
request: VmResizeRequest,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
if request.cpu is None and request.ram is None and request.storage is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
vm = application.lifecycle.resize(
vm_id,
cpu=parse_cpu(str(request.cpu)) if request.cpu is not None else None,
ram_mib=parse_ram(str(request.ram)) if request.ram is not None else None,
storage_mib=parse_storage(str(request.storage)) if request.storage is not None else None,
timeout=parse_timeout(str(timeout)) if timeout is not None else None,
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm(
vm_id: str,
@@ -213,32 +179,6 @@ def sync_ports(
)
@router.post("/{vm_id}/exec", response_model=VmExecResponse)
def execute_in_vm(
vm_id: str,
request: VmExecRequest,
application: Application = Depends(get_authorized_application),
) -> VmExecResponse:
"""Execute a bounded, non-interactive guest command over vsock (exact VM ID)."""
result = application.lifecycle.execute(
vm_id, request.command, sudo=request.sudo, timeout_seconds=request.timeout_seconds,
)
return VmExecResponse(
exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr,
timed_out=result.timed_out, truncated=result.truncated,
)
@router.post("/{vm_id}/agent/install", response_model=VmAgentInstallResponse)
def install_guest_agent(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmAgentInstallResponse:
"""Install/upgrade the execution agent on a stopped VM's retained private disk."""
vm = application.lifecycle.install_guest_agent(vm_id)
return VmAgentInstallResponse(id=vm.id)
@router.get("/{identifier}/logs", response_model=VmLogResponse)
def get_vm_logs(
identifier: str,
+15 -43
View File
@@ -2,18 +2,13 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import asynccontextmanager
from ipaddress import ip_address
from pathlib import Path
import ssl
from typing import Any
from .app import Application, build_application
from .errors import (
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
NotFoundError, UvmError, ValidationError,
)
from .errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
@@ -32,66 +27,50 @@ def create_api(application: Application | None = None, *, host: str | None = Non
try:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
except ModuleNotFoundError as error:
raise UvmError(
"FastAPI server support is not installed. Install this project with its dependencies."
) from error
from .routers.health import router as health_router
from .routers.exit import router as exit_router
from .routers.installation import router as installation_router
from .routers.vms import router as vms_router
from .routers.diagnostics import router as diagnostics_router
from .routers.tools import router as tools_router
@asynccontextmanager
async def lifespan(_api):
tools = getattr(resolved_application, "tools", None)
if tools is not None:
await run_in_threadpool(tools.start)
try:
yield
finally:
if tools is not None:
await run_in_threadpool(tools.shutdown)
from .routers.dashboard import router as dashboard_router
api = FastAPI(
title="uvm",
version="0.1.0",
description="Local Firecracker microVM management API.",
lifespan=lifespan,
)
api.state.uvm_application = resolved_application
@api.exception_handler(UvmError)
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
detail: dict[str, Any] = {"message": str(error)}
if isinstance(error, GuestExecutionError):
detail["outcome_unknown"] = error.outcome_unknown
return JSONResponse(
status_code=_http_status_for(error),
content={"error": detail},
content={"error": {"message": str(error)}},
)
api.include_router(health_router)
api.include_router(exit_router)
api.include_router(installation_router)
api.include_router(vms_router)
api.include_router(diagnostics_router)
api.include_router(tools_router)
static_dir = Path(__file__).parent / "dashboard" / "static"
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
api.include_router(dashboard_router, prefix="/dashboard")
@api.get("/")
async def root() -> Any:
return RedirectResponse(url="/dashboard")
return api
def run_server(
application: Application,
*,
host: str,
port: int,
api_factory: Callable[..., Any] | None = None,
) -> None:
def run_server(application: Application, *, host: str, port: int) -> None:
"""Run Uvicorn after enforcing the local-management security boundary."""
settings = application.settings
@@ -112,10 +91,7 @@ def run_server(
if settings.api_tls_cert is not None:
options["ssl_certfile"] = str(settings.api_tls_cert)
options["ssl_keyfile"] = str(settings.api_tls_key)
api = (api_factory or create_api)(application, host=host)
server = uvicorn.Server(uvicorn.Config(api, **options))
api.state.uvicorn_server = server
server.run()
uvicorn.run(create_api(application, host=host), **options)
def _is_loopback_host(host: str) -> bool:
@@ -152,10 +128,6 @@ def _validate_server_settings(
def _http_status_for(error: UvmError) -> int:
if isinstance(error, GuestAgentUnavailableError):
return 503
if isinstance(error, GuestExecutionError):
return 502
if isinstance(error, NotFoundError):
return 404
if isinstance(error, ConflictError):
-162
View File
@@ -1,162 +0,0 @@
"""The allowlisted guest tool catalog and pinned, architecture-specific recipes.
Download digests come from the publishers' release SHA256SUMS/.sha256 files:
Node v24.21.0, python-build-standalone 20260924, Miniforge 26.5.3-0, and
micromamba-releases 2.9.0-0. Update versions, URLs, and digests together.
"""
from __future__ import annotations
from dataclasses import dataclass
from .errors import ValidationError
CATALOG = (
{"id": "git", "name": "Git", "description": "Version control for your projects.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "nodejs", "name": "Node.js", "description": "JavaScript runtime with npm and npx.",
"version_label": "24 LTS · 24.21.0", "versions": [], "source": "nodejs.org"},
{"id": "python", "name": "Python", "description": "Side-by-side Python runtimes with pip and venv.",
"version_label": "3.11 / 3.12 / 3.13", "versions": ["3.11", "3.12", "3.13"],
"source": "Astral python-build-standalone"},
{"id": "jdk", "name": "JDK", "description": "OpenJDK development kit: java and javac.",
"version_label": "OpenJDK 21 LTS", "versions": [], "source": "Ubuntu"},
{"id": "wget", "name": "wget", "description": "Download files from the command line.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "curl", "name": "curl", "description": "Transfer data and call HTTP APIs.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "htop", "name": "htop", "description": "Interactive process and resource monitor.",
"version_label": "Ubuntu package", "versions": [], "source": "Ubuntu"},
{"id": "php", "name": "PHP", "description": "PHP command-line runtime.",
"version_label": "8.5 CLI", "versions": [], "source": "Ondřej Surý PHP PPA"},
{"id": "conda", "name": "Conda", "description": "Miniforge with the conda-forge package channel.",
"version_label": "Miniforge 26.5.3-0", "versions": [], "source": "conda-forge"},
{"id": "micromamba", "name": "micromamba", "description": "Lightweight Conda-compatible environment manager.",
"version_label": "2.9.0", "versions": [], "source": "mamba-org"},
)
TOOL_IDS = frozenset(tool["id"] for tool in CATALOG)
MAX_SELECTIONS = 12
@dataclass(frozen=True, slots=True)
class ToolSelection:
id: str
version: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.id, str) or self.id not in TOOL_IDS:
raise ValidationError("unknown tool; choose a tool from GET /tools")
if self.id == "python":
if self.version not in ("3.11", "3.12", "3.13"):
raise ValidationError("Python requires version 3.11, 3.12, or 3.13")
elif self.version is not None:
raise ValidationError("only Python accepts a version; other tools use the catalog default")
@property
def key(self) -> str:
return f"{self.id}-{self.version}" if self.version else self.id
def to_dict(self) -> dict:
return {"id": self.id, "version": self.version}
def normalize_tools(values: object, *, allow_empty: bool = False) -> tuple[ToolSelection, ...]:
if not isinstance(values, (list, tuple)) or len(values) > MAX_SELECTIONS:
raise ValidationError(f"tools must be a list of at most {MAX_SELECTIONS} catalog selections")
result = {}
for value in values:
if isinstance(value, ToolSelection):
selection = ToolSelection(value.id, value.version)
elif isinstance(value, dict) and "id" in value and not set(value) - {"id", "version"}:
selection = ToolSelection(value["id"], value.get("version"))
else:
raise ValidationError("each tool selection requires an id and optional Python version")
result[selection.key] = selection
if not result and not allow_empty:
raise ValidationError("select at least one tool to install")
# Stable ordering also makes duplicate requests independent of checkbox order.
order = {tool["id"]: index for index, tool in enumerate(CATALOG)}
return tuple(sorted(result.values(), key=lambda item: (order[item.id], item.version or "")))
def all_selections() -> tuple[ToolSelection, ...]:
return tuple(ToolSelection(tool["id"], version) for tool in CATALOG for version in tool["versions"] or [None])
_PYTHON_RELEASE = "20260924"
_PYTHON = {
"3.11": ("3.11.16", {
"x86_64": "68c6739376b65258dee5058ccf6777232fe38d31a578965ae8bda327ec7da3a8",
"aarch64": "763dae20c6abe982de86ceb7829cc050c5bda79a9824846d106cc1c86d57701b",
}),
"3.12": ("3.12.14", {
"x86_64": "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41",
"aarch64": "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35",
}),
"3.13": ("3.13.15", {
"x86_64": "d0b640eed27fbdd6f5f2bd33444aee53df2c8863f8b2a96f4094717411e3de9c",
"aarch64": "5ad58156cbec94e5643c13caa792e92df72a23f33d3a6425d4cbff5c4b7a040c",
}),
}
_NODE_SHA256 = {
"x86_64": "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6",
"aarch64": "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2",
}
_CONDA_SHA256 = {
"x86_64": "14db468222ad564658656f769506056209b6dc375f5e7dfd31eb5ebbf08fa529",
"aarch64": "0391e42075a7632e9665d6e728387ee6b905f6c3e704d3513e1c1133d0d69b89",
}
_MICROMAMBA_SHA256 = {
"x86_64": "366cd9cd8be14df1ab8ed50352a82111082a36686b2d389fdb79a92c3fafb3e3",
"aarch64": "9f93b974adcb4d166996af969b6cd371287d1a3e52733704727884d9b74cb7a7",
}
def recipe(selection: ToolSelection, architecture: str) -> dict:
"""Return a JSON-serializable recipe; no request text becomes a shell program."""
if architecture not in {"x86_64", "aarch64"}:
raise ValidationError("tool installation supports x86_64 and ARM64 guests")
tool = selection.id
result = {**selection.to_dict(), "key": selection.key, "kind": "apt", "packages": [tool],
"checks": [[tool, "--version"]], "match": r"(\d+(?:\.\d+)+)", "prefix_match": "",
"links": {}, "usage": f"{tool} --version"}
if tool == "nodejs":
arch = "x64" if architecture == "x86_64" else "arm64"
result.update(kind="archive", release="nodejs-24.21.0", packages=["ca-certificates", "libstdc++6"],
url=f"https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-{arch}.tar.xz",
sha256=_NODE_SHA256[architecture], archive_root=f"node-v24.21.0-linux-{arch}",
checks=[["node", "--version"], ["npm", "--version"], ["npx", "--version"]], prefix_match="24.",
links={name: f"bin/{name}" for name in ("node", "npm", "npx")}, usage="node --version; npm --version")
elif tool == "python":
version, hashes = _PYTHON[selection.version]
binary = f"python{selection.version}"
result.update(kind="archive", release=f"python-{version}", packages=["ca-certificates", "libcrypt1"],
url=(f"https://github.com/astral-sh/python-build-standalone/releases/download/{_PYTHON_RELEASE}/"
f"cpython-{version}+{_PYTHON_RELEASE}-{architecture}-unknown-linux-gnu-install_only_stripped.tar.gz"),
sha256=hashes[architecture], archive_root="python", prefix_match=selection.version + ".",
checks=[[binary, "-c", "import sys, ssl, sqlite3, venv, ensurepip; print(sys.version.split()[0])"],
[binary, "-m", "pip", "--version"]], links={binary: f"bin/{binary}"},
usage=f"{binary} -m venv .venv; . .venv/bin/activate")
elif tool == "jdk":
arch = "amd64" if architecture == "x86_64" else "arm64"
home = f"/usr/lib/jvm/java-21-openjdk-{arch}"
result.update(packages=["openjdk-21-jdk-headless"], checks=[["javac", "-version"], ["java", "-version"]],
prefix_match="21.", java_home=home, links={name: f"{home}/bin/{name}" for name in ("java", "javac")},
usage="java -version; javac -version")
elif tool == "php":
result.update(kind="php", packages=["php8.5-cli"], prefix_match="8.5.",
links={"php": "/usr/bin/php8.5"}, usage="php --version")
elif tool == "conda":
result.update(kind="conda", release="miniforge-26.5.3-0", packages=["ca-certificates", "bzip2"],
url=("https://github.com/conda-forge/miniforge/releases/download/26.5.3-0/"
f"Miniforge3-26.5.3-0-Linux-{architecture}.sh"), sha256=_CONDA_SHA256[architecture],
links={"conda": "bin/conda", "mamba": "bin/mamba"},
usage='eval "$(conda shell.posix hook)"; conda create -n myenv python=3.13; conda activate myenv')
elif tool == "micromamba":
arch = "64" if architecture == "x86_64" else "aarch64"
result.update(kind="binary", release="micromamba-2.9.0", packages=["ca-certificates"],
url=f"https://github.com/mamba-org/micromamba-releases/releases/download/2.9.0-0/micromamba-linux-{arch}",
sha256=_MICROMAMBA_SHA256[architecture], links={"micromamba": "micromamba"},
usage='eval "$(micromamba shell hook --shell posix)"; micromamba create -n myenv -c conda-forge python=3.13; micromamba activate myenv')
return result
-197
View File
@@ -1,197 +0,0 @@
"""Atomic per-VM tool job records, serialized with lifecycle filesystem changes."""
from __future__ import annotations
import copy
import json
import os
import re
import stat
import tempfile
import time
import uuid
from contextlib import contextmanager
from .config import Settings
from .errors import ConflictError, NotFoundError, StateError
from .state import StateStore
from .tool_catalog import normalize_tools
ACTIVE_JOB_STATUSES = frozenset({"queued", "starting", "waiting", "installing"})
MAX_RETAINED_JOBS = 10
MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
class ToolJobStore:
"""Only short transactions hold operations.lock; guest execution never does."""
def __init__(self, settings: Settings, state_store: StateStore, *, clock=time.time) -> None:
self.settings = settings
self.state = state_store
self.clock = clock
def _path(self, vm_id):
vm = self.state.get(vm_id) # Exact ID only, including while creating a VM.
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id):
raise StateError("invalid VM ID in tool history")
directory = self.settings.vm_dir(vm.id)
try:
metadata = directory.lstat()
except FileNotFoundError as error:
raise NotFoundError("VM runtime directory is no longer available") from error
if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid()
or metadata.st_mode & 0o077 or not vm.runtime_owned):
raise StateError("tool history requires a private, owned VM runtime directory")
return directory / "tools.json"
@staticmethod
def _empty():
return {"schema_version": 1, "jobs": [], "inventory": [], "checked_at": None,
"agent_ready": False, "guest_busy": False, "supported": None, "error": None}
def _read(self, vm_id):
path = self._path(vm_id)
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except FileNotFoundError:
return self._empty()
except OSError as error:
raise StateError("could not open private tool installation history") from error
try:
with os.fdopen(descriptor, "rb") as stream:
metadata = os.fstat(stream.fileno())
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077):
raise StateError("unsafe tool history file")
raw = stream.read(MAX_DOCUMENT_BYTES + 1)
if len(raw) > MAX_DOCUMENT_BYTES:
raise StateError("tool history exceeds its size limit")
data = json.loads(raw)
if (not isinstance(data, dict) or data.get("schema_version") != 1
or not isinstance(data.get("jobs"), list) or not isinstance(data.get("inventory"), list)
or len(data["jobs"]) > MAX_RETAINED_JOBS):
raise StateError("invalid tool history document")
for job in data["jobs"]:
if (not isinstance(job, dict) or job.get("vm_id") != vm_id
or not re.fullmatch(r"tools-[0-9a-f]{32}", str(job.get("id", "")))
or not isinstance(job.get("items"), list) or len(job["items"]) > 12):
raise StateError("invalid tool installation job")
return data
except (OSError, ValueError) as error:
raise StateError("could not read tool installation history") from error
def _write(self, vm_id, data):
path = self._path(vm_id) # Never recreate a deleted VM directory.
descriptor, name = tempfile.mkstemp(prefix=".tools-", suffix=".json", dir=path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(data, stream, ensure_ascii=True, allow_nan=False)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
self.state._fsync_parent(path)
except OSError as error:
raise StateError("could not save tool installation history") from error
finally:
try:
os.unlink(name)
except FileNotFoundError:
pass
@contextmanager
def transaction(self, vm_id):
with self.state.operation_lock():
data = self._read(vm_id)
yield data
self._write(vm_id, data)
def read(self, vm_id):
with self.state.operation_lock():
return self._read(vm_id)
def enqueue_locked(self, vm_id, selections, *, start_if_stopped=False, restart=False,
request_id=None, source="user"):
"""Called by lifecycle create/submit with operations.lock already held."""
selections = normalize_tools(selections)
data = self._read(vm_id)
wanted = [item.to_dict() for item in selections]
for previous in reversed(data["jobs"]):
same = (previous["selections"] == wanted and previous["start_if_stopped"] == start_if_stopped
and previous.get("restart", False) == restart)
if request_id is not None and previous.get("request_id") == request_id:
if not same:
raise ConflictError("this installation request ID was already used for different tools")
return copy.deepcopy(previous)
if previous["status"] in ACTIVE_JOB_STATUSES:
if same:
return copy.deepcopy(previous)
raise ConflictError("a tool installation is already queued or running for this VM")
now = self.clock()
job = {"id": "tools-" + uuid.uuid4().hex, "vm_id": vm_id, "status": "queued", "source": source,
"selections": wanted, "start_if_stopped": start_if_stopped, "restart": restart,
"request_id": request_id, "created_at": now, "updated_at": now, "completed_at": None,
"error": None, "outcome_unknown": False,
"items": [{**item.to_dict(), "key": item.key, "status": "queued", "detected_version": None,
"error": None, "output": "", "truncated": False} for item in selections]}
data["jobs"] = [*data["jobs"], job][-MAX_RETAINED_JOBS:]
self._write(vm_id, data)
return copy.deepcopy(job)
def get_job(self, vm_id, job_id):
for job in self.read(vm_id)["jobs"]:
if job["id"] == job_id:
return job
raise NotFoundError("tool installation job not found for this VM")
def update_job(self, vm_id, job_id, update):
with self.transaction(vm_id) as data:
for job in data["jobs"]:
if job["id"] == job_id:
update(job, data)
job["updated_at"] = self.clock()
return copy.deepcopy(job)
raise NotFoundError("tool installation job not found for this VM")
def claim(self):
with self.state.operation_lock():
for vm in self.state.load().vms.values():
# A failed/interrupted create may not own a runtime directory.
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
continue
data = self._read(vm.id)
for job in data["jobs"]:
if job["status"] == "queued":
job["status"] = "starting" if vm.status == "stopped" or job.get("restart") else "waiting"
job["updated_at"] = self.clock()
self._write(vm.id, data)
return copy.deepcopy(job)
return None
@staticmethod
def interrupt(job, message, now, *, outcome_unknown=True):
job.update(status="interrupted", error=message, outcome_unknown=outcome_unknown,
updated_at=now, completed_at=now)
for item in job["items"]:
if item["status"] in {"installing", "queued"}:
item["status"] = "interrupted" if item["status"] == "installing" else "skipped"
item["error"] = message
def recover(self):
"""The worker owner lock proves that previous in-flight host workers exited."""
with self.state.operation_lock():
for vm in self.state.load().vms.values():
if not vm.runtime_owned or not self.settings.vm_dir(vm.id).is_dir():
continue
data = self._read(vm.id)
changed = False
for job in data["jobs"]:
if job["status"] in ACTIVE_JOB_STATUSES - {"queued"}:
self.interrupt(job, "Management server restarted. Refresh tool status before retrying; "
"the last installation may have completed inside the guest.", self.clock())
changed = True
if changed:
data.update(agent_ready=False, guest_busy=False)
self._write(vm.id, data)
-338
View File
@@ -1,338 +0,0 @@
"""Bounded background installation workers using the existing guest exec channel."""
from __future__ import annotations
import fcntl
import json
import logging
import os
import re
import shlex
import stat
import threading
import time
from functools import lru_cache
from pathlib import Path
from . import guest_tools
from .errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError, ValidationError
from .guest_exec import validate_exec
from .system import require_root
from .tool_catalog import all_selections, normalize_tools, recipe
from .tool_store import ToolJobStore
@lru_cache(maxsize=1)
def _guest_source():
return Path(guest_tools.__file__).read_text(encoding="utf-8")
def guest_command(payload: dict) -> str:
command = shlex.join(["/usr/bin/python3", "-I", "-c", _guest_source(), json.dumps(payload, ensure_ascii=True)])
validate_exec(command, True, 60)
return command
def _identity(vm):
return vm.pid, vm.process_start_time, vm.boot_id
def _text(value, limit=16384):
return str(value or "").encode("utf-8", errors="replace")[-limit:].decode("utf-8", errors="replace")
class ToolService:
def __init__(self, settings, state_store, lifecycle) -> None:
self.settings = settings
self.state = state_store
self.lifecycle = lifecycle
self.store = ToolJobStore(settings, state_store)
self._mutex = threading.RLock()
self._wake = threading.Condition()
self._stop = threading.Event()
self._threads = []
self._sessions = {}
self._owner = None
self._workers_left = 0
def start(self):
with self._mutex:
if self._stop.is_set():
raise ConflictError("the tool installer is shutting down")
if self._owner is not None:
return
with self.state.operation_lock():
descriptor = os.open(self.settings.base / "tool-worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid():
raise UvmError("unsafe tool worker ownership file")
os.fchmod(descriptor, 0o600)
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BaseException as error:
os.close(descriptor)
if isinstance(error, BlockingIOError):
raise ConflictError("another management server is already processing tool installations for this registry") from error
raise
self._owner = descriptor
try:
self.store.recover()
for index in range(self.settings.tool_workers):
thread = threading.Thread(target=self._worker, name=f"uvm-tools-{index}", daemon=True)
self._workers_left += 1
try:
thread.start()
except BaseException:
self._workers_left -= 1
raise
self._threads.append(thread)
except BaseException:
self._stop.set()
self.notify()
if not self._workers_left:
self._release_owner()
raise
def _release_owner(self):
if self._owner is not None:
os.close(self._owner)
self._owner = None
def notify(self):
with self._wake:
self._wake.notify_all()
def shutdown(self):
self._stop.set()
self.notify()
with self._mutex:
sessions = list(self._sessions.values())
for session in sessions:
session.close()
deadline = time.monotonic() + 8
for thread in self._threads:
thread.join(max(0, deadline - time.monotonic()))
# A worker finishing a host start keeps ownership until that start ends.
# Its guest command is never submitted after shutdown was requested.
with self._mutex:
if not self._workers_left:
self._release_owner()
def submit(self, vm_id, selections, *, start_if_stopped=False, restart=False, request_id=None):
require_root()
selections = normalize_tools(selections)
if type(start_if_stopped) is not bool or type(restart) is not bool:
raise ValidationError("start_if_stopped and restart must be booleans")
if request_id is not None and (not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", request_id)):
raise ValidationError("request_id must contain 1–80 letters, numbers, underscores, or hyphens")
self.start()
job = self.lifecycle.queue_tools(vm_id, selections, start_if_stopped=start_if_stopped,
restart=restart, request_id=request_id)
self.notify()
return job
def _execute(self, vm, payload, timeout):
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
thread_id = threading.get_ident()
def connected(session):
with self._mutex:
self._sessions[thread_id] = session
if self._stop.is_set():
session.close()
try:
result = self.lifecycle.execute(vm.id, guest_command(payload), sudo=True, timeout_seconds=timeout,
expected_identity=_identity(vm), on_connect=connected)
finally:
with self._mutex:
self._sessions.pop(thread_id, None)
if result.timed_out:
return {"ok": False, "error": f"installation/verification timed out after {timeout:g} seconds; retry to finish it",
"output": _text(result.stderr), "truncated": result.truncated}
try:
value = json.loads(result.stdout)
if result.exit_code != 0 or result.truncated or not isinstance(value, dict) or type(value.get("ok")) is not bool:
raise ValueError("invalid result")
return value
except (ValueError, TypeError) as error:
raise GuestExecutionError("guest returned an incomplete tool result; refresh status before retrying",
outcome_unknown=payload["action"] == "install") from error
def _payload(self, vm, action, selections, *, boot_id=None):
return {"action": action, "username": vm.username, "architecture": self.settings.architecture,
"boot_id": boot_id, "tools": [recipe(selection, self.settings.architecture) for selection in selections]}
@staticmethod
def _inventory(result, selections):
expected = {item.key: item for item in selections}
records = result.get("tools")
if not isinstance(records, list) or len(records) != len(expected):
raise GuestExecutionError("invalid guest tool inventory")
inventory = []
for value in records:
if (not isinstance(value, dict) or value.get("key") not in expected
or type(value.get("installed")) is not bool):
raise GuestExecutionError("invalid guest tool inventory")
selection = expected.pop(value["key"])
inventory.append({**selection.to_dict(), "key": selection.key, "installed": value["installed"],
"detected_version": _text(value.get("detected_version"), 120) or None,
"error": _text(value.get("error"), 512) or None,
"usage": _text(value.get("usage"), 512)})
return inventory
def snapshot(self, vm_id, *, refresh=False):
require_root()
vm = self.state.get(vm_id)
data = self.store.read(vm_id)
running = self.lifecycle.inspect(vm.id).observed_status == "running"
if refresh and running:
supported = data["supported"]
try:
result = self._execute(vm, self._payload(vm, "probe", all_selections()), 90)
if type(result.get("supported")) is bool:
supported = result["supported"]
if not result["ok"]:
raise UvmError(_text(result.get("error"), 2048))
inventory = self._inventory(result, all_selections())
with self.store.transaction(vm_id) as current:
current.update(inventory=inventory, checked_at=time.time(), error=None, agent_ready=True,
guest_busy=result.get("busy") is True, supported=result.get("supported") is True)
data = self.store.read(vm_id)
except UvmError as error:
with self.store.transaction(vm_id) as current:
current.update(error=_text(error, 2048), agent_ready=not isinstance(error, GuestAgentUnavailableError),
supported=supported)
data = self.store.read(vm_id)
if not running:
data.update(agent_ready=False, guest_busy=False)
return {"vm_id": vm_id, **{key: data[key] for key in (
"inventory", "checked_at", "agent_ready", "guest_busy", "supported", "error")},
"stale": not running or data["checked_at"] is None or time.time() - data["checked_at"] > 60,
"latest_job": data["jobs"][-1] if data["jobs"] else None,
"jobs": [{key: job[key] for key in ("id", "status", "created_at", "completed_at")}
for job in reversed(data["jobs"])]}
def _worker(self):
try:
while not self._stop.is_set():
try:
job = self.store.claim()
if job is not None:
self._run_job(job)
continue
except (UvmError, OSError):
logging.getLogger(__name__).exception("Could not process a tool installation job")
with self._wake:
self._wake.wait(timeout=1)
finally:
with self._mutex:
self._workers_left -= 1
if self._workers_left == 0 and self._stop.is_set():
self._release_owner()
def _update(self, job, update):
return self.store.update_job(job["vm_id"], job["id"], update)
def _wait_ready(self, vm, selections):
deadline = time.monotonic() + self.settings.tool_ready_timeout_s
while not self._stop.is_set():
try:
result = self._execute(vm, self._payload(vm, "probe", selections), 90)
if not result["ok"]:
raise UvmError(_text(result.get("error"), 2048))
if result.get("busy"):
raise ConflictError("an earlier installation is still running inside this VM; wait and refresh before retrying")
if not isinstance(result.get("boot_id"), str) or not result["boot_id"]:
raise GuestExecutionError("guest did not report its boot identity")
return result
except GuestAgentUnavailableError:
if time.monotonic() >= deadline:
raise GuestAgentUnavailableError("Guest agent did not become ready. Restart & retry to apply agent setup, "
"then check the VM log if startup still fails.")
self._stop.wait(min(2, max(0, deadline - time.monotonic())))
raise InterruptedError("management server is shutting down")
def _run_job(self, job):
submitted = False
try:
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
selections = normalize_tools(job["selections"])
vm = self.lifecycle.prepare_tool_install(job["vm_id"], start_if_stopped=job["start_if_stopped"],
restart=job.get("restart", False))
self._update(job, lambda current, data: current.update(status="waiting"))
info = self._wait_ready(vm, selections)
initial = self._inventory(info, selections)
def ready(current, data):
observed = {item["key"]: item for item in data["inventory"]}
observed.update({item["key"]: item for item in initial})
data.update(inventory=list(observed.values()), agent_ready=True,
guest_busy=False, supported=True, error=None)
self._update(job, ready)
for index, selection in enumerate(selections):
if self._stop.is_set():
raise InterruptedError("management server is shutting down")
def begin(current, data):
current["status"] = "installing"
current["items"][index]["status"] = "installing"
self._update(job, begin)
submitted = True
result = self._execute(vm, self._payload(vm, "install", [selection], boot_id=info["boot_id"]),
self.settings.tool_install_timeout_s)
observed = None
if result["ok"]:
observed, = self._inventory({"tools": [result.get("tool")]}, [selection])
if not observed["installed"] or result.get("status") not in {"installed", "already_installed"}:
raise GuestExecutionError("guest installation did not verify the selected tool", outcome_unknown=True)
def finish_item(current, data):
current["items"][index].update(
status=result["status"] if result["ok"] else "failed",
detected_version=observed["detected_version"] if observed else None,
error=None if result["ok"] else _text(result.get("error"), 2048),
output=_text(result.get("output")), truncated=result.get("truncated") is True)
inventory = {item["key"]: item for item in data["inventory"]}
if observed:
inventory[selection.key] = observed
elif selection.key in inventory:
inventory[selection.key].update(installed=False, error="Refresh to verify after the failed installation.")
data.update(inventory=list(inventory.values()))
self._update(job, finish_item)
submitted = False
def finish(current, data):
failures = sum(item["status"] == "failed" for item in current["items"])
current.update(status="failed" if failures else "succeeded", completed_at=time.time(),
error=f"{failures} tool installation(s) failed; see the per-tool output and retry." if failures else None)
self._update(job, finish)
except NotFoundError:
return # Deletion owns removal of the whole runtime, including history.
except (InterruptedError, GuestExecutionError, ConflictError) as error:
try:
self._update(job, lambda current, data: self.store.interrupt(
current, _text(error, 2048), time.time(),
outcome_unknown=getattr(error, "outcome_unknown", submitted)))
except NotFoundError:
pass
except (UvmError, OSError, ValueError) as error:
def failed(current, data):
self.store.interrupt(current, _text(error, 2048), time.time(), outcome_unknown=submitted)
current["status"] = "failed" if not submitted else "interrupted"
if isinstance(error, GuestAgentUnavailableError):
data.update(agent_ready=False, error=_text(error, 2048))
try:
self._update(job, failed)
except NotFoundError:
pass
except Exception:
logging.getLogger(__name__).exception("Unexpected tool installation failure")
try:
self._update(job, lambda current, data: self.store.interrupt(
current, "Unexpected installer failure; check the management server log and refresh before retrying.",
time.time(), outcome_unknown=submitted))
except NotFoundError:
pass
+1 -17
View File
@@ -5,15 +5,13 @@ from __future__ import annotations
import math
import re
from collections.abc import Sequence
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from decimal import Decimal, InvalidOperation
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import ValidationError
from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
_STORAGE_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?\s*", re.IGNORECASE)
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
_MIB_FACTORS = {
"b": Decimal(1) / Decimal(1024 * 1024),
@@ -64,20 +62,6 @@ def parse_cpu(value: str) -> float:
return cpu
def parse_storage(value: str) -> int:
"""Parse GiB-default storage, rounding capacity up to a whole MiB."""
match = _STORAGE_PATTERN.fullmatch(value)
if match is None:
raise ValidationError(f"invalid storage value: {value}; use e.g. 30G, 50G, or 1T")
factors = {**_MIB_FACTORS, **dict.fromkeys(("t", "tb", "ti", "tib"), Decimal(1024 * 1024))}
amount = Decimal(match.group(1)) * factors[(match.group(2) or "g").lower()]
if amount < MIN_STORAGE_MIB:
raise ValidationError("storage must be at least 30 GiB")
if amount > MAX_STORAGE_MIB:
raise ValidationError(f"storage must not exceed {MAX_STORAGE_MIB} MiB")
return int(amount.to_integral_value(rounding=ROUND_CEILING))
def parse_username(value: str) -> str:
"""Validate a conventional Linux account name."""