__init__
This commit is contained in:
+774
@@ -0,0 +1,774 @@
|
||||
# UVM Developer Guide
|
||||
|
||||
This document is the developer handoff for the entire UVM codebase. Read it
|
||||
before changing lifecycle behavior, host networking, Firecracker launch
|
||||
semantics, persistent state, or the HTTP API.
|
||||
|
||||
`uvm` is a local, privileged Firecracker microVM manager. It is an
|
||||
application with a CLI and optional FastAPI management server. It is not a
|
||||
general-purpose Python library or a multi-host control plane.
|
||||
|
||||
## 1. Goals and Boundaries
|
||||
|
||||
UVM currently provides a small host-local workflow:
|
||||
|
||||
1. Install Firecracker, a guest kernel, and a rootfs template.
|
||||
2. Allocate an ID, private IPv4 address, MAC address, TAP device, and VM
|
||||
runtime directory.
|
||||
3. Copy the rootfs template to a private writable VM disk and provision the
|
||||
requested existing guest account password.
|
||||
4. Configure and launch one Firecracker process.
|
||||
5. List, stop, SSH to, and destroy the VM.
|
||||
6. Optionally expose those operations through an authenticated FastAPI API.
|
||||
|
||||
The design deliberately does not provide these capabilities yet:
|
||||
|
||||
- Multi-host scheduling or remote state coordination.
|
||||
- User tenancy, quotas, or a public-facing control plane.
|
||||
- A `start` or `restart` command for stopped VMs.
|
||||
- Guest snapshots, extra disks, port forwarding, or DHCP.
|
||||
- Cgroup CPU enforcement for fractional CPU requests.
|
||||
- Jailer-based Firecracker execution, namespaces, or seccomp hardening.
|
||||
- Production-grade image building or guest SSH-key injection.
|
||||
|
||||
Treat UVM as a privileged local systems tool. It manipulates `/dev/kvm`, TAP
|
||||
interfaces, bridge addresses, iptables rules, host filesystems, and processes.
|
||||
|
||||
## 2. First Run
|
||||
|
||||
All UVM-specific files live in this directory. Start here:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install -e '.[test]'
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v
|
||||
./uvm.py --help
|
||||
```
|
||||
|
||||
The `uvm` console script is registered by `pyproject.toml`. During source
|
||||
development, `./uvm.py` and `python3 -m uvm` are equivalent entry points.
|
||||
|
||||
Requirements for real host operations:
|
||||
|
||||
- Python 3.12+
|
||||
- Linux with KVM enabled and `/dev/kvm` accessible
|
||||
- Root privileges for installation and lifecycle mutations
|
||||
- `iproute2`, `iptables`, `e2fsprogs`, `util-linux`, `openssl`, and OpenSSH
|
||||
client tools
|
||||
- An x86_64 host for the default kernel/rootfs URLs
|
||||
|
||||
Do not run real integration operations on a developer workstation unless the
|
||||
bridge, subnet, iptables changes, and guest workload are acceptable there.
|
||||
|
||||
## 3. Project Layout
|
||||
|
||||
```text
|
||||
uvm/
|
||||
├── DEVELOPER.md # This document
|
||||
├── README.md # Operator-facing usage guide
|
||||
├── pyproject.toml # Package metadata and dependencies
|
||||
├── uvm.py # Executable source-tree launcher
|
||||
├── uvm/
|
||||
│ ├── __main__.py # `python -m uvm`
|
||||
│ ├── cli.py # argparse, CLI rendering, serve dispatch
|
||||
│ ├── app.py # Composition root
|
||||
│ ├── config.py # Settings and environment loading
|
||||
│ ├── domain.py # VmSpec, VmRecord, State
|
||||
│ ├── errors.py # Expected error hierarchy
|
||||
│ ├── validation.py # CPU and RAM parsing
|
||||
│ ├── system.py # CommandRunner, root/KVM/directory checks
|
||||
│ ├── state.py # JSON state, atomic writes, file locks
|
||||
│ ├── integrity.py # SHA-256 and integrity manifest logic
|
||||
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation
|
||||
│ ├── images.py # Template lookup, private disks, and guest credentials
|
||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
|
||||
│ ├── lifecycle.py # Create/list/stop/destroy orchestration
|
||||
│ ├── api_models.py # Pydantic HTTP request/response models
|
||||
│ ├── server.py # FastAPI factory and Uvicorn launcher
|
||||
│ ├── routers/
|
||||
│ │ ├── dependencies.py # API application/token dependencies
|
||||
│ │ ├── health.py # Liveness endpoint
|
||||
│ │ ├── installation.py # Installation endpoint
|
||||
│ │ └── vms.py # VM lifecycle endpoints
|
||||
│ └── firecracker/
|
||||
│ ├── config.py # Firecracker JSON and boot arguments
|
||||
│ ├── api.py # Unix-socket HTTP client
|
||||
│ └── process.py # Process launch, liveness, termination
|
||||
└── tests/ # Unit tests and conditional API tests
|
||||
```
|
||||
|
||||
Keep feature code in the most specific existing module. Do not add a new
|
||||
abstraction layer merely to wrap one function. The current module boundaries
|
||||
are intentionally small and operationally focused.
|
||||
|
||||
## 4. Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CLI[CLI: uvm] --> APP[app.py composition root]
|
||||
HTTP[FastAPI server] --> APP
|
||||
APP --> INSTALL[Installer]
|
||||
APP --> LIFE[LifecycleService]
|
||||
APP --> STATE[StateStore]
|
||||
LIFE --> IMAGES[ImageStore]
|
||||
LIFE --> NET[NetworkManager]
|
||||
LIFE --> FC[Firecracker adapters]
|
||||
INSTALL --> RUNNER[CommandRunner]
|
||||
NET --> RUNNER
|
||||
FC --> HOST[Linux host primitives]
|
||||
RUNNER --> HOST
|
||||
STATE --> DISK[(state.json)]
|
||||
IMAGES --> DISKSTORE[/var/lib/uvm/images and vms]
|
||||
FC --> KVM[/dev/kvm]
|
||||
```
|
||||
|
||||
The public entry points are thin adapters. The lifecycle layer is the only
|
||||
place that coordinates multiple host resources for a VM. Lower-level modules
|
||||
must not import `cli.py`, `server.py`, or `lifecycle.py`.
|
||||
|
||||
### Dependency Direction
|
||||
|
||||
```text
|
||||
cli.py / server.py / routers/
|
||||
|
|
||||
v
|
||||
app.py
|
||||
|
|
||||
+-------+--------+
|
||||
| |
|
||||
install.py lifecycle.py
|
||||
|
|
||||
+------------+------------+
|
||||
| | |
|
||||
state.py network.py images.py
|
||||
|
|
||||
firecracker/*
|
||||
| | |
|
||||
+------ system.py --------+
|
||||
|
|
||||
Linux host
|
||||
```
|
||||
|
||||
Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.py`.
|
||||
They must remain free of subprocess, network, CLI, and FastAPI side effects.
|
||||
|
||||
## 5. Entrypoints and Request Paths
|
||||
|
||||
### CLI Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant C as cli.py
|
||||
participant A as app.py
|
||||
participant L as LifecycleService
|
||||
participant H as Linux host
|
||||
|
||||
U->>C: uvm create --cpu 1 --ram 512
|
||||
C->>A: build_application()
|
||||
A->>L: composed service graph
|
||||
C->>L: create(VmSpec)
|
||||
L->>H: allocate files, TAP, process, Firecracker API
|
||||
L-->>C: VmRecord
|
||||
C-->>U: formatted VM details
|
||||
```
|
||||
|
||||
`cli.py` owns parsing, terminal output, exit handling, SSH `execvp`, and the
|
||||
`--serve` switch. It must not manipulate state files, interfaces, or
|
||||
Firecracker sockets directly.
|
||||
|
||||
### HTTP Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Uvicorn
|
||||
participant API as server.py / routers
|
||||
participant L as LifecycleService
|
||||
participant H as Linux host
|
||||
|
||||
Client->>Uvicorn: HTTP(S) request + X-UVM-Token
|
||||
Uvicorn->>API: route dispatch
|
||||
API->>API: token dependency and Pydantic validation
|
||||
API->>L: lifecycle operation
|
||||
L->>H: host mutation
|
||||
L-->>API: VmRecord or UvmError
|
||||
API-->>Client: JSON response or JSON error
|
||||
```
|
||||
|
||||
Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The
|
||||
`create_api()` factory requires an explicit host specifically to prevent it
|
||||
from being used as an insecure generic Uvicorn factory.
|
||||
|
||||
## 6. Application Composition
|
||||
|
||||
[`app.py`](uvm/app.py) constructs one concrete object graph per CLI or server
|
||||
process:
|
||||
|
||||
| Component | Constructor | Responsibility |
|
||||
|---|---|---|
|
||||
| Settings | `Settings.from_environment()` | Immutable runtime configuration |
|
||||
| CommandRunner | `CommandRunner` | Logged/testable subprocess execution |
|
||||
| StateStore | `StateStore` | Inventory, locks, JSON persistence |
|
||||
| ImageStore | `ImageStore` | Template verification, VM disks, and offline credentials |
|
||||
| NetworkManager | `NetworkManager` | Guest allocation and Linux networking |
|
||||
| FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control |
|
||||
| LifecycleService | `LifecycleService` | Ordered VM resource changes and rollback |
|
||||
| Installer | `Installer` | Host packages and artifacts |
|
||||
|
||||
The FastAPI factory calls `build_application(emit=None)` so API-driven host
|
||||
commands do not print shell-style command traces to the server output. The CLI
|
||||
uses the default emitter and prints commands as `+ ...` for transparency.
|
||||
|
||||
## 7. Persistent Data and Models
|
||||
|
||||
### Host Data Layout
|
||||
|
||||
Default runtime storage is `/var/lib/uvm`:
|
||||
|
||||
```text
|
||||
/var/lib/uvm/
|
||||
├── bin/
|
||||
│ ├── firecracker
|
||||
│ └── jailer
|
||||
├── images/
|
||||
│ ├── vmlinux
|
||||
│ └── ubuntu.ext4
|
||||
├── vms/
|
||||
│ └── vm-<uuid>/
|
||||
│ ├── rootfs.ext4
|
||||
│ ├── config.json
|
||||
│ ├── firecracker.log
|
||||
│ └── firecracker.sock
|
||||
├── state.json
|
||||
├── state.lock
|
||||
├── operations.lock
|
||||
└── integrity.json
|
||||
```
|
||||
|
||||
Per-VM runtime directories are mode `0700`; copied VM disks are mode `0600`.
|
||||
`state.json` is mode `0600` because VM records contain plaintext guest
|
||||
passwords. Registry-backed commands therefore need access to the root-owned
|
||||
state file.
|
||||
|
||||
### State Schema
|
||||
|
||||
[`domain.py`](uvm/domain.py) is the schema authority. The current state
|
||||
version is `1`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_mac_index": 2,
|
||||
"vms": {
|
||||
"vm-<uuid>": {
|
||||
"id": "vm-<uuid>",
|
||||
"cpu": 1.0,
|
||||
"ram_mib": 512,
|
||||
"guest_ip": "10.42.0.2",
|
||||
"gateway": "10.42.0.1",
|
||||
"tap": "uvm-...",
|
||||
"mac": "02:fc:00:00:00:01",
|
||||
"socket": "/var/lib/uvm/vms/.../firecracker.sock",
|
||||
"config": "/var/lib/uvm/vms/.../config.json",
|
||||
"log": "/var/lib/uvm/vms/.../firecracker.log",
|
||||
"disk": "/var/lib/uvm/vms/.../rootfs.ext4",
|
||||
"username": "root",
|
||||
"password": "replace-me",
|
||||
"status": "running",
|
||||
"pid": 1234,
|
||||
"process_start_time": "...",
|
||||
"created_at": 0,
|
||||
"updated_at": 0,
|
||||
"last_error": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`VmSpec` is the validated create request. `VmRecord` is the persisted local
|
||||
record. Do not make API models or CLI namespaces the persistence format.
|
||||
|
||||
### State Guarantees
|
||||
|
||||
- `state.json` writes use a sibling temporary file, `fsync`, atomic replace,
|
||||
and parent-directory `fsync`.
|
||||
- `state.json` is mode `0600`; it must be treated as a credential registry and
|
||||
excluded from logs and bug reports.
|
||||
- `state.lock` serializes short JSON read-modify-write transactions.
|
||||
- `operations.lock` serializes long lifecycle and installation operations.
|
||||
- State reads are lock-free because atomic replacement gives readers either a
|
||||
complete old file or a complete new file.
|
||||
- Legacy state without a schema version is accepted as version `0`; missing
|
||||
`disk` and `process_start_time` fields have safe defaults.
|
||||
- Records created before credential provisioning default to username `root`
|
||||
and password `null`; UVM does not apply a password retroactively.
|
||||
- `next_mac_index` is reconstructed from older locally administered MACs when
|
||||
absent.
|
||||
|
||||
When adding a persisted field, update `VmRecord.to_dict`, `VmRecord.from_dict`,
|
||||
the state schema tests, and this document. Never silently discard existing
|
||||
state fields without a migration plan.
|
||||
|
||||
## 8. VM Lifecycle
|
||||
|
||||
### Stored and Observed Status
|
||||
|
||||
The persisted status is a string, not an enum, to keep legacy JSON readable.
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `starting` | Identity reserved; host resources are being created |
|
||||
| `running` | Firecracker configuration and `InstanceStart` succeeded |
|
||||
| `stopping` | Stop mutation is in progress |
|
||||
| `stopped` | Process/TAP removed; private disk remains |
|
||||
| `terminating` | Destruction is in progress |
|
||||
| `failed` | A lifecycle step or cleanup failed; `last_error` explains why |
|
||||
| `dead` | Observed-only list status when a supposedly active process is gone |
|
||||
|
||||
`dead` is not persisted. `destroy` removes the record rather than retaining a tombstone.
|
||||
|
||||
### Create Order
|
||||
|
||||
[`LifecycleService.create`](uvm/lifecycle.py) follows this sequence:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Validate root, KVM, binary, integrity] --> B[Acquire operations.lock]
|
||||
B --> C[Reserve ID, IP, MAC, and starting state]
|
||||
C --> D[Create 0700 runtime directory]
|
||||
D --> E[Copy template to private rootfs.ext4]
|
||||
E --> F[Provision guest password and SSH policy]
|
||||
F --> G[Ensure bridge, NAT, and forwarding rules]
|
||||
G --> H[Create TAP]
|
||||
H --> I[Write Firecracker config atomically]
|
||||
I --> J[Spawn Firecracker and wait for API socket]
|
||||
J --> K[Persist PID and process start token]
|
||||
K --> L[Configure Firecracker through Unix HTTP]
|
||||
L --> M[Persist running state]
|
||||
```
|
||||
|
||||
The record is persisted as `starting` before host side effects. The PID and
|
||||
Linux process-start token are persisted before Firecracker API configuration.
|
||||
This makes cleanup and later diagnosis possible if API configuration fails.
|
||||
|
||||
### Rollback Rules
|
||||
|
||||
Any `Exception`, `KeyboardInterrupt`, or `SystemExit` during create triggers
|
||||
compensation:
|
||||
|
||||
1. Terminate a started Firecracker process when its identity is known.
|
||||
2. Remove the TAP when it was created.
|
||||
3. Remove the VM runtime directory and private disk.
|
||||
4. Remove the reserved state record if cleanup succeeds.
|
||||
5. Preserve a `failed` record with `last_error` if cleanup itself fails.
|
||||
|
||||
Do not reorder these operations without considering leaked TAPs, orphaned VMM
|
||||
processes, and allocation reuse.
|
||||
|
||||
### Stop and Destroy
|
||||
|
||||
```text
|
||||
stop:
|
||||
running/failed/stopped -> stopping -> terminate process -> delete TAP -> stopped
|
||||
private disk and config remain
|
||||
|
||||
destroy:
|
||||
any non-transition state -> terminating -> terminate process -> delete TAP
|
||||
-> remove VM runtime directory -> remove state record
|
||||
```
|
||||
|
||||
`stop` is intentionally disk-preserving. There is no public `start` command
|
||||
yet, so do not add start behavior by modifying stop semantics.
|
||||
|
||||
## 9. Firecracker Integration
|
||||
|
||||
The `firecracker/` package has three separate responsibilities.
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `config.py` | Build boot args and Firecracker JSON; atomically write config |
|
||||
| `api.py` | Typed HTTP-over-Unix-socket requests |
|
||||
| `process.py` | Spawn, socket readiness, liveness, signal safety, cleanup |
|
||||
|
||||
### Configuration Order
|
||||
|
||||
`FirecrackerClient.configure_and_start()` must keep this ordering:
|
||||
|
||||
1. `PUT /machine-config`
|
||||
2. `PUT /boot-source`
|
||||
3. `PUT /drives/rootfs`
|
||||
4. `PUT /network-interfaces/eth0`
|
||||
5. `PUT /actions` with `InstanceStart`
|
||||
|
||||
The guest receives a static IP via the kernel `ip=` boot argument. The netmask
|
||||
comes from `Settings.network`, not a hard-coded `/24` value.
|
||||
|
||||
### Process Safety
|
||||
|
||||
Firecracker starts detached in a new session, with stdout/stderr redirected to
|
||||
the per-VM log. The manager records `/proc/<pid>/stat` start time and only
|
||||
signals a process when its persisted token still matches. This prevents a stale
|
||||
state record from killing an unrelated process after PID reuse.
|
||||
|
||||
Legacy records with a PID but no start token are only cleaned up automatically
|
||||
when that PID is already gone. If it remains alive, UVM fails closed and asks
|
||||
the operator to inspect it.
|
||||
|
||||
## 10. Guest Images and Integrity
|
||||
|
||||
### Template Contract
|
||||
|
||||
`images/ubuntu.ext4` is a template only. Each create operation copies it into
|
||||
the VM runtime directory before UVM modifies the requested existing account's
|
||||
shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees
|
||||
the resulting private copy. Never change that to a shared writable rootfs.
|
||||
|
||||
Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's
|
||||
`/etc/ssh/sshd_config`, the requested account, and their own first-boot SSH
|
||||
host-key generation. UVM does not create missing users. The known public demo
|
||||
key in Firecracker's bionic image is removed from private VM disks. A shared
|
||||
template must never contain reusable SSH host private keys.
|
||||
|
||||
### Integrity Model
|
||||
|
||||
[`integrity.py`](uvm/integrity.py) has two concepts:
|
||||
|
||||
- A configured trusted SHA-256 from the environment.
|
||||
- A local `integrity.json` manifest recording artifact hashes and whether the
|
||||
installation was verified.
|
||||
|
||||
Strict installation requires all of these values:
|
||||
|
||||
```text
|
||||
UVM_FIRECRACKER_SHA256
|
||||
UVM_KERNEL_SHA256
|
||||
UVM_ROOTFS_SHA256
|
||||
```
|
||||
|
||||
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` is a local-development escape hatch. It
|
||||
writes a manifest marked `verified: false`; a later strict run will not treat
|
||||
that manifest as publisher trust. Use `install --force` with trusted digests to
|
||||
refresh an existing artifact without manually deleting managed files.
|
||||
|
||||
The optional `UVM_FIRECRACKER_BINARY_SHA256` validates a local Firecracker
|
||||
binary when no verified manifest is available.
|
||||
|
||||
## 11. Networking
|
||||
|
||||
### Topology
|
||||
|
||||
```text
|
||||
guest eth0
|
||||
|
|
||||
virtio-net / Firecracker
|
||||
|
|
||||
per-VM TAP: uvm-<suffix>
|
||||
|
|
||||
Linux bridge: uvm0 (10.42.0.1/24 by default)
|
||||
|
|
||||
iptables forwarding + MASQUERADE
|
||||
|
|
||||
host default uplink
|
||||
|
|
||||
internet
|
||||
```
|
||||
|
||||
[`network.py`](uvm/network.py) owns all networking details:
|
||||
|
||||
- IP allocation from `Settings.network`, excluding network, broadcast, and
|
||||
gateway addresses.
|
||||
- Locally administered MAC generation: `02:fc:00:xx:xx:xx`.
|
||||
- Interface names limited to Linux `IFNAMSIZ` (15 characters).
|
||||
- Bridge creation or validation that an existing interface is actually a
|
||||
Linux bridge.
|
||||
- Gateway address setup and IPv4 forwarding.
|
||||
- One egress NAT rule and forward/return rules for the default uplink.
|
||||
- TAP creation, cleanup, and error reporting.
|
||||
|
||||
Network actions are idempotent where practical. Do not adopt an existing TAP:
|
||||
it may belong to another VM. A name collision is an error.
|
||||
|
||||
## 12. Installer and Host Commands
|
||||
|
||||
`Installer` is the only owner of apt installation, Firecracker artifact
|
||||
handling, downloads, and integrity-manifest publication.
|
||||
|
||||
`system.py` centralizes host command execution:
|
||||
|
||||
- `CommandRunner.run()` converts subprocess failures into `CommandError`.
|
||||
- `require_root()` is called by host-mutating operations.
|
||||
- `check_kvm()` validates the KVM device.
|
||||
- `ensure_data_directories()` creates the UVM storage layout.
|
||||
|
||||
Use `CommandRunner` rather than direct `subprocess.run` in installation or
|
||||
network code. This keeps command construction observable in the CLI and
|
||||
mockable in tests.
|
||||
|
||||
Downloads use temporary sibling files, optional checksum verification, and
|
||||
atomic replacement. Archive extraction rejects path traversal before using
|
||||
Python's safe tar extraction filter.
|
||||
|
||||
## 13. HTTP API
|
||||
|
||||
### Server Startup
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install .
|
||||
|
||||
export UVM_API_TOKEN='long-random-visible-ascii-token'
|
||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
For non-loopback binds, both TLS variables are required and loaded into an
|
||||
`ssl.SSLContext` before Uvicorn starts:
|
||||
|
||||
```sh
|
||||
export UVM_API_TLS_CERT='/etc/uvm/api-cert.pem'
|
||||
export UVM_API_TLS_KEY='/etc/uvm/api-key.pem'
|
||||
sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||
./uvm.py --serve --host 0.0.0.0 --port 8443
|
||||
```
|
||||
|
||||
The token must contain visible ASCII characters without whitespace. It is
|
||||
never accepted as a UVM command-line argument, so it does not appear in UVM
|
||||
process arguments. If you type an `export` command interactively, your shell
|
||||
may record it in history; use a protected environment file, service manager,
|
||||
or secret manager when that matters.
|
||||
|
||||
### API Contract
|
||||
|
||||
| Method | Route | Router | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
|
||||
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
||||
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
|
||||
| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, optional `guest_ip`, `username`, and `password` |
|
||||
| `GET` | `/vms/{id-or-ip}` | `vms.py` | Lookup by ID or guest IP |
|
||||
| `POST` | `/vms/{id}/stop` | `vms.py` | Stop while retaining private disk |
|
||||
| `DELETE` | `/vms/{id}` | `vms.py` | Remove runtime files and state |
|
||||
|
||||
All management routes use `get_authorized_application()` and require:
|
||||
|
||||
```http
|
||||
X-UVM-Token: <UVM_API_TOKEN>
|
||||
```
|
||||
|
||||
Request schemas reject unexpected fields. `UvmError` instances are rendered as
|
||||
JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal
|
||||
422 response format. VM responses include the username but never the password.
|
||||
|
||||
The API intentionally does not expose interactive SSH, raw Firecracker calls,
|
||||
arbitrary command execution, or host path access.
|
||||
|
||||
## 14. Configuration Reference
|
||||
|
||||
| Variable | Default | Used by |
|
||||
|---|---|---|
|
||||
| `UVM_BASE` | `/var/lib/uvm` | Storage layout |
|
||||
| `UVM_NETWORK` | `10.42.0.0/24` | IP allocation and bridge subnet |
|
||||
| `UVM_GATEWAY` | `10.42.0.1` | Bridge and guest boot args |
|
||||
| `UVM_BRIDGE` | `uvm0` | Linux bridge name |
|
||||
| `UVM_KERNEL_URL` | Firecracker quickstart URL | Guest kernel download |
|
||||
| `UVM_ROOTFS_URL` | Firecracker quickstart URL | Guest rootfs download |
|
||||
| `UVM_FIRECRACKER_SHA256` | unset | Trusted release archive checksum |
|
||||
| `UVM_KERNEL_SHA256` | unset | Trusted kernel checksum |
|
||||
| `UVM_ROOTFS_SHA256` | unset | Trusted rootfs checksum |
|
||||
| `UVM_FIRECRACKER_BINARY_SHA256` | unset | Optional direct binary checksum |
|
||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | `false` | Development-only integrity bypass |
|
||||
| `UVM_API_TOKEN` | unset | Required API bearer token |
|
||||
| `UVM_API_TLS_CERT` | unset | TLS certificate for remote API bind |
|
||||
| `UVM_API_TLS_KEY` | unset | TLS key for remote API bind |
|
||||
|
||||
`Settings` validates the gateway, bridge-name length, API token format, and
|
||||
environment boolean values at process startup. Add configuration fields there,
|
||||
not as module-level global constants in feature code.
|
||||
|
||||
## 15. Error Handling Rules
|
||||
|
||||
Expected failures inherit from `UvmError`:
|
||||
|
||||
| Error | Use |
|
||||
|---|---|
|
||||
| `ConfigurationError` | Invalid local settings or environment |
|
||||
| `ValidationError` | Invalid CLI/API resource input |
|
||||
| `StateError` | Unsafe or malformed persisted state |
|
||||
| `CommandError` | Required host command failure |
|
||||
| `FirecrackerError` | VMM process or Unix API failure |
|
||||
| `TapCreationError` | TAP setup failed after interface creation |
|
||||
|
||||
CLI code catches `UvmError` and prints `uvm: error: ...` without a traceback.
|
||||
The FastAPI server maps the same class to an error envelope. New expected
|
||||
operational failures should use this hierarchy rather than leaking raw
|
||||
`OSError`, `CalledProcessError`, or implementation-specific exceptions.
|
||||
|
||||
## 16. Concurrency and Ownership Invariants
|
||||
|
||||
These invariants are critical:
|
||||
|
||||
1. Only `LifecycleService` may coordinate VM state, disks, TAPs, and
|
||||
Firecracker as one operation.
|
||||
2. Only `StateStore` may read/write `state.json`.
|
||||
3. All long host mutations acquire `operations.lock`.
|
||||
4. All short inventory mutations acquire `state.lock`.
|
||||
5. The installer shares `operations.lock` with lifecycle actions, preventing a
|
||||
Firecracker binary replacement between verification and launch.
|
||||
6. A VM rootfs is private before it becomes writable.
|
||||
7. A PID is not signaled unless its persisted start token still matches
|
||||
`/proc/<pid>/stat`.
|
||||
8. A new VM never adopts a pre-existing TAP.
|
||||
9. A server must have an API token; a remote server must also have usable TLS.
|
||||
10. Routers call application services; they do not run shell commands or touch
|
||||
state files directly.
|
||||
11. Plaintext guest passwords remain in the mode-`0600` registry and never
|
||||
enter Firecracker config, helper command arguments/traces, or API responses.
|
||||
|
||||
When a change would violate one of these rules, redesign the change rather
|
||||
than adding a special-case bypass.
|
||||
|
||||
## 17. Testing Strategy
|
||||
|
||||
### Standard Unit Suite
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install -e '.[test]'
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
The suite covers:
|
||||
|
||||
- CPU/RAM parsing and validation.
|
||||
- State serialization, legacy migration, locking, and permissions.
|
||||
- Checksum verification and integrity-manifest provenance.
|
||||
- Private rootfs behavior.
|
||||
- Guest credential persistence, validation, and offline rootfs provisioning.
|
||||
- IP, MAC, bridge, TAP, NAT, and forwarding command construction.
|
||||
- Firecracker configuration and API request ordering.
|
||||
- PID safety and startup interrupt cleanup.
|
||||
- Lifecycle success, rollback, stop, and destroy behavior.
|
||||
- CLI SSH safety and `--serve` parsing.
|
||||
- Server token/TLS validation and conditional API route tests.
|
||||
|
||||
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
||||
are installed. They should be enabled in CI using the `test` extra.
|
||||
|
||||
### Real Integration Tests
|
||||
|
||||
No current test automatically boots a real microVM. Before a release, run an
|
||||
isolated host test that covers:
|
||||
|
||||
1. Strict checked installation.
|
||||
2. `create` and successful guest boot.
|
||||
3. Guest IP reachability and egress.
|
||||
4. SSH with unique guest host keys.
|
||||
5. Stop and retained disk inspection.
|
||||
6. Destroy and IP/TAP release.
|
||||
7. API launch with token and TLS on a non-loopback test address.
|
||||
8. Firecracker crash and interrupted create recovery.
|
||||
|
||||
## 18. Common Development Tasks
|
||||
|
||||
### Add a CLI Command
|
||||
|
||||
1. Put business logic in an existing service or a cohesive new internal
|
||||
module, not `cli.py`.
|
||||
2. Add parser arguments in `build_parser()`.
|
||||
3. Add display/dispatch behavior in `_run_command()`.
|
||||
4. Use `UvmError` subclasses for expected failures.
|
||||
5. Add unit tests for parsing and service behavior.
|
||||
6. Update `README.md` and this document if the architecture changes.
|
||||
|
||||
### Add an HTTP Endpoint
|
||||
|
||||
1. Add or extend a Pydantic request/response model in `api_models.py`.
|
||||
2. Put the endpoint in the appropriate `routers/` module.
|
||||
3. Use `get_authorized_application()` for every privileged endpoint.
|
||||
4. Keep `/health` as the only intentionally unauthenticated management route.
|
||||
FastAPI's default documentation endpoints are informational and public.
|
||||
5. Add the router to `create_api()`.
|
||||
6. Add `httpx`/FastAPI tests and update the API table in both docs.
|
||||
|
||||
### Add a Host Primitive
|
||||
|
||||
1. Put subprocess construction in `system.py`, `network.py`, `install.py`, or
|
||||
a specific Firecracker adapter.
|
||||
2. Use `CommandRunner` when invoking external commands.
|
||||
3. Make repeated application safe where possible.
|
||||
4. Define compensation behavior before calling it from lifecycle code.
|
||||
5. Add fake-runner tests; do not require root in ordinary unit tests.
|
||||
|
||||
### Change State
|
||||
|
||||
1. Increment `STATE_SCHEMA_VERSION` only when the serialized interpretation
|
||||
changes incompatibly.
|
||||
2. Keep older state readable or provide an explicit migration path.
|
||||
3. Update `VmRecord`, `State`, state tests, and runtime cleanup behavior.
|
||||
4. Consider lock ownership and crash/retry behavior before changing fields.
|
||||
|
||||
## 19. Debugging Guide
|
||||
|
||||
| Symptom | First places to inspect |
|
||||
|---|---|
|
||||
| `guest assets missing` | `/var/lib/uvm/images`, integrity variables, `integrity.json` |
|
||||
| Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access |
|
||||
| VM is `dead` in `list` | PID/start token in state, `/proc/<pid>`, VM log |
|
||||
| Cannot stop legacy VM | Missing process start token; inspect PID manually before cleanup |
|
||||
| Guest has no egress | `ip route show default`, bridge address, TAP state, iptables rules |
|
||||
| API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files |
|
||||
| API returns 401 | `X-UVM-Token` header mismatch |
|
||||
| API returns 422 | Request schema or UVM input validation failure |
|
||||
|
||||
Useful host commands during isolated debugging:
|
||||
|
||||
```sh
|
||||
ip link show uvm0
|
||||
ip addr show dev uvm0
|
||||
ip route show default
|
||||
iptables -t nat -S POSTROUTING
|
||||
iptables -S FORWARD
|
||||
ls -la /var/lib/uvm/vms
|
||||
cat /var/lib/uvm/state.json
|
||||
```
|
||||
|
||||
Do not use these commands to mutate UVM resources manually while a lifecycle
|
||||
operation is active. Prefer fixing the service behavior and retrying through
|
||||
the CLI or API.
|
||||
|
||||
## 20. Current Production Gaps
|
||||
|
||||
The code is intentionally honest about current limits:
|
||||
|
||||
- Firecracker is launched directly, even though the Jailer binary is
|
||||
downloaded.
|
||||
- No cgroup CPU, memory, I/O, or process limits are applied.
|
||||
- Fractional CPU is represented by rounding vCPUs up and printing an advisory.
|
||||
- Guest health is inferred from VMM process state, not application readiness.
|
||||
- There is no DHCP, port mapping, ingress firewall policy, or network tenancy.
|
||||
- `iptables` rules are incrementally managed rather than rendered from a full
|
||||
declarative firewall policy.
|
||||
- The JSON inventory is suitable for one host, not distributed coordination.
|
||||
- Guest passwords are stored in plaintext in the root-only local registry.
|
||||
- API authentication is a single bearer token, not a user/tenant model.
|
||||
|
||||
Do not market UVM as a hardened multi-tenant platform until these boundaries
|
||||
are deliberately addressed with architecture, tests, and operational review.
|
||||
|
||||
## 21. Contributor Checklist
|
||||
|
||||
Before submitting a change:
|
||||
|
||||
1. Run the complete unit suite.
|
||||
2. Run `git diff --check`.
|
||||
3. Confirm a new feature stays inside the `uvm/` project directory.
|
||||
4. Verify command/API errors use `UvmError` rather than raw tracebacks.
|
||||
5. Verify host mutations have a lock owner and cleanup path.
|
||||
6. Verify persistent state remains readable across upgrades.
|
||||
7. Update `README.md` for operator-facing behavior.
|
||||
8. Update this document for architectural, trust-boundary, or lifecycle
|
||||
changes.
|
||||
@@ -0,0 +1,318 @@
|
||||
# uvm
|
||||
|
||||
`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 persists enough state to list, stop, and destroy the VM later.
|
||||
|
||||
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).
|
||||
|
||||
For end-to-end operator scenarios, CLI examples, and HTTP API calls, see
|
||||
[USAGE.md](USAGE.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
From the parent repository directory, run commands from this project root:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
./uvm.py --help
|
||||
python3 -m uvm --help
|
||||
```
|
||||
|
||||
The `pyproject.toml` file also defines an installable `uvm` console command.
|
||||
The project is an application, not a reusable Python SDK.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Ubuntu or another compatible Linux host
|
||||
- Python 3.12 or newer
|
||||
- Root access for UVM commands that read or mutate the protected VM registry
|
||||
- KVM enabled and accessible at `/dev/kvm`
|
||||
- An x86_64 host for the default guest assets
|
||||
- Working host networking, `iproute2`, `iptables`, `e2fsprogs`, `openssl`, and
|
||||
`openssh-client`
|
||||
|
||||
`uvm install` installs the required host packages. It validates `/dev/kvm`
|
||||
before downloading or launching a microVM.
|
||||
|
||||
## Trusted Installation
|
||||
|
||||
Installation requires trusted SHA-256 digests by default. Supply digests from
|
||||
an independently trusted source before running the privileged installer:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
|
||||
sudo env \
|
||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
||||
./uvm.py install
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
For local experimentation only, the old unverified quick-start behavior is
|
||||
available through an explicit opt-out:
|
||||
|
||||
```sh
|
||||
sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install
|
||||
```
|
||||
|
||||
An unverified install is recorded as such and cannot satisfy a later strict
|
||||
run. Reinstall with trusted digests to establish a verified local manifest.
|
||||
To create VMs from an unverified local-development install, retain
|
||||
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create` commands and on the API
|
||||
server process.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
# Install after supplying the trusted checksum variables shown above.
|
||||
# A bare `install` command fails under the default strict integrity policy.
|
||||
|
||||
# Create and boot a VM with the defaults: 1 CPU, 512 MiB RAM, and root/root.
|
||||
sudo ./uvm.py create
|
||||
|
||||
# Request CPU and RAM explicitly. RAM defaults to MiB; B/K/M/G suffixes work.
|
||||
sudo ./uvm.py create --cpu 1 --ram 1G
|
||||
|
||||
# Set credentials for an account that already exists in the guest image.
|
||||
sudo ./uvm.py create --username root --password '<guest-password>'
|
||||
|
||||
# Select the guest IP. Despite the legacy flag name, this is the guest IP.
|
||||
sudo ./uvm.py create --host-ip 10.42.0.10
|
||||
|
||||
# Show persisted VM state and observed process status.
|
||||
sudo ./uvm.py list
|
||||
|
||||
# Connect over SSH by VM ID or guest IP.
|
||||
sudo ./uvm.py ssh vm-<id>
|
||||
ssh root@10.42.0.10
|
||||
|
||||
# Stop or remove a VM.
|
||||
sudo ./uvm.py stop vm-<id>
|
||||
sudo ./uvm.py destroy vm-<id>
|
||||
```
|
||||
|
||||
SSH uses `StrictHostKeyChecking=accept-new` and a VM-ID-specific host-key
|
||||
alias by default. `--insecure-host-key` restores the old no-verification
|
||||
behavior for a single connection.
|
||||
|
||||
## Management API
|
||||
|
||||
Install the project dependencies before serving the API:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install .
|
||||
```
|
||||
|
||||
Launch the FastAPI management server with the requested CLI form:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
uvm --serve --host 127.0.0.1 --port 8000
|
||||
|
||||
# Equivalent when running the source checkout directly.
|
||||
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
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 server exposes these routes:
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Unauthenticated liveness response |
|
||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||
| `GET` | `/vms` | List VM records and observed status |
|
||||
| `POST` | `/vms` | Create a VM; body supports `cpu`, `ram`, `guest_ip`, `username`, and `password` |
|
||||
| `GET` | `/vms/{id-or-ip}` | Retrieve one VM by ID or guest IP |
|
||||
| `POST` | `/vms/{id}/stop` | Stop a VM while retaining its private disk |
|
||||
| `DELETE` | `/vms/{id}` | Destroy a VM and release its resources |
|
||||
|
||||
The API intentionally does not proxy interactive SSH. Use the returned guest
|
||||
IP with `uvm ssh` or a normal SSH client.
|
||||
|
||||
### API Security
|
||||
|
||||
The server controls privileged host operations. `UVM_API_TOKEN` is required
|
||||
for every API server, including loopback-only deployments. All management
|
||||
routes require it in the `X-UVM-Token` header. FastAPI's informational `/docs`,
|
||||
`/redoc`, and `/openapi.json` endpoints do not require a token.
|
||||
|
||||
A non-loopback bind additionally requires TLS certificate and key paths:
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-secret'
|
||||
export UVM_API_TLS_CERT='/etc/uvm/api-cert.pem'
|
||||
export UVM_API_TLS_KEY='/etc/uvm/api-key.pem'
|
||||
sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||
./uvm.py --serve --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Send the token on management requests:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
http://127.0.0.1:8000/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.
|
||||
|
||||
## Guest Image Contract
|
||||
|
||||
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 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`
|
||||
- The requested user account; UVM does not create missing users
|
||||
- Unique SSH host keys generated during first boot
|
||||
|
||||
The known public demo key bundled in Firecracker's default bionic image is
|
||||
removed from each private disk. Never bake SSH host private keys into a rootfs
|
||||
template shared by multiple VMs.
|
||||
|
||||
## Host Networking
|
||||
|
||||
By default, UVM manages:
|
||||
|
||||
```text
|
||||
Network: 10.42.0.0/24
|
||||
Gateway: 10.42.0.1
|
||||
Bridge: uvm0
|
||||
TAPs: uvm-<unique suffix>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Persistent State
|
||||
|
||||
The default data directory is `/var/lib/uvm`:
|
||||
|
||||
```text
|
||||
/var/lib/uvm/
|
||||
├── bin/ # Firecracker and Jailer binaries
|
||||
├── images/ # Kernel and rootfs templates
|
||||
├── vms/<vm-id>/ # Per-VM disk, config, log, and API socket
|
||||
├── integrity.json # Download/build checksums and verification provenance
|
||||
└── state.json # Root-only VM inventory and guest credentials
|
||||
```
|
||||
|
||||
State writes are locked and atomic. Lifecycle operations share an additional
|
||||
operation lock so concurrent `create`, `stop`, `destroy`, and `install`
|
||||
commands cannot overwrite each other's state or binaries.
|
||||
|
||||
The registry stores guest usernames and passwords in plaintext as requested,
|
||||
so `state.json` 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
|
||||
VM disks are not modified automatically.
|
||||
|
||||
## Configuration
|
||||
|
||||
All optional configuration is supplied through environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `UVM_BASE` | Data directory; defaults to `/var/lib/uvm` |
|
||||
| `UVM_NETWORK` | Guest 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_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 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 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 |
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
uvm/
|
||||
├── README.md
|
||||
├── pyproject.toml
|
||||
├── uvm.py # Executable compatibility launcher
|
||||
├── uvm/ # Private application implementation
|
||||
│ ├── cli.py # argparse, terminal output, SSH execution
|
||||
│ ├── app.py # Application composition root
|
||||
│ ├── api_models.py # FastAPI request and response models
|
||||
│ ├── config.py # Settings and filesystem layout
|
||||
│ ├── domain.py # Typed VM and state models
|
||||
│ ├── errors.py # Expected application failures
|
||||
│ ├── integrity.py # Checksums and manifest provenance
|
||||
│ ├── install.py # Host/artifact installation
|
||||
│ ├── images.py # Guest templates and per-VM disks
|
||||
│ ├── lifecycle.py # Create/list/stop/destroy orchestration
|
||||
│ ├── network.py # IP/MAC/TAP/bridge/NAT management
|
||||
│ ├── routers/ # Health, installation, and VM HTTP routers
|
||||
│ ├── server.py # FastAPI application factory and Uvicorn launcher
|
||||
│ ├── state.py # Locked atomic JSON persistence
|
||||
│ ├── system.py # Host command and KVM adapters
|
||||
│ └── firecracker/ # Firecracker config, API, and process adapters
|
||||
└── tests/ # Unit tests with mocked host boundaries
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
From the parent repository directory, run the unit suite from this project root:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install '.[test]'
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
The unit tests do not need root, KVM, Firecracker, or host network changes.
|
||||
They cover validation, state migration and locking, integrity manifests,
|
||||
network command rendering, Firecracker request ordering, process safety, and
|
||||
lifecycle rollback. API route tests run automatically when the FastAPI
|
||||
and `httpx` test dependencies are installed.
|
||||
|
||||
## Current Boundaries
|
||||
|
||||
- Fractional CPU requests still round Firecracker vCPUs up and are advisory.
|
||||
- `stop` retains the private VM disk; the current command surface does not yet
|
||||
include a separate `start` command.
|
||||
- Firecracker is launched directly by this local CLI. Jailer/cgroup hardening
|
||||
is not implemented by the current command set.
|
||||
- Run real KVM, TAP, and guest-connectivity checks on an isolated Linux host
|
||||
before relying on UVM for workloads.
|
||||
@@ -0,0 +1,774 @@
|
||||
# UVM Usage Guide
|
||||
|
||||
This guide explains how to install, operate, and troubleshoot UVM as an
|
||||
operator. It covers the command-line tool, the optional FastAPI server, common
|
||||
scenarios, and expected limitations.
|
||||
|
||||
For the project architecture and contributor guidance, see
|
||||
[DEVELOPER.md](DEVELOPER.md). For a concise overview, see [README.md](README.md).
|
||||
|
||||
## 1. What UVM Does
|
||||
|
||||
UVM creates small Linux microVMs through Firecracker and KVM on one Linux host.
|
||||
Each VM receives:
|
||||
|
||||
- A unique VM ID.
|
||||
- A private writable root disk copied from a shared template.
|
||||
- A private IP address and MAC address.
|
||||
- A TAP interface connected to a host bridge.
|
||||
- A dedicated Firecracker process, API socket, log, and config file.
|
||||
|
||||
By default, guest networking uses:
|
||||
|
||||
```text
|
||||
Guest network: 10.42.0.0/24
|
||||
Gateway: 10.42.0.1
|
||||
Bridge: uvm0
|
||||
Guest pool: 10.42.0.2 through 10.42.0.254
|
||||
```
|
||||
|
||||
The default locations on the host are under `/var/lib/uvm`.
|
||||
|
||||
## 2. Before You Start
|
||||
|
||||
### Host Requirements
|
||||
|
||||
You need:
|
||||
|
||||
- Linux, preferably Ubuntu or a compatible distribution.
|
||||
- Python 3.12 or newer.
|
||||
- KVM enabled and readable/writable at `/dev/kvm`.
|
||||
- Root access for installation and VM lifecycle operations.
|
||||
- An x86_64 host for the default guest kernel/rootfs downloads.
|
||||
- Internet access if downloading Firecracker and guest assets.
|
||||
|
||||
Check virtualization before installing:
|
||||
|
||||
```sh
|
||||
ls -l /dev/kvm
|
||||
lscpu | grep -i virtualization
|
||||
```
|
||||
|
||||
### Source Checkout Setup
|
||||
|
||||
From the parent directory of this project:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
./uvm.py --help
|
||||
python3 -m uvm --help
|
||||
```
|
||||
|
||||
To install the optional HTTP API dependencies and the console command:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install .
|
||||
uvm --help
|
||||
```
|
||||
|
||||
The rest of this guide uses `./uvm.py` so it works from a source checkout. If
|
||||
you installed the package, replace `./uvm.py` with `uvm`.
|
||||
|
||||
### Privilege Rules
|
||||
|
||||
| Operation | Root required? |
|
||||
|---|---|
|
||||
| `install` | Yes |
|
||||
| `create` | Yes |
|
||||
| `stop` | Yes |
|
||||
| `destroy` | Yes |
|
||||
| `list` | Yes with the default root-owned registry |
|
||||
| `ssh` | Yes when resolving a VM through the registry |
|
||||
| API server that manages VMs | Yes |
|
||||
|
||||
The registry contains plaintext guest credentials and is mode `0600`. Direct
|
||||
SSH to a known guest IP does not require root.
|
||||
|
||||
## 3. Command Overview
|
||||
|
||||
```sh
|
||||
./uvm.py --help
|
||||
|
||||
./uvm.py install [--force]
|
||||
./uvm.py create [--cpu CPU] [--ram RAM] [--host-ip GUEST_IP] \
|
||||
[--username USER] [--password PASSWORD]
|
||||
sudo ./uvm.py list
|
||||
sudo ./uvm.py ssh VM_OR_IP [--user USER] [--key PATH] [--insecure-host-key]
|
||||
./uvm.py stop VM_ID
|
||||
./uvm.py destroy VM_ID
|
||||
|
||||
./uvm.py --serve [--host HOST] [--port PORT]
|
||||
```
|
||||
|
||||
`--host-ip` is a legacy CLI name. It means the **guest IP address**, not the
|
||||
host's public or uplink address.
|
||||
|
||||
## 4. Install UVM Assets
|
||||
|
||||
UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
|
||||
rootfs template. It requires SHA-256 checksums by default because it downloads
|
||||
artifacts that will be used by a privileged process.
|
||||
|
||||
### Scenario: Strict First Installation
|
||||
|
||||
Obtain trusted checksums independently, then run:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
|
||||
sudo env \
|
||||
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
|
||||
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
|
||||
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
|
||||
./uvm.py install
|
||||
```
|
||||
|
||||
On success, UVM prints paths similar to:
|
||||
|
||||
```text
|
||||
Firecracker: /var/lib/uvm/bin/firecracker
|
||||
Kernel: /var/lib/uvm/images/vmlinux
|
||||
Rootfs: /var/lib/uvm/images/ubuntu.ext4
|
||||
```
|
||||
|
||||
The checksums and whether the installation was verified are recorded in:
|
||||
|
||||
```text
|
||||
/var/lib/uvm/integrity.json
|
||||
```
|
||||
|
||||
### Scenario: Refresh Firecracker or Guest Assets
|
||||
|
||||
Use `--force` after changing a URL, replacing an image, or rotating checksums:
|
||||
|
||||
```sh
|
||||
sudo env \
|
||||
UVM_FIRECRACKER_SHA256='<new-firecracker-archive-sha256>' \
|
||||
UVM_KERNEL_SHA256='<new-kernel-sha256>' \
|
||||
UVM_ROOTFS_SHA256='<new-rootfs-sha256>' \
|
||||
./uvm.py install --force
|
||||
```
|
||||
|
||||
`--force` downloads to temporary files and only replaces the existing artifact
|
||||
after checksum verification succeeds.
|
||||
|
||||
### Scenario: Local Experimentation Without Checksums
|
||||
|
||||
This mode is unsafe for production. It is useful only when you deliberately
|
||||
accept the source artifacts without checksum verification:
|
||||
|
||||
```sh
|
||||
sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install
|
||||
```
|
||||
|
||||
An unverified install is marked as unverified. It cannot later become a strict
|
||||
trusted installation merely by setting checksum variables. Reinstall with the
|
||||
strict command above when you are ready to trust the host assets.
|
||||
|
||||
For later local-development VM creation, keep the opt-out on the command too:
|
||||
|
||||
```sh
|
||||
sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py create
|
||||
```
|
||||
|
||||
The same rule applies to an API server that creates VMs from an unverified
|
||||
installation: start that server with `UVM_ALLOW_UNVERIFIED_DOWNLOADS=1`, or
|
||||
perform a strict reinstall first.
|
||||
|
||||
### Installation Failures
|
||||
|
||||
| Message | Meaning and action |
|
||||
|---|---|
|
||||
| `/dev/kvm does not exist` | Enable hardware/nested virtualization first. |
|
||||
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
|
||||
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
|
||||
| `unsupported host architecture` | Use x86_64 defaults or provide compatible assets. |
|
||||
| `default guest assets support x86_64 only` | Configure both custom kernel and rootfs URLs for ARM. |
|
||||
|
||||
## 5. Create VMs
|
||||
|
||||
### Scenario: Create a Default VM
|
||||
|
||||
The default is one CPU, 512 MiB RAM, username `root`, and password `root`:
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create
|
||||
```
|
||||
|
||||
Typical output includes the VM ID, guest IP, TAP name, and SSH target:
|
||||
|
||||
```text
|
||||
VM created: vm-...
|
||||
IP: 10.42.0.2
|
||||
RAM: 512 MiB
|
||||
CPU: 1.0
|
||||
TAP: uvm-...
|
||||
Username: root
|
||||
SSH: ssh root@10.42.0.2
|
||||
|
||||
WARNING: the guest is using the default password 'root'. Change it promptly.
|
||||
```
|
||||
|
||||
Set a different password during creation:
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create --username root --password '<guest-password>'
|
||||
```
|
||||
|
||||
`--username` must name an account already present in the guest image. The
|
||||
password is visible in the process arguments and may be recorded in shell
|
||||
history, so avoid reusing a sensitive host or service password.
|
||||
|
||||
### Scenario: Choose CPU and RAM
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create --cpu 2 --ram 2G
|
||||
sudo ./uvm.py create --cpu 1 --ram 1024M
|
||||
sudo ./uvm.py create --cpu 1 --ram 768
|
||||
```
|
||||
|
||||
RAM is in MiB by default. Supported suffixes include `B`, `K`, `M`, `G`,
|
||||
`KiB`, `MiB`, and `GiB`.
|
||||
|
||||
UVM enforces a minimum of 128 MiB. Firecracker uses whole vCPUs. A fractional
|
||||
request such as `--cpu 0.5` is accepted but rounds the VM up to one Firecracker
|
||||
vCPU; it does not currently apply a host CPU quota.
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create --cpu 0.5 --ram 512
|
||||
```
|
||||
|
||||
### Scenario: Request a Specific Guest IP
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create --host-ip 10.42.0.10
|
||||
```
|
||||
|
||||
The address must be inside the configured guest network, must not be the
|
||||
network address, broadcast address, or gateway, and must not already belong to
|
||||
another persisted VM.
|
||||
|
||||
### What Happens During Create
|
||||
|
||||
UVM performs these operations in order:
|
||||
|
||||
1. Validates root access, KVM, assets, and integrity state.
|
||||
2. Reserves a VM ID, IP, MAC, and state record.
|
||||
3. Creates `/var/lib/uvm/vms/<vm-id>/`.
|
||||
4. Copies the rootfs template to a private `rootfs.ext4` disk.
|
||||
5. Sets the requested account password and enables SSH password login in the copy.
|
||||
6. Ensures the bridge/NAT/forwarding rules exist.
|
||||
7. Creates a TAP device.
|
||||
8. Starts and configures Firecracker.
|
||||
9. Marks the VM as `running`.
|
||||
|
||||
If a step fails, UVM attempts to terminate the new VMM, remove the TAP, remove
|
||||
the private runtime directory, and release the reservation.
|
||||
|
||||
## 6. Inspect VMs
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py list
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
ID IP USER CPU RAM STATUS PID
|
||||
vm-0123456789abcdef... 10.42.0.2 root 1.0 512 running 12345
|
||||
```
|
||||
|
||||
Status meanings:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `starting` | VM creation is in progress. |
|
||||
| `running` | Firecracker started successfully. |
|
||||
| `stopping` | Stop is in progress. |
|
||||
| `stopped` | VMM and TAP are gone; disk remains. |
|
||||
| `terminating` | Destruction is in progress. |
|
||||
| `failed` | A lifecycle operation or cleanup failed. |
|
||||
| `dead` | Stored state claims the VM is active but its VMM process is gone. |
|
||||
|
||||
If a VM is `failed` or `dead`, inspect its log before deciding whether to stop
|
||||
or destroy it:
|
||||
|
||||
```sh
|
||||
sudo ls -la /var/lib/uvm/vms/<vm-id>
|
||||
sudo cat /var/lib/uvm/vms/<vm-id>/firecracker.log
|
||||
```
|
||||
|
||||
## 7. Connect with SSH
|
||||
|
||||
UVM provisions a password for an existing account. The default credentials are
|
||||
`root` / `root`. Your guest image must include:
|
||||
|
||||
- An SSH server.
|
||||
- The requested login user plus standard passwd and shadow files.
|
||||
- Unique SSH host keys generated at first boot.
|
||||
|
||||
This is applied only while creating a new VM. Existing VM disks and legacy
|
||||
registry entries are not changed retroactively.
|
||||
|
||||
### Scenario: SSH as Root
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py ssh vm-<id>
|
||||
ssh root@10.42.0.2
|
||||
```
|
||||
|
||||
### Scenario: Use a Different User and Private Key
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py ssh vm-<id> --user ubuntu --key ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
By default, UVM uses SSH `StrictHostKeyChecking=accept-new` and a stable
|
||||
host-key alias derived from the VM ID. This lets a guest IP be reused later
|
||||
without confusing it with the prior VM's known-hosts entry.
|
||||
|
||||
### Scenario: Temporarily Bypass Host-Key Verification
|
||||
|
||||
Use only for troubleshooting a guest you trust:
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py ssh vm-<id> --insecure-host-key
|
||||
```
|
||||
|
||||
This disables normal host-key checking for that connection.
|
||||
|
||||
## 8. Stop and Destroy VMs
|
||||
|
||||
### Scenario: Stop a VM and Keep Its Disk
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py stop vm-<id>
|
||||
```
|
||||
|
||||
Stopping terminates Firecracker and deletes the TAP device. The private root
|
||||
disk, config, and log remain under the VM runtime directory.
|
||||
|
||||
UVM does not currently offer a `start` command, so a stopped disk cannot be
|
||||
rebooted through the public CLI yet. Keep it only if you need to inspect it or
|
||||
expect a future start feature.
|
||||
|
||||
### Scenario: Permanently Remove a VM
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py destroy vm-<id>
|
||||
```
|
||||
|
||||
Destroying a VM:
|
||||
|
||||
1. Terminates the VMM if it is alive.
|
||||
2. Deletes the TAP device.
|
||||
3. Deletes the VM runtime directory and private root disk.
|
||||
4. Removes the VM record from `state.json`.
|
||||
5. Releases its IP address for later allocation.
|
||||
|
||||
This is destructive. Back up guest data before running it.
|
||||
|
||||
## 9. Use the FastAPI Server
|
||||
|
||||
The API is an alternative management surface for the same local lifecycle
|
||||
services. It does not make UVM multi-host or safe to expose directly to the
|
||||
public internet.
|
||||
|
||||
### Scenario: Start a Local API Server
|
||||
|
||||
Install dependencies first:
|
||||
|
||||
```sh
|
||||
cd uvm
|
||||
python3 -m pip install .
|
||||
```
|
||||
|
||||
Then start the server as root so lifecycle endpoints can manipulate KVM,
|
||||
networking, and host files:
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
||||
|
||||
sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
./uvm.py --serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Open these local URLs after it starts:
|
||||
|
||||
```text
|
||||
Health: http://127.0.0.1:8000/health
|
||||
OpenAPI: http://127.0.0.1:8000/docs
|
||||
```
|
||||
|
||||
The API token is required even for a loopback server. All VM and installation
|
||||
routes require it. FastAPI's informational `/docs`, `/redoc`, and
|
||||
`/openapi.json` endpoints are also available without a token; they do not
|
||||
perform host mutations.
|
||||
|
||||
### Scenario: Start a Remote TLS API Server
|
||||
|
||||
Non-loopback binds require a valid TLS certificate and key. The files must be
|
||||
readable by the server process:
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token'
|
||||
export UVM_API_TLS_CERT='/etc/uvm/api-cert.pem'
|
||||
export UVM_API_TLS_KEY='/etc/uvm/api-key.pem'
|
||||
|
||||
sudo env \
|
||||
UVM_API_TOKEN="$UVM_API_TOKEN" \
|
||||
UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \
|
||||
UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \
|
||||
./uvm.py --serve --host 0.0.0.0 --port 8443
|
||||
```
|
||||
|
||||
For real remote use, use a certificate trusted by clients and restrict network
|
||||
access with a firewall or reverse proxy. Do not use a self-signed development
|
||||
certificate for an untrusted network.
|
||||
|
||||
### API Authentication
|
||||
|
||||
Set one reusable shell variable for API examples:
|
||||
|
||||
```sh
|
||||
export UVM_API_TOKEN='replace-with-the-server-token'
|
||||
export UVM_API_URL='http://127.0.0.1:8000'
|
||||
```
|
||||
|
||||
Every management request needs:
|
||||
|
||||
```http
|
||||
X-UVM-Token: <token>
|
||||
```
|
||||
|
||||
For a TLS server, set `UVM_API_URL` to an `https://` URL and use your CA:
|
||||
|
||||
```sh
|
||||
export UVM_API_URL='https://host.example:8443'
|
||||
curl --cacert /path/to/ca.pem \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms"
|
||||
```
|
||||
|
||||
## 10. API Endpoint Examples
|
||||
|
||||
### Health Check
|
||||
|
||||
No token is required:
|
||||
|
||||
```sh
|
||||
curl "$UVM_API_URL/health"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
### List VMs
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms"
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "vm-0123456789abcdef",
|
||||
"cpu": 1.0,
|
||||
"ram_mib": 512,
|
||||
"guest_ip": "10.42.0.2",
|
||||
"gateway": "10.42.0.1",
|
||||
"mac": "02:fc:00:00:00:01",
|
||||
"username": "root",
|
||||
"status": "running",
|
||||
"observed_status": "running",
|
||||
"pid": 12345,
|
||||
"created_at": 1700000000,
|
||||
"updated_at": 1700000000,
|
||||
"last_error": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Create a VM
|
||||
|
||||
Create a default-sized VM:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
-d '{"cpu": 1, "ram": "512", "username": "root", "password": "root"}' \
|
||||
"$UVM_API_URL/vms"
|
||||
```
|
||||
|
||||
Create a VM with a chosen guest address:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
-d '{"cpu": 2, "ram": "2G", "guest_ip": "10.42.0.10", "username": "root", "password": "replace-me"}' \
|
||||
"$UVM_API_URL/vms"
|
||||
```
|
||||
|
||||
The response is the VM record with its username, but never its password.
|
||||
Omitting credentials uses `root` / `root`. Request fields are strict:
|
||||
misspelled or extra fields return HTTP `422` instead of being silently ignored.
|
||||
|
||||
### Look Up One VM
|
||||
|
||||
By ID:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms/vm-0123456789abcdef"
|
||||
```
|
||||
|
||||
By guest IP:
|
||||
|
||||
```sh
|
||||
curl \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms/10.42.0.2"
|
||||
```
|
||||
|
||||
### Stop a VM
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms/vm-0123456789abcdef/stop"
|
||||
```
|
||||
|
||||
The response is the VM record with `status: "stopped"`.
|
||||
|
||||
### Destroy a VM
|
||||
|
||||
```sh
|
||||
curl -X DELETE \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
"$UVM_API_URL/vms/vm-0123456789abcdef"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "vm-0123456789abcdef",
|
||||
"status": "terminated"
|
||||
}
|
||||
```
|
||||
|
||||
### Install or Refresh Through the API
|
||||
|
||||
The server process must itself have checksum variables configured for strict
|
||||
installation. Start the server with those environment variables before using
|
||||
this endpoint.
|
||||
|
||||
For a deliberately unverified local-development server, the server process
|
||||
must instead include `UVM_ALLOW_UNVERIFIED_DOWNLOADS=1`. Do not use that mode
|
||||
on a host that runs untrusted workloads.
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
-d '{"force": false}' \
|
||||
"$UVM_API_URL/install"
|
||||
```
|
||||
|
||||
Refresh all downloaded artifacts:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-UVM-Token: $UVM_API_TOKEN" \
|
||||
-d '{"force": true}' \
|
||||
"$UVM_API_URL/install"
|
||||
```
|
||||
|
||||
## 11. API Errors
|
||||
|
||||
| Status | Typical meaning |
|
||||
|---|---|
|
||||
| `401` | Missing or incorrect `X-UVM-Token`. |
|
||||
| `403` | UVM process lacks root privileges for a host mutation. |
|
||||
| `404` | Requested VM was not found. |
|
||||
| `409` | A lifecycle operation is already in progress. |
|
||||
| `422` | Invalid JSON body, extra request fields, or invalid UVM input. |
|
||||
| `500` | Host command, Firecracker, state, or unexpected operational failure. |
|
||||
|
||||
UVM operation errors use this envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "human-readable explanation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
FastAPI request-schema errors use FastAPI's normal `detail` response format.
|
||||
|
||||
## 12. Configuration Scenarios
|
||||
|
||||
### Scenario: Use a Different Storage Directory
|
||||
|
||||
```sh
|
||||
sudo env \
|
||||
UVM_BASE='/srv/uvm' \
|
||||
UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 \
|
||||
./uvm.py install
|
||||
```
|
||||
|
||||
All UVM binaries, images, VM runtime directories, locks, and state use the
|
||||
configured base path.
|
||||
|
||||
### Scenario: Use a Different Guest Network
|
||||
|
||||
```sh
|
||||
sudo env \
|
||||
UVM_NETWORK='10.50.0.0/24' \
|
||||
UVM_GATEWAY='10.50.0.1' \
|
||||
UVM_BRIDGE='uvm50' \
|
||||
UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 \
|
||||
./uvm.py install
|
||||
```
|
||||
|
||||
Use the same environment values for later `create`, `list`, `stop`, `destroy`,
|
||||
and server commands. Changing the network variables after VMs exist can make
|
||||
their persisted state inconsistent with host networking.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Use |
|
||||
|---|---|
|
||||
| `UVM_BASE` | Base data directory; default `/var/lib/uvm` |
|
||||
| `UVM_NETWORK` | Guest subnet; default `10.42.0.0/24` |
|
||||
| `UVM_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` |
|
||||
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
|
||||
| `UVM_KERNEL_URL` | Custom kernel download URL |
|
||||
| `UVM_ROOTFS_URL` | Custom rootfs download URL |
|
||||
| `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum |
|
||||
| `UVM_KERNEL_SHA256` | Strict kernel checksum |
|
||||
| `UVM_ROOTFS_SHA256` | Strict rootfs checksum |
|
||||
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional local binary checksum |
|
||||
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for local development |
|
||||
| `UVM_API_TOKEN` | Required API token |
|
||||
| `UVM_API_TLS_CERT` | TLS certificate for remote server binds |
|
||||
| `UVM_API_TLS_KEY` | TLS key for remote server binds |
|
||||
|
||||
## 13. Files, Logs, and State
|
||||
|
||||
The default host layout is:
|
||||
|
||||
```text
|
||||
/var/lib/uvm/
|
||||
├── bin/ # Firecracker and Jailer binaries
|
||||
├── images/ # Shared kernel and rootfs template
|
||||
├── vms/<vm-id>/ # Private rootfs, config, socket, and log
|
||||
├── integrity.json # Artifact checksums and verification status
|
||||
└── state.json # Mode-0600 VM inventory and plaintext guest credentials
|
||||
```
|
||||
|
||||
Useful inspection commands:
|
||||
|
||||
```sh
|
||||
sudo cat /var/lib/uvm/state.json
|
||||
sudo ls -la /var/lib/uvm/vms
|
||||
sudo cat /var/lib/uvm/vms/<vm-id>/firecracker.log
|
||||
sudo cat /var/lib/uvm/vms/<vm-id>/config.json
|
||||
```
|
||||
|
||||
Avoid editing `state.json`, VM config files, runtime directories, TAPs, or
|
||||
iptables rules by hand while UVM is running. Use UVM commands or the API so
|
||||
locks and cleanup rules remain correct.
|
||||
|
||||
`state.json` contains plaintext guest passwords. Keep it root-only, never copy
|
||||
it into logs or bug reports, and use unique non-default passwords outside local
|
||||
throwaway environments.
|
||||
|
||||
## 14. Networking Troubleshooting
|
||||
|
||||
Check the bridge and TAP state:
|
||||
|
||||
```sh
|
||||
ip link show uvm0
|
||||
ip addr show dev uvm0
|
||||
ip route show default
|
||||
```
|
||||
|
||||
Check NAT and forwarding rules:
|
||||
|
||||
```sh
|
||||
sudo iptables -t nat -S POSTROUTING
|
||||
sudo iptables -S FORWARD
|
||||
```
|
||||
|
||||
Common issues:
|
||||
|
||||
| Symptom | First checks |
|
||||
|---|---|
|
||||
| Guest cannot reach the internet | Bridge is up, TAP is up, host has default route, NAT and FORWARD rules exist. |
|
||||
| Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. |
|
||||
| Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. |
|
||||
| Existing bridge is rejected | `UVM_BRIDGE` points to an interface that is not a Linux bridge. |
|
||||
| SSH cannot connect | Confirm guest booted, the requested user exists, the image runs OpenSSH, and the configured password is being used. |
|
||||
|
||||
## 15. Important Limitations
|
||||
|
||||
Keep these constraints in mind when deciding whether UVM fits a scenario:
|
||||
|
||||
- Firecracker is launched directly; the downloaded Jailer is not yet used.
|
||||
- No cgroup CPU, memory, I/O, or process limits are applied.
|
||||
- Fractional CPU requests are advisory only.
|
||||
- There is no public `start` or `restart` command after a VM is stopped.
|
||||
- There is no DHCP, inbound port mapping, load balancing, multi-network
|
||||
isolation, or multi-host scheduling.
|
||||
- The API uses one shared bearer token, not individual users, roles, or tenants.
|
||||
- There is no guest readiness probe beyond Firecracker process state.
|
||||
- UVM is not a hardened multi-tenant platform.
|
||||
|
||||
## 16. Safe Operating Checklist
|
||||
|
||||
Before relying on a VM for useful work:
|
||||
|
||||
1. Use strict checksums for installed artifacts.
|
||||
2. Confirm `/dev/kvm` and nested virtualization are available.
|
||||
3. Use a guest image with unique first-boot SSH host keys and set a non-default password.
|
||||
4. Test guest egress and SSH on the actual host network.
|
||||
5. Back up guest data before destroy operations.
|
||||
6. Keep the API loopback-only unless you have a strong remote-access need.
|
||||
7. For remote API access, use a long random token, valid TLS, and firewall
|
||||
restrictions.
|
||||
8. Review `/var/lib/uvm/vms/<vm-id>/firecracker.log` after failed starts.
|
||||
|
||||
## 17. Get Help
|
||||
|
||||
```sh
|
||||
./uvm.py --help
|
||||
./uvm.py install --help
|
||||
./uvm.py --serve --help
|
||||
```
|
||||
|
||||
When reporting a problem, collect:
|
||||
|
||||
- The exact command or HTTP request.
|
||||
- The full error message.
|
||||
- Output from `sudo ./uvm.py list`.
|
||||
- The VM Firecracker log, if a VM was created.
|
||||
- The relevant `UVM_*` variables, with secrets such as `UVM_API_TOKEN` removed.
|
||||
|
||||
Never include `/var/lib/uvm/state.json` because it contains guest passwords.
|
||||
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "uvm-cli"
|
||||
version = "0.1.0"
|
||||
description = "Local Firecracker microVM command-line manager"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
"uvicorn>=0.30,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["httpx>=0.27,<1"]
|
||||
|
||||
[project.scripts]
|
||||
uvm = "uvm.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["uvm", "uvm.firecracker", "uvm.routers"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the local uvm command-line application."""
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.cli import _run_command
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.images import GuestAssets
|
||||
|
||||
|
||||
def make_vm() -> VmRecord:
|
||||
return 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",
|
||||
username="admin",
|
||||
password="stored-secret",
|
||||
)
|
||||
|
||||
|
||||
class CliSshTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.vm = make_vm()
|
||||
self.application = SimpleNamespace(
|
||||
lifecycle=SimpleNamespace(find_for_ssh=lambda _identifier: self.vm)
|
||||
)
|
||||
|
||||
def test_ssh_uses_a_stable_vm_host_key_alias_by_default(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user="root",
|
||||
insecure_host_key=False,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
command = execvp.call_args.args[1]
|
||||
self.assertIn("HostKeyAlias=uvm-vm-test", command)
|
||||
self.assertIn("StrictHostKeyChecking=accept-new", command)
|
||||
self.assertNotIn("StrictHostKeyChecking=no", command)
|
||||
|
||||
def test_ssh_only_disables_verification_when_explicitly_requested(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user="root",
|
||||
insecure_host_key=True,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
command = execvp.call_args.args[1]
|
||||
self.assertIn("StrictHostKeyChecking=no", command)
|
||||
self.assertNotIn("HostKeyAlias=uvm-vm-test", command)
|
||||
|
||||
def test_ssh_uses_the_username_stored_for_the_vm(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user=None,
|
||||
insecure_host_key=False,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
self.assertEqual(execvp.call_args.args[1][-1], "admin@10.42.0.2")
|
||||
|
||||
|
||||
class CliCreateTests(unittest.TestCase):
|
||||
def test_create_passes_credentials_without_printing_the_password(self) -> None:
|
||||
captured = None
|
||||
|
||||
def create(spec):
|
||||
nonlocal captured
|
||||
captured = spec
|
||||
vm = make_vm()
|
||||
vm.username = spec.username
|
||||
vm.password = spec.password
|
||||
return vm
|
||||
|
||||
application = SimpleNamespace(lifecycle=SimpleNamespace(create=create))
|
||||
args = argparse.Namespace(
|
||||
command="create",
|
||||
cpu="1",
|
||||
ram="512",
|
||||
host_ip=None,
|
||||
username="admin",
|
||||
password="custom-secret",
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
result = _run_command(application, args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured.username, "admin")
|
||||
self.assertEqual(captured.password, "custom-secret")
|
||||
self.assertNotIn("custom-secret", output.getvalue())
|
||||
|
||||
|
||||
class CliInstallTests(unittest.TestCase):
|
||||
def test_install_next_step_does_not_assume_an_installed_console_command(self) -> None:
|
||||
state_store = SimpleNamespace(initialize=lambda: None)
|
||||
application = SimpleNamespace(
|
||||
settings=SimpleNamespace(allow_unverified_downloads=False),
|
||||
installer=SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
GuestAssets(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
),
|
||||
state_store=state_store,
|
||||
)
|
||||
args = argparse.Namespace(command="install", force=False)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
result = _run_command(application, args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertIn("Run your UVM command with: create --cpu 1 --ram 512", output.getvalue())
|
||||
self.assertNotIn("sudo uvm create", output.getvalue())
|
||||
|
||||
def test_unverified_install_reminds_the_operator_to_keep_the_opt_out(self) -> None:
|
||||
application = SimpleNamespace(
|
||||
settings=SimpleNamespace(allow_unverified_downloads=True),
|
||||
installer=SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
GuestAssets(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
),
|
||||
state_store=SimpleNamespace(initialize=lambda: None),
|
||||
)
|
||||
args = argparse.Namespace(command="install", force=False)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
_run_command(application, args)
|
||||
|
||||
self.assertIn("UVM_ALLOW_UNVERIFIED_DOWNLOADS=1", output.getvalue())
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.firecracker.api import FirecrackerClient
|
||||
from uvm.firecracker.config import build_config, vcpu_count
|
||||
|
||||
|
||||
class RecordingFirecrackerClient(FirecrackerClient):
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, object]] = []
|
||||
|
||||
def _request(self, method: str, path: str, body: object) -> None:
|
||||
self.calls.append((method, path, body))
|
||||
|
||||
|
||||
class FirecrackerConfigTests(unittest.TestCase):
|
||||
def test_builds_a_writable_vm_disk_configuration(self) -> None:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=0.5,
|
||||
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",
|
||||
disk="/vm/rootfs.ext4",
|
||||
)
|
||||
config = build_config(
|
||||
Settings(),
|
||||
vm,
|
||||
Path("/images/vmlinux"),
|
||||
Path("/vm/rootfs.ext4"),
|
||||
)
|
||||
|
||||
self.assertEqual(vcpu_count(0.5), 1)
|
||||
self.assertEqual(config["machine-config"]["mem_size_mib"], 512)
|
||||
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))
|
||||
|
||||
def test_configures_firecracker_in_required_order_before_start(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",
|
||||
disk="/vm/rootfs.ext4",
|
||||
)
|
||||
config = build_config(
|
||||
Settings(), vm, Path("/images/vmlinux"), Path("/vm/rootfs.ext4")
|
||||
)
|
||||
client = RecordingFirecrackerClient()
|
||||
|
||||
client.configure_and_start(config)
|
||||
|
||||
self.assertEqual(
|
||||
[path for _method, path, _body in client.calls],
|
||||
[
|
||||
"/machine-config",
|
||||
"/boot-source",
|
||||
"/drives/rootfs",
|
||||
"/network-interfaces/eth0",
|
||||
"/actions",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
client.calls[0][2],
|
||||
{"vcpu_count": 1, "mem_size_mib": 512, "smt": False},
|
||||
)
|
||||
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
|
||||
|
||||
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
|
||||
settings = Settings(
|
||||
network=IPv4Network("10.50.0.0/16"),
|
||||
gateway=IPv4Address("10.50.0.1"),
|
||||
)
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.50.0.2",
|
||||
gateway="10.50.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
)
|
||||
|
||||
config = build_config(settings, vm, Path("/images/vmlinux"), Path("/vm/rootfs.ext4"))
|
||||
|
||||
self.assertIn("ip=10.50.0.2::10.50.0.1:255.255.0.0", config["boot-source"]["boot_args"])
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.errors import UvmError
|
||||
from uvm.images import (
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY,
|
||||
_enable_ssh_password_authentication,
|
||||
_set_shadow_password,
|
||||
_without_firecracker_demo_key,
|
||||
ImageStore,
|
||||
)
|
||||
from uvm.integrity import write_manifest
|
||||
from uvm.system import CommandResult
|
||||
|
||||
|
||||
class PasswordHashRunner:
|
||||
def __init__(self) -> None:
|
||||
self.command: tuple[str, ...] | None = None
|
||||
self.options: dict[str, object] = {}
|
||||
|
||||
def run(self, command, **options) -> CommandResult:
|
||||
self.command = tuple(str(part) for part in command)
|
||||
self.options = options
|
||||
return CommandResult(self.command, 0, stdout="$6$salt$password-hash\n")
|
||||
|
||||
|
||||
class ImageStoreTests(unittest.TestCase):
|
||||
def test_uses_the_persisted_install_manifest_when_strict_mode_is_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = Settings(base=Path(temporary_directory), allow_unverified_downloads=False)
|
||||
settings.images_dir.mkdir(parents=True)
|
||||
settings.kernel_image.write_bytes(b"kernel")
|
||||
settings.rootfs_image.write_bytes(b"rootfs")
|
||||
write_manifest(
|
||||
settings.integrity_manifest_path,
|
||||
{
|
||||
"kernel": hashlib.sha256(b"kernel").hexdigest(),
|
||||
"rootfs": hashlib.sha256(b"rootfs").hexdigest(),
|
||||
"firecracker": "0" * 64,
|
||||
"jailer": "1" * 64,
|
||||
},
|
||||
verified=True,
|
||||
)
|
||||
|
||||
assets = ImageStore(settings).installed_assets()
|
||||
|
||||
self.assertEqual(assets.kernel.name, "vmlinux")
|
||||
self.assertEqual(assets.rootfs.name, "ubuntu.ext4")
|
||||
|
||||
def test_hashes_password_through_stdin_without_putting_it_in_argv(self) -> None:
|
||||
runner = PasswordHashRunner()
|
||||
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
|
||||
|
||||
password_hash = store._password_hash("secret-value")
|
||||
|
||||
self.assertEqual(password_hash, "$6$salt$password-hash")
|
||||
self.assertEqual(runner.command, ("openssl", "passwd", "-6", "-stdin"))
|
||||
self.assertNotIn("secret-value", runner.command)
|
||||
self.assertEqual(runner.options["input_text"], "secret-value\n")
|
||||
self.assertTrue(runner.options["sensitive"])
|
||||
|
||||
def test_updates_only_the_requested_shadow_entry(self) -> None:
|
||||
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
|
||||
|
||||
updated = _set_shadow_password(original, "root", "$6$salt$hash")
|
||||
|
||||
self.assertEqual(
|
||||
updated,
|
||||
"root:$6$salt$hash:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n",
|
||||
)
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
_set_shadow_password(original, "missing", "$6$salt$hash")
|
||||
|
||||
def test_enables_root_password_login_before_existing_sshd_settings(self) -> None:
|
||||
updated = _enable_ssh_password_authentication(
|
||||
"PasswordAuthentication no\nPermitRootLogin prohibit-password\n",
|
||||
"root",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
updated.startswith(
|
||||
"# Managed by uvm\nPasswordAuthentication yes\nPermitRootLogin yes\n"
|
||||
)
|
||||
)
|
||||
|
||||
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
||||
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
||||
|
||||
updated = _without_firecracker_demo_key(
|
||||
f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\n{own_key}\n"
|
||||
)
|
||||
|
||||
self.assertEqual(updated, f"{own_key}\n")
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.errors import UvmError
|
||||
from uvm.integrity import load_manifest, verify_file, write_manifest
|
||||
|
||||
|
||||
class IntegrityTests(unittest.TestCase):
|
||||
def test_verifies_a_matching_sha256(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"trusted artifact")
|
||||
digest = hashlib.sha256(b"trusted artifact").hexdigest()
|
||||
|
||||
verify_file(
|
||||
artifact,
|
||||
digest,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
|
||||
def test_rejects_missing_or_mismatched_checksums_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"artifact")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
verify_file(
|
||||
artifact,
|
||||
None,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
with self.assertRaises(UvmError):
|
||||
verify_file(
|
||||
artifact,
|
||||
"0" * 64,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
|
||||
def test_allows_an_explicit_local_development_opt_out(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"artifact")
|
||||
|
||||
verify_file(
|
||||
artifact,
|
||||
None,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=True,
|
||||
)
|
||||
|
||||
def test_unverified_manifest_is_not_promoted_to_a_verified_install(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
manifest_path = Path(temporary_directory) / "integrity.json"
|
||||
write_manifest(manifest_path, {"kernel": "0" * 64}, verified=False)
|
||||
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
self.assertFalse(manifest.verified)
|
||||
self.assertEqual(manifest.checksums["kernel"], "0" * 64)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmSpec
|
||||
from uvm.errors import FirecrackerError, UvmError
|
||||
from uvm.firecracker.process import ProcessInfo
|
||||
from uvm.images import ImageStore
|
||||
from uvm.lifecycle import LifecycleService
|
||||
from uvm.state import StateStore
|
||||
|
||||
|
||||
class FakeNetwork:
|
||||
def __init__(self) -> None:
|
||||
self.created_taps: list[str] = []
|
||||
self.deleted_taps: list[str] = []
|
||||
|
||||
def allocate_ip(self, _vms, requested: IPv4Address | None) -> IPv4Address:
|
||||
return requested or IPv4Address("10.42.0.2")
|
||||
|
||||
@staticmethod
|
||||
def mac_for(index: int) -> str:
|
||||
return f"02:fc:00:00:00:{index:02x}"
|
||||
|
||||
@staticmethod
|
||||
def tap_name(vm_id: str) -> str:
|
||||
return f"uvm-{vm_id[-8:]}"[:15]
|
||||
|
||||
def ensure_bridge(self) -> None:
|
||||
return None
|
||||
|
||||
def create_tap(self, name: str) -> None:
|
||||
self.created_taps.append(name)
|
||||
|
||||
def delete_tap(self, name: str) -> None:
|
||||
self.deleted_taps.append(name)
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self) -> None:
|
||||
self.started = False
|
||||
self.terminated: list[int | None] = []
|
||||
self.alive = True
|
||||
|
||||
def start(self, _vm) -> ProcessInfo:
|
||||
self.started = True
|
||||
return ProcessInfo(pid=12345, start_time="42")
|
||||
|
||||
def is_alive(self, _vm) -> bool:
|
||||
return self.alive
|
||||
|
||||
def terminate(self, vm) -> None:
|
||||
self.terminated.append(vm.pid)
|
||||
self.alive = False
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, should_fail: bool = False) -> None:
|
||||
self.should_fail = should_fail
|
||||
self.config = None
|
||||
|
||||
def configure_and_start(self, config) -> None:
|
||||
self.config = config
|
||||
if self.should_fail:
|
||||
raise FirecrackerError("simulated API failure")
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||
base = Path(self._temporary_directory.name) / "uvm"
|
||||
self.settings = replace(Settings(), base=base, allow_unverified_downloads=True)
|
||||
self.settings.bin_dir.mkdir(parents=True)
|
||||
self.settings.firecracker_binary.touch()
|
||||
self.settings.images_dir.mkdir(parents=True)
|
||||
self.settings.kernel_image.write_bytes(b"kernel")
|
||||
self.settings.rootfs_image.write_bytes(b"rootfs")
|
||||
self.store = StateStore(self.settings)
|
||||
self.images = ImageStore(self.settings)
|
||||
self.images.provision_credentials = Mock() # type: ignore[method-assign]
|
||||
self.network = FakeNetwork()
|
||||
self.process = FakeProcess()
|
||||
self.client = FakeClient()
|
||||
self.service = LifecycleService(
|
||||
self.settings,
|
||||
self.store,
|
||||
self.images,
|
||||
self.network, # type: ignore[arg-type]
|
||||
self.process, # type: ignore[arg-type]
|
||||
client_factory=lambda _socket, _timeout: self.client, # type: ignore[arg-type]
|
||||
)
|
||||
self._root_patch = patch("uvm.lifecycle.require_root")
|
||||
self._kvm_patch = patch("uvm.lifecycle.check_kvm")
|
||||
self._root_patch.start()
|
||||
self._kvm_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._kvm_patch.stop()
|
||||
self._root_patch.stop()
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def test_create_uses_a_private_disk_and_persists_running_state(self) -> None:
|
||||
vm = self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(vm.status, "running")
|
||||
self.assertNotEqual(Path(vm.disk), self.settings.rootfs_image)
|
||||
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
|
||||
self.assertEqual(vm.username, "root")
|
||||
self.assertEqual(vm.password, "root")
|
||||
self.assertEqual(self.store.load().vms[vm.id].pid, 12345)
|
||||
self.assertEqual(self.network.created_taps, [vm.tap])
|
||||
self.assertEqual(self.client.config["drives"][0]["path_on_host"], vm.disk)
|
||||
self.images.provision_credentials.assert_called_once_with(
|
||||
Path(vm.disk),
|
||||
"root",
|
||||
"root",
|
||||
)
|
||||
|
||||
def test_create_persists_and_provisions_custom_guest_credentials(self) -> None:
|
||||
vm = self.service.create(
|
||||
VmSpec(cpu=1, ram_mib=512, username="admin", password="secret-value")
|
||||
)
|
||||
|
||||
persisted = self.store.load().vms[vm.id]
|
||||
self.assertEqual(persisted.username, "admin")
|
||||
self.assertEqual(persisted.password, "secret-value")
|
||||
self.images.provision_credentials.assert_called_once_with(
|
||||
Path(vm.disk),
|
||||
"admin",
|
||||
"secret-value",
|
||||
)
|
||||
|
||||
def test_create_rolls_back_when_firecracker_configuration_fails(self) -> None:
|
||||
self.client.should_fail = True
|
||||
|
||||
with self.assertRaises(FirecrackerError):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(len(self.network.deleted_taps), 1)
|
||||
self.assertEqual(self.process.terminated, [12345])
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_create_rolls_back_before_network_setup_when_provisioning_fails(self) -> None:
|
||||
self.images.provision_credentials.side_effect = UvmError("invalid guest image")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(self.network.created_taps, [])
|
||||
self.assertFalse(self.process.started)
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_create_rolls_back_when_interrupted(self) -> None:
|
||||
def interrupting_create_tap(_name: str) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
self.network.create_tap = interrupting_create_tap
|
||||
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_stop_keeps_disk_and_destroy_releases_state(self) -> None:
|
||||
vm = self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
disk = Path(vm.disk)
|
||||
|
||||
stopped = self.service.stop(vm.id)
|
||||
self.assertEqual(stopped.status, "stopped")
|
||||
self.assertTrue(disk.exists())
|
||||
self.assertIsNone(stopped.pid)
|
||||
|
||||
destroyed = self.service.destroy(vm.id)
|
||||
self.assertEqual(destroyed.id, vm.id)
|
||||
self.assertFalse(self.settings.vm_dir(vm.id).exists())
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.errors import UvmError, ValidationError
|
||||
from uvm.network import NetworkManager
|
||||
from uvm.system import CommandResult
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
self.existing_links: set[str] = set()
|
||||
self.link_details: dict[str, str] = {}
|
||||
|
||||
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
|
||||
del check, capture, timeout
|
||||
args = tuple(str(part) for part in command)
|
||||
self.calls.append(args)
|
||||
if args[:3] == ("ip", "link", "show") and args[3] in self.existing_links:
|
||||
return CommandResult(args=args, returncode=0)
|
||||
if args[:6] == ("ip", "-j", "-d", "link", "show", "dev"):
|
||||
return CommandResult(args=args, returncode=0, stdout=self.link_details[args[6]])
|
||||
if args[:4] == ("ip", "link", "show", "uvm0"):
|
||||
return CommandResult(args=args, returncode=1)
|
||||
if args == ("ip", "route", "show", "default"):
|
||||
return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n")
|
||||
if len(args) > 3 and args[0] == "iptables" and "-C" in args:
|
||||
return CommandResult(args=args, returncode=1)
|
||||
return CommandResult(args=args, returncode=0)
|
||||
|
||||
|
||||
class NetworkManagerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.runner = FakeRunner()
|
||||
self.network = NetworkManager(Settings(), runner=self.runner) # type: ignore[arg-type]
|
||||
|
||||
def test_allocates_first_available_guest_address(self) -> None:
|
||||
self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2"))
|
||||
|
||||
def test_rejects_gateway_and_used_requested_addresses(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",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([vm], IPv4Address("10.42.0.1"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([vm], IPv4Address("10.42.0.2"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([], IPv4Address("10.42.0.0"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([], IPv4Address("10.42.0.255"))
|
||||
|
||||
def test_mac_and_tap_names_are_bounded_and_deterministic(self) -> None:
|
||||
self.assertEqual(NetworkManager.mac_for(1), "02:fc:00:00:00:01")
|
||||
tap = NetworkManager.tap_name("vm-0123456789abcdef")
|
||||
self.assertLessEqual(len(tap), 15)
|
||||
self.assertTrue(tap.startswith("uvm-"))
|
||||
|
||||
def test_bridge_setup_adds_a_missing_bridge_and_one_nat_rule(self) -> None:
|
||||
self.network.ensure_bridge()
|
||||
|
||||
self.assertIn(("ip", "link", "add", "uvm0", "type", "bridge"), self.runner.calls)
|
||||
self.assertIn(
|
||||
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
||||
self.runner.calls,
|
||||
)
|
||||
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.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(
|
||||
network=IPv4Network("10.50.0.0/16"),
|
||||
gateway=IPv4Address("10.50.0.1"),
|
||||
)
|
||||
runner = FakeRunner()
|
||||
NetworkManager(settings, runner=runner).ensure_bridge() # type: ignore[arg-type]
|
||||
|
||||
self.assertIn(
|
||||
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
|
||||
runner.calls,
|
||||
)
|
||||
|
||||
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
|
||||
self.runner.existing_links.add("uvm-existing")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.network.create_tap("uvm-existing")
|
||||
|
||||
self.assertNotIn(
|
||||
("ip", "tuntap", "add", "dev", "uvm-existing", "mode", "tap"),
|
||||
self.runner.calls,
|
||||
)
|
||||
|
||||
def test_refuses_to_reconfigure_an_existing_non_bridge_interface(self) -> None:
|
||||
self.runner.existing_links.add("uvm0")
|
||||
self.runner.link_details["uvm0"] = '[{"linkinfo": {"info_kind": "dummy"}}]'
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.network.ensure_bridge()
|
||||
|
||||
self.assertNotIn(
|
||||
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
||||
self.runner.calls,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
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.process import FirecrackerProcessManager
|
||||
|
||||
|
||||
def make_vm(pid: int | None, start_time: str | None) -> VmRecord:
|
||||
return 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/uvm-missing.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
pid=pid,
|
||||
process_start_time=start_time,
|
||||
)
|
||||
|
||||
|
||||
class FirecrackerProcessSafetyTests(unittest.TestCase):
|
||||
def test_refuses_to_signal_an_unidentified_legacy_pid(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=12345, start_time=None)
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(FirecrackerError):
|
||||
manager.terminate(vm)
|
||||
|
||||
kill.assert_called_once_with(12345, 0)
|
||||
|
||||
def test_allows_cleanup_when_an_unidentified_legacy_pid_is_already_gone(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=12345, start_time=None)
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill", side_effect=ProcessLookupError):
|
||||
manager.terminate(vm)
|
||||
|
||||
def test_rejects_non_positive_pids_without_signaling(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=0, start_time="42")
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(FirecrackerError):
|
||||
manager.terminate(vm)
|
||||
|
||||
kill.assert_not_called()
|
||||
|
||||
def test_interrupt_during_startup_terminates_the_child_process(self) -> None:
|
||||
class FakePopen:
|
||||
pid = 12345
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = Settings(base=Path(temporary_directory), terminate_timeout_s=0)
|
||||
manager = FirecrackerProcessManager(settings, popen=lambda *_args, **_kwargs: FakePopen())
|
||||
manager._wait_for_socket = lambda *_args: (_ for _ in ()).throw(KeyboardInterrupt)
|
||||
vm = make_vm(pid=None, start_time=None)
|
||||
vm.log = str(Path(temporary_directory) / "firecracker.log")
|
||||
vm.socket = str(Path(temporary_directory) / "firecracker.sock")
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
manager.start(vm)
|
||||
|
||||
self.assertEqual(kill.call_count, 2)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.cli import build_parser, main
|
||||
from uvm.config import Settings
|
||||
from uvm.errors import ConfigurationError, UvmError
|
||||
from uvm.server import create_api, run_server
|
||||
|
||||
|
||||
def fake_application(*, api_token: str | None = None):
|
||||
settings = SimpleNamespace(
|
||||
app_name="uvm",
|
||||
default_vcpu=1,
|
||||
default_ram_mib=512,
|
||||
default_ssh_user="root",
|
||||
api_token=api_token,
|
||||
api_tls_cert=None,
|
||||
api_tls_key=None,
|
||||
)
|
||||
return SimpleNamespace(settings=settings)
|
||||
|
||||
|
||||
class ServerCliTests(unittest.TestCase):
|
||||
def test_parser_accepts_the_requested_serve_invocation(self) -> None:
|
||||
parser = build_parser(fake_application())
|
||||
|
||||
args = parser.parse_args(["--serve", "--port", "8123", "--host", "127.0.0.1"])
|
||||
|
||||
self.assertTrue(args.serve)
|
||||
self.assertEqual(args.port, 8123)
|
||||
self.assertEqual(args.host, "127.0.0.1")
|
||||
self.assertIsNone(args.command)
|
||||
|
||||
def test_parser_rejects_an_invalid_server_port(self) -> None:
|
||||
parser = build_parser(fake_application())
|
||||
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--serve", "--port", "70000"])
|
||||
|
||||
def test_create_parser_defaults_guest_credentials_to_root(self) -> None:
|
||||
args = build_parser(fake_application()).parse_args(["create"])
|
||||
|
||||
self.assertEqual(args.username, "root")
|
||||
self.assertEqual(args.password, "root")
|
||||
|
||||
def test_main_delegates_serve_mode_to_the_server_launcher(self) -> None:
|
||||
application = fake_application()
|
||||
with (
|
||||
patch("uvm.cli.build_application", return_value=application),
|
||||
patch("uvm.server.run_server") as run_server_mock,
|
||||
):
|
||||
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
run_server_mock.assert_called_once_with(application, host="127.0.0.1", port=8123)
|
||||
|
||||
def test_server_requires_an_api_token_even_on_loopback(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(fake_application(api_token=None), host="127.0.0.1", port=8000)
|
||||
|
||||
def test_non_loopback_server_requires_tls(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(fake_application(api_token="test-token"), host="0.0.0.0", port=8000)
|
||||
|
||||
def test_factory_requires_an_explicit_host(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
create_api(fake_application(api_token="test-token"))
|
||||
|
||||
def test_invalid_tls_material_is_rejected_before_uvicorn_starts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
certificate = Path(temporary_directory) / "cert.pem"
|
||||
key = Path(temporary_directory) / "key.pem"
|
||||
certificate.touch()
|
||||
key.touch()
|
||||
application = fake_application(api_token="test-token")
|
||||
application.settings.api_tls_cert = certificate
|
||||
application.settings.api_tls_key = key
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(application, host="127.0.0.1", port=8000)
|
||||
|
||||
def test_non_loopback_server_uses_configured_tls(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
certificate = Path(temporary_directory) / "cert.pem"
|
||||
key = Path(temporary_directory) / "key.pem"
|
||||
certificate.touch()
|
||||
key.touch()
|
||||
application = fake_application(api_token="test-token")
|
||||
application.settings.api_tls_cert = certificate
|
||||
application.settings.api_tls_key = key
|
||||
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
|
||||
|
||||
with (
|
||||
patch("uvm.server._validate_server_settings"),
|
||||
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)
|
||||
|
||||
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", ""):
|
||||
with self.subTest(token=token):
|
||||
with self.assertRaises(ConfigurationError):
|
||||
Settings(api_token=token)
|
||||
|
||||
|
||||
FASTAPI_AVAILABLE = (
|
||||
importlib.util.find_spec("fastapi") is not None
|
||||
and importlib.util.find_spec("httpx") is not None
|
||||
)
|
||||
|
||||
if FASTAPI_AVAILABLE:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.lifecycle import ListedVm
|
||||
from uvm.server import create_api
|
||||
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec = None
|
||||
self.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",
|
||||
status="running",
|
||||
)
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
return [ListedVm(vm=self.vm, observed_status=self.vm.status)]
|
||||
|
||||
def create(self, spec):
|
||||
self.last_spec = spec
|
||||
self.vm.username = spec.username
|
||||
self.vm.password = spec.password
|
||||
self.vm.status = "running"
|
||||
return self.vm
|
||||
|
||||
def stop(self, _vm_id: str):
|
||||
self.vm.status = "stopped"
|
||||
return self.vm
|
||||
|
||||
def destroy(self, _vm_id: str):
|
||||
return self.vm
|
||||
|
||||
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed")
|
||||
class ApiRouterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
application = fake_application(api_token="test-token")
|
||||
self.lifecycle = FakeLifecycle()
|
||||
application.lifecycle = self.lifecycle
|
||||
application.installer = SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
)
|
||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||
self.client = TestClient(create_api(application, host="127.0.0.1"))
|
||||
|
||||
def test_health_is_available_without_credentials(self) -> None:
|
||||
response = self.client.get("/health")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
|
||||
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
||||
unauthorized = self.client.get("/vms")
|
||||
authorized = self.client.get("/vms", headers={"X-UVM-Token": "test-token"})
|
||||
|
||||
self.assertEqual(unauthorized.status_code, 401)
|
||||
self.assertEqual(authorized.status_code, 200)
|
||||
self.assertEqual(authorized.json()[0]["id"], "vm-test")
|
||||
self.assertNotIn("password", authorized.json()[0])
|
||||
|
||||
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||
headers = {"X-UVM-Token": "test-token"}
|
||||
created = self.client.post(
|
||||
"/vms",
|
||||
headers=headers,
|
||||
json={
|
||||
"cpu": 1,
|
||||
"ram": "512",
|
||||
"username": "root",
|
||||
"password": "api-secret",
|
||||
},
|
||||
)
|
||||
detail = self.client.get("/vms/vm-test", headers=headers)
|
||||
stopped = self.client.post("/vms/vm-test/stop", headers=headers)
|
||||
destroyed = self.client.delete("/vms/vm-test", headers=headers)
|
||||
invalid = self.client.post("/vms", headers=headers, json={"forse": True})
|
||||
|
||||
self.assertEqual(created.status_code, 201)
|
||||
self.assertEqual(created.json()["username"], "root")
|
||||
self.assertNotIn("password", created.json())
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(stopped.json()["status"], "stopped")
|
||||
self.assertEqual(destroyed.json()["status"], "terminated")
|
||||
self.assertEqual(invalid.status_code, 422)
|
||||
|
||||
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
|
||||
response = self.client.post(
|
||||
"/vms",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"cpu": 1, "ram": "512"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self.lifecycle.last_spec.username, "root")
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
||||
|
||||
def test_install_route_uses_the_existing_installer(self) -> None:
|
||||
response = self.client.post(
|
||||
"/install",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"force": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.state import StateStore
|
||||
|
||||
|
||||
def make_vm(vm_id: str = "vm-test") -> VmRecord:
|
||||
return VmRecord(
|
||||
id=vm_id,
|
||||
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",
|
||||
disk="/tmp/rootfs.ext4",
|
||||
username="admin",
|
||||
password="stored-secret",
|
||||
)
|
||||
|
||||
|
||||
class StateStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.base = Path(self._temporary_directory.name) / "uvm"
|
||||
self.settings = replace(Settings(), base=self.base)
|
||||
self.store = StateStore(self.settings)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def test_transaction_persists_a_vm_atomically(self) -> None:
|
||||
self.store.initialize()
|
||||
with self.store.transaction() as state:
|
||||
state.vms["vm-test"] = make_vm()
|
||||
state.next_mac_index = 2
|
||||
|
||||
loaded = self.store.load()
|
||||
self.assertEqual(loaded.vms["vm-test"].disk, "/tmp/rootfs.ext4")
|
||||
self.assertEqual(loaded.vms["vm-test"].username, "admin")
|
||||
self.assertEqual(loaded.vms["vm-test"].password, "stored-secret")
|
||||
self.assertNotIn("stored-secret", repr(loaded.vms["vm-test"]))
|
||||
self.assertEqual(loaded.next_mac_index, 2)
|
||||
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
||||
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["username"], "admin")
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["password"], "stored-secret")
|
||||
|
||||
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
legacy = {"vms": {"vm-test": make_vm().to_dict()}}
|
||||
legacy["vms"]["vm-test"].pop("disk")
|
||||
legacy["vms"]["vm-test"].pop("username")
|
||||
legacy["vms"]["vm-test"].pop("password")
|
||||
legacy["vms"]["vm-test"].pop("updated_at")
|
||||
self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
|
||||
loaded = self.store.load()
|
||||
self.assertEqual(loaded.vms["vm-test"].disk, "")
|
||||
self.assertEqual(loaded.vms["vm-test"].username, "root")
|
||||
self.assertIsNone(loaded.vms["vm-test"].password)
|
||||
self.assertEqual(loaded.next_mac_index, 2)
|
||||
|
||||
with self.store.transaction() as state:
|
||||
state.vms["vm-test"].status = "stopped"
|
||||
|
||||
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["schema_version"], 1)
|
||||
self.assertEqual(persisted["next_mac_index"], 2)
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
||||
|
||||
def test_initialize_protects_registry_credentials(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
self.settings.state_path.write_text('{"vms": {}}', encoding="utf-8")
|
||||
self.settings.state_path.chmod(0o644)
|
||||
|
||||
self.store.initialize()
|
||||
|
||||
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.errors import CommandError
|
||||
from uvm.system import CommandRunner
|
||||
|
||||
|
||||
class CommandRunnerTests(unittest.TestCase):
|
||||
def test_passes_sensitive_input_over_stdin_without_logging_it(self) -> None:
|
||||
emitted: list[str] = []
|
||||
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="")
|
||||
|
||||
with patch("uvm.system.subprocess.run", return_value=completed) as run:
|
||||
result = CommandRunner(emit=emitted.append).run(
|
||||
("credential-tool", "--stdin"),
|
||||
capture=True,
|
||||
input_text="secret-value\n",
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.stdout, "result\n")
|
||||
self.assertEqual(run.call_args.kwargs["input"], "secret-value\n")
|
||||
self.assertNotIn("secret-value", emitted[0])
|
||||
|
||||
def test_redacts_sensitive_command_output_from_errors(self) -> None:
|
||||
completed = SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="failure mentioning secret-value",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("uvm.system.subprocess.run", return_value=completed),
|
||||
self.assertRaises(CommandError) as raised,
|
||||
):
|
||||
CommandRunner(emit=None).run(
|
||||
("credential-tool", "--stdin"),
|
||||
capture=True,
|
||||
input_text="secret-value\n",
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
self.assertNotIn("secret-value", str(raised.exception))
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from uvm.errors import ValidationError
|
||||
from uvm.validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def test_parse_ram_uses_mib_by_default(self) -> None:
|
||||
self.assertEqual(parse_ram("512"), 512)
|
||||
self.assertEqual(parse_ram("1G"), 1024)
|
||||
self.assertEqual(parse_ram("512MiB"), 512)
|
||||
self.assertEqual(parse_ram("134217728B"), 128)
|
||||
|
||||
def test_parse_ram_rejects_small_and_invalid_values(self) -> None:
|
||||
for value in ("127M", "nonsense", "-1G", "1T"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_ram(value)
|
||||
|
||||
def test_parse_cpu_requires_a_positive_finite_value(self) -> None:
|
||||
self.assertEqual(parse_cpu("0.5"), 0.5)
|
||||
for value in ("0", "-1", "nan", "inf", "cpu"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_cpu(value)
|
||||
|
||||
def test_guest_username_is_validated(self) -> None:
|
||||
self.assertEqual(parse_username("root"), "root")
|
||||
self.assertEqual(parse_username("app-user"), "app-user")
|
||||
for value in ("", "Root", "9user", "user name", "a" * 33):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_username(value)
|
||||
|
||||
def test_guest_password_rejects_empty_control_or_oversized_values(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_password("correct horse battery staple"),
|
||||
"correct horse battery staple",
|
||||
)
|
||||
for value in ("", "line\nbreak", "tab\tvalue", "a" * 129):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_password(value)
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility launcher for the modular uvm command-line application."""
|
||||
|
||||
from uvm.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Internal implementation package for the uvm command-line application."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Run the uvm command-line application with ``python -m uvm``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Pydantic request and response models for the UVM HTTP API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
from .domain import VmRecord
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
class InstallRequest(BaseModel):
|
||||
force: bool = False
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
|
||||
|
||||
class InstallResponse(BaseModel):
|
||||
firecracker: str
|
||||
kernel: str
|
||||
rootfs: str
|
||||
|
||||
|
||||
class VmCreateRequest(BaseModel):
|
||||
cpu: float = Field(default=1, gt=0)
|
||||
ram: str | int | float = "512"
|
||||
guest_ip: IPv4Address | None = None
|
||||
username: str = "root"
|
||||
password: SecretStr = SecretStr("root")
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
|
||||
|
||||
class VmResponse(BaseModel):
|
||||
id: str
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
mac: str
|
||||
username: str
|
||||
status: str
|
||||
observed_status: str
|
||||
pid: int | None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
last_error: str | None
|
||||
|
||||
|
||||
class DestroyResponse(BaseModel):
|
||||
id: str
|
||||
status: str = "terminated"
|
||||
|
||||
|
||||
def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse:
|
||||
return VmResponse(
|
||||
id=vm.id,
|
||||
cpu=vm.cpu,
|
||||
ram_mib=vm.ram_mib,
|
||||
guest_ip=vm.guest_ip,
|
||||
gateway=vm.gateway,
|
||||
mac=vm.mac,
|
||||
username=vm.username,
|
||||
status=vm.status,
|
||||
observed_status=observed_status or vm.status,
|
||||
pid=vm.pid,
|
||||
created_at=vm.created_at,
|
||||
updated_at=vm.updated_at,
|
||||
last_error=vm.last_error,
|
||||
)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Application composition root for the uvm command-line executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import Settings
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .images import ImageStore
|
||||
from .install import Installer
|
||||
from .lifecycle import LifecycleService
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Application:
|
||||
"""Concrete host services used by the CLI; this is not a public SDK."""
|
||||
|
||||
settings: Settings
|
||||
state_store: StateStore
|
||||
installer: Installer
|
||||
lifecycle: LifecycleService
|
||||
|
||||
|
||||
def build_application(emit: Callable[[str], None] | None = print) -> Application:
|
||||
settings = Settings.from_environment()
|
||||
runner = CommandRunner(emit=emit)
|
||||
state_store = StateStore(settings)
|
||||
images = ImageStore(settings, runner)
|
||||
network = NetworkManager(settings, runner)
|
||||
process = FirecrackerProcessManager(settings)
|
||||
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,
|
||||
)
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""The sole public interface of this project: the local uvm CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from .app import Application, build_application
|
||||
from .domain import VmSpec
|
||||
from .errors import UvmError, ValidationError
|
||||
from .firecracker.config import vcpu_count
|
||||
from .validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
|
||||
|
||||
def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=application.settings.app_name,
|
||||
description="Tiny Firecracker microVM CLI for Ubuntu.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--serve",
|
||||
action="store_true",
|
||||
help="launch the FastAPI management server instead of running a CLI command",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="server bind address used with --serve; defaults to 127.0.0.1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=_port_number,
|
||||
default=8000,
|
||||
help="server bind port used with --serve; defaults to 8000",
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
|
||||
install = subcommands.add_parser(
|
||||
"install",
|
||||
description=(
|
||||
"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",
|
||||
)
|
||||
install.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
||||
)
|
||||
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM")
|
||||
create.add_argument(
|
||||
"--cpu",
|
||||
default=str(application.settings.default_vcpu),
|
||||
help="CPU capacity, e.g. 1, 2, 0.5 (fractional CPU is advisory)",
|
||||
)
|
||||
create.add_argument(
|
||||
"--ram",
|
||||
default=str(application.settings.default_ram_mib),
|
||||
help="RAM, e.g. 512, 1G, 512M",
|
||||
)
|
||||
create.add_argument(
|
||||
"--host-ip",
|
||||
dest="host_ip",
|
||||
help="guest IP to assign; otherwise uvm allocates one",
|
||||
)
|
||||
create.add_argument(
|
||||
"--username",
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
)
|
||||
create.add_argument(
|
||||
"--password",
|
||||
default="root",
|
||||
help="guest login password; defaults to root",
|
||||
)
|
||||
|
||||
subcommands.add_parser("list", help="list VMs")
|
||||
|
||||
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
|
||||
ssh.add_argument("vm", help="VM ID or guest IP")
|
||||
ssh.add_argument("--user", help="override the username stored for the VM")
|
||||
ssh.add_argument("--key")
|
||||
ssh.add_argument(
|
||||
"--insecure-host-key",
|
||||
action="store_true",
|
||||
help="disable SSH host-key verification for this connection",
|
||||
)
|
||||
|
||||
stop = subcommands.add_parser("stop", help="stop a VM")
|
||||
stop.add_argument("vm")
|
||||
|
||||
destroy = subcommands.add_parser("destroy", help="stop and remove a VM")
|
||||
destroy.add_argument("vm")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
try:
|
||||
application = build_application()
|
||||
parser = build_parser(application)
|
||||
args = parser.parse_args(argv)
|
||||
if args.serve:
|
||||
if args.command is not None:
|
||||
parser.error("--serve cannot be combined with a CLI command")
|
||||
return _run_server(application, args)
|
||||
if args.command is None:
|
||||
parser.error("a command is required unless --serve is used")
|
||||
return _run_command(application, args)
|
||||
except UvmError as error:
|
||||
print(f"uvm: error: {error}", file=sys.stderr)
|
||||
return error.exit_code
|
||||
except KeyboardInterrupt:
|
||||
print("uvm: interrupted", file=sys.stderr)
|
||||
return 130
|
||||
|
||||
|
||||
def _run_server(application: Application, args: argparse.Namespace) -> int:
|
||||
from .server import run_server
|
||||
|
||||
run_server(application, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
if args.command == "install":
|
||||
firecracker, assets = application.installer.install(force_assets=args.force)
|
||||
application.state_store.initialize()
|
||||
print()
|
||||
print("uvm installed.")
|
||||
print(f" Firecracker: {firecracker}")
|
||||
print(f" Kernel: {assets.kernel}")
|
||||
print(f" Rootfs: {assets.rootfs}")
|
||||
print()
|
||||
print("Next:")
|
||||
if application.settings.allow_unverified_downloads:
|
||||
print(" Keep UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 for later create commands.")
|
||||
print(" Run your UVM command with: create --cpu 1 --ram 512")
|
||||
return 0
|
||||
|
||||
if args.command == "create":
|
||||
guest_ip = _parse_guest_ip(args.host_ip)
|
||||
vm = application.lifecycle.create(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(args.cpu),
|
||||
ram_mib=parse_ram(args.ram),
|
||||
guest_ip=guest_ip,
|
||||
username=parse_username(args.username),
|
||||
password=parse_password(args.password),
|
||||
)
|
||||
)
|
||||
if vm.cpu < 1.0:
|
||||
print(
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU;"
|
||||
)
|
||||
print(" fractional CPU enforcement is not yet applied by this MVP.")
|
||||
print()
|
||||
print(f"VM created: {vm.id}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" CPU: {vm.cpu}")
|
||||
print(f" TAP: {vm.tap}")
|
||||
print(f" Username: {vm.username}")
|
||||
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
|
||||
print()
|
||||
if vm.password == "root":
|
||||
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
vms = application.lifecycle.list_vms()
|
||||
if not vms:
|
||||
print("No VMs.")
|
||||
return 0
|
||||
print(
|
||||
f"{'ID':<37} {'IP':<16} {'USER':<16} {'CPU':<7} "
|
||||
f"{'RAM':<8} {'STATUS':<10} PID"
|
||||
)
|
||||
for listed in vms:
|
||||
vm = listed.vm
|
||||
pid = vm.pid if vm.pid is not None else ""
|
||||
print(
|
||||
f"{vm.id:<37} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
|
||||
f"{vm.ram_mib:<8} {listed.observed_status:<10} {pid}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "ssh":
|
||||
vm = application.lifecycle.find_for_ssh(args.vm)
|
||||
command = ["ssh"]
|
||||
if args.key:
|
||||
command.extend(("-i", args.key))
|
||||
if args.insecure_host_key:
|
||||
command.extend(
|
||||
(
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
)
|
||||
)
|
||||
else:
|
||||
command.extend(
|
||||
(
|
||||
"-o",
|
||||
f"HostKeyAlias=uvm-{vm.id}",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
)
|
||||
)
|
||||
command.append(f"{args.user or vm.username}@{vm.guest_ip}")
|
||||
os.execvp(command[0], command)
|
||||
return 0
|
||||
|
||||
if args.command == "stop":
|
||||
vm = application.lifecycle.stop(args.vm)
|
||||
print(f"Stopped {vm.id}")
|
||||
return 0
|
||||
|
||||
if args.command == "destroy":
|
||||
vm = application.lifecycle.destroy(args.vm)
|
||||
print(f"Destroyed {vm.id}")
|
||||
return 0
|
||||
|
||||
raise UvmError(f"unsupported command: {args.command}")
|
||||
|
||||
|
||||
def _parse_guest_ip(value: str | None) -> IPv4Address | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return IPv4Address(value)
|
||||
except ValueError as error:
|
||||
raise ValidationError(f"invalid guest IP: {value}") from error
|
||||
|
||||
|
||||
def _port_number(value: str) -> int:
|
||||
try:
|
||||
port = int(value)
|
||||
except ValueError as error:
|
||||
raise argparse.ArgumentTypeError("port must be an integer") from error
|
||||
if not 1 <= port <= 65535:
|
||||
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
|
||||
return port
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"""Configuration and filesystem layout for the uvm command-line application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ConfigurationError
|
||||
|
||||
|
||||
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)
|
||||
class Settings:
|
||||
"""All host-specific values consumed by the application."""
|
||||
|
||||
app_name: str = "uvm"
|
||||
base: Path = Path("/var/lib/uvm")
|
||||
network: IPv4Network = IPv4Network("10.42.0.0/24")
|
||||
gateway: IPv4Address = IPv4Address("10.42.0.1")
|
||||
bridge: str = "uvm0"
|
||||
firecracker_version: str = "v1.16.1"
|
||||
default_ram_mib: int = 512
|
||||
default_vcpu: float = 1
|
||||
default_ssh_user: str = "root"
|
||||
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
|
||||
rootfs_sha256: str | None = None
|
||||
allow_unverified_downloads: bool = False
|
||||
api_token: str | None = None
|
||||
api_tls_cert: Path | None = None
|
||||
api_tls_key: Path | None = None
|
||||
api_timeout_s: float = 2.0
|
||||
api_socket_timeout_s: float = 5.0
|
||||
terminate_timeout_s: float = 5.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.gateway not in self.network:
|
||||
raise ConfigurationError(
|
||||
f"gateway {self.gateway} is outside configured network {self.network}"
|
||||
)
|
||||
if self.gateway in (self.network.network_address, self.network.broadcast_address):
|
||||
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.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(
|
||||
"UVM_API_TOKEN must contain only visible ASCII characters without whitespace"
|
||||
)
|
||||
|
||||
@property
|
||||
def bin_dir(self) -> Path:
|
||||
return self.base / "bin"
|
||||
|
||||
@property
|
||||
def images_dir(self) -> Path:
|
||||
return self.base / "images"
|
||||
|
||||
@property
|
||||
def vms_dir(self) -> Path:
|
||||
return self.base / "vms"
|
||||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
return self.base / "state.json"
|
||||
|
||||
@property
|
||||
def state_lock_path(self) -> Path:
|
||||
return self.base / "state.lock"
|
||||
|
||||
@property
|
||||
def operation_lock_path(self) -> Path:
|
||||
return self.base / "operations.lock"
|
||||
|
||||
@property
|
||||
def firecracker_binary(self) -> Path:
|
||||
return self.bin_dir / "firecracker"
|
||||
|
||||
@property
|
||||
def jailer_binary(self) -> Path:
|
||||
return self.bin_dir / "jailer"
|
||||
|
||||
@property
|
||||
def kernel_image(self) -> Path:
|
||||
return self.images_dir / "vmlinux"
|
||||
|
||||
@property
|
||||
def rootfs_image(self) -> Path:
|
||||
return self.images_dir / "ubuntu.ext4"
|
||||
|
||||
@property
|
||||
def integrity_manifest_path(self) -> Path:
|
||||
return self.base / "integrity.json"
|
||||
|
||||
def vm_dir(self, vm_id: str) -> Path:
|
||||
return self.vms_dir / vm_id
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "Settings":
|
||||
"""Load optional deployment overrides while retaining script defaults."""
|
||||
|
||||
try:
|
||||
network = IPv4Network(os.environ.get("UVM_NETWORK", "10.42.0.0/24"))
|
||||
gateway = IPv4Address(os.environ.get("UVM_GATEWAY", "10.42.0.1"))
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"invalid network configuration: {error}") from error
|
||||
|
||||
return cls(
|
||||
base=Path(os.environ.get("UVM_BASE", "/var/lib/uvm")).expanduser(),
|
||||
network=network,
|
||||
gateway=gateway,
|
||||
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
|
||||
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"
|
||||
),
|
||||
kernel_sha256=_optional_environment_value("UVM_KERNEL_SHA256"),
|
||||
rootfs_sha256=_optional_environment_value("UVM_ROOTFS_SHA256"),
|
||||
allow_unverified_downloads=_boolean_environment_value(
|
||||
"UVM_ALLOW_UNVERIFIED_DOWNLOADS", default=False
|
||||
),
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
def _optional_environment_value(name: str) -> str | None:
|
||||
value = os.environ.get(name)
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _optional_environment_path(name: str) -> Path | None:
|
||||
value = _optional_environment_value(name)
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _boolean_environment_value(name: str, *, default: bool) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise ConfigurationError(f"{name} must be one of true/false, yes/no, or 1/0")
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
"""Internal data structures persisted by the uvm CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any
|
||||
|
||||
from .errors import StateError
|
||||
|
||||
|
||||
STATE_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VmSpec:
|
||||
"""Validated resource request supplied to the create command."""
|
||||
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: IPv4Address | None = None
|
||||
username: str = "root"
|
||||
password: str = field(default="root", repr=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VmRecord:
|
||||
"""Persistent record for one local Firecracker VM."""
|
||||
|
||||
id: str
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
tap: str
|
||||
mac: str
|
||||
socket: str
|
||||
config: str
|
||||
log: str
|
||||
disk: str = ""
|
||||
username: str = "root"
|
||||
password: str | None = field(default=None, repr=False)
|
||||
status: str = "starting"
|
||||
pid: int | None = None
|
||||
process_start_time: str | None = None
|
||||
created_at: int = field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = field(default_factory=lambda: int(time.time()))
|
||||
last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"cpu": self.cpu,
|
||||
"ram_mib": self.ram_mib,
|
||||
"guest_ip": self.guest_ip,
|
||||
"gateway": self.gateway,
|
||||
"tap": self.tap,
|
||||
"mac": self.mac,
|
||||
"socket": self.socket,
|
||||
"config": self.config,
|
||||
"log": self.log,
|
||||
"disk": self.disk,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
if self.pid is not None:
|
||||
data["pid"] = self.pid
|
||||
if self.process_start_time is not None:
|
||||
data["process_start_time"] = self.process_start_time
|
||||
if self.last_error is not None:
|
||||
data["last_error"] = self.last_error
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> "VmRecord":
|
||||
if not isinstance(value, dict):
|
||||
raise StateError("VM record is not an object")
|
||||
|
||||
required = (
|
||||
"id",
|
||||
"cpu",
|
||||
"ram_mib",
|
||||
"guest_ip",
|
||||
"gateway",
|
||||
"tap",
|
||||
"mac",
|
||||
"socket",
|
||||
"config",
|
||||
"log",
|
||||
)
|
||||
missing = [name for name in required if name not in value]
|
||||
if missing:
|
||||
raise StateError(f"VM record is missing fields: {', '.join(missing)}")
|
||||
|
||||
try:
|
||||
pid_value = value.get("pid")
|
||||
return cls(
|
||||
id=str(value["id"]),
|
||||
cpu=float(value["cpu"]),
|
||||
ram_mib=int(value["ram_mib"]),
|
||||
guest_ip=str(value["guest_ip"]),
|
||||
gateway=str(value["gateway"]),
|
||||
tap=str(value["tap"]),
|
||||
mac=str(value["mac"]),
|
||||
socket=str(value["socket"]),
|
||||
config=str(value["config"]),
|
||||
log=str(value["log"]),
|
||||
disk=str(value.get("disk", "")),
|
||||
username=str(value.get("username", "root")),
|
||||
password=(
|
||||
str(value["password"])
|
||||
if value.get("password") is not None
|
||||
else None
|
||||
),
|
||||
status=str(value.get("status", "unknown")),
|
||||
pid=int(pid_value) if pid_value is not None else None,
|
||||
process_start_time=(
|
||||
str(value["process_start_time"])
|
||||
if value.get("process_start_time") is not None
|
||||
else None
|
||||
),
|
||||
created_at=int(value.get("created_at", int(time.time()))),
|
||||
updated_at=int(value.get("updated_at", value.get("created_at", int(time.time())))),
|
||||
last_error=(
|
||||
str(value["last_error"])
|
||||
if value.get("last_error") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError(f"invalid VM record: {error}") from error
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class State:
|
||||
"""Versioned JSON state document stored under the configured base path."""
|
||||
|
||||
vms: dict[str, VmRecord] = field(default_factory=dict)
|
||||
next_mac_index: int = 1
|
||||
schema_version: int = STATE_SCHEMA_VERSION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"next_mac_index": self.next_mac_index,
|
||||
"vms": {vm_id: vm.to_dict() for vm_id, vm in self.vms.items()},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> "State":
|
||||
if not isinstance(value, dict):
|
||||
raise StateError("state file is not an object")
|
||||
|
||||
try:
|
||||
schema_version = int(value.get("schema_version", 0))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError("state file has an invalid schema_version") from error
|
||||
if schema_version > STATE_SCHEMA_VERSION:
|
||||
raise StateError(
|
||||
f"state schema version {schema_version} is newer than this uvm version"
|
||||
)
|
||||
|
||||
raw_vms = value.get("vms", {})
|
||||
if not isinstance(raw_vms, dict):
|
||||
raise StateError("state file has an invalid vms collection")
|
||||
|
||||
vms = {str(vm_id): VmRecord.from_dict(vm) for vm_id, vm in raw_vms.items()}
|
||||
next_mac_index = value.get("next_mac_index")
|
||||
if next_mac_index is None:
|
||||
next_mac_index = _next_mac_index(vms.values())
|
||||
|
||||
try:
|
||||
next_mac_index = max(1, int(next_mac_index))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError("state file has an invalid next_mac_index") from error
|
||||
|
||||
return cls(
|
||||
schema_version=STATE_SCHEMA_VERSION,
|
||||
vms=vms,
|
||||
next_mac_index=next_mac_index,
|
||||
)
|
||||
|
||||
|
||||
def new_vm_id() -> str:
|
||||
"""Generate an opaque local ID that does not depend on PID or timestamp reuse."""
|
||||
|
||||
return f"vm-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _next_mac_index(vms: object) -> int:
|
||||
maximum = 0
|
||||
for vm in vms:
|
||||
if not isinstance(vm, VmRecord):
|
||||
continue
|
||||
try:
|
||||
parts = vm.mac.split(":")
|
||||
if parts[:3] != ["02", "fc", "00"] or len(parts) != 6:
|
||||
continue
|
||||
maximum = max(maximum, int("".join(parts[3:]), 16))
|
||||
except ValueError:
|
||||
continue
|
||||
return maximum + 1
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Application-specific failures with predictable command-line handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class UvmError(Exception):
|
||||
"""An expected error that should be rendered without a traceback."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class ConfigurationError(UvmError):
|
||||
"""Raised when local uvm configuration is invalid."""
|
||||
|
||||
|
||||
class ValidationError(UvmError):
|
||||
"""Raised when a command argument is invalid."""
|
||||
|
||||
|
||||
class StateError(UvmError):
|
||||
"""Raised when persisted VM state cannot be safely used."""
|
||||
|
||||
|
||||
class CommandError(UvmError):
|
||||
"""Raised when a required host command cannot be executed successfully."""
|
||||
|
||||
|
||||
class FirecrackerError(UvmError):
|
||||
"""Raised when Firecracker cannot be started or configured."""
|
||||
|
||||
|
||||
class TapCreationError(UvmError):
|
||||
"""Raised when TAP setup fails after creating a host interface."""
|
||||
|
||||
def __init__(self, message: str, *, tap_created: bool) -> None:
|
||||
super().__init__(message)
|
||||
self.tap_created = tap_created
|
||||
@@ -0,0 +1 @@
|
||||
"""Internal Firecracker configuration, API, and process adapters."""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Typed Firecracker HTTP requests over a per-VM Unix-domain socket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..errors import FirecrackerError
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: Path, timeout: float) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self._socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.sock.connect(str(self._socket_path))
|
||||
|
||||
|
||||
class FirecrackerClient:
|
||||
"""Keep Firecracker endpoint details out of lifecycle orchestration."""
|
||||
|
||||
def __init__(self, socket_path: Path, timeout_s: float) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._timeout_s = timeout_s
|
||||
|
||||
def configure_and_start(self, config: Mapping[str, Any]) -> None:
|
||||
machine = config["machine-config"]
|
||||
boot = config["boot-source"]
|
||||
drives = config["drives"]
|
||||
interfaces = config["network-interfaces"]
|
||||
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")
|
||||
|
||||
self._request(
|
||||
"PUT",
|
||||
"/machine-config",
|
||||
{
|
||||
"vcpu_count": machine["vcpu_count"],
|
||||
"mem_size_mib": machine["mem_size_mib"],
|
||||
"smt": False,
|
||||
},
|
||||
)
|
||||
self._request("PUT", "/boot-source", boot)
|
||||
self._request("PUT", "/drives/rootfs", drives[0])
|
||||
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
|
||||
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
|
||||
|
||||
def _request(self, method: str, path: str, body: object) -> None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
connection = _UnixHTTPConnection(self._socket_path, self._timeout_s)
|
||||
try:
|
||||
connection.request(
|
||||
method,
|
||||
path,
|
||||
payload,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read().decode("utf-8", errors="replace")
|
||||
except (OSError, http.client.HTTPException) as error:
|
||||
raise FirecrackerError(f"Firecracker API {method} {path}: {error}") from error
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
if response.status >= 300:
|
||||
raise FirecrackerError(
|
||||
f"Firecracker API {method} {path}: {response.status} {response_body}"
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Build and persist Firecracker configuration for one VM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import Settings
|
||||
from ..domain import VmRecord
|
||||
|
||||
|
||||
def vcpu_count(cpu: float) -> int:
|
||||
"""Firecracker accepts whole vCPUs; fractional capacity rounds up for now."""
|
||||
|
||||
return max(1, math.ceil(cpu))
|
||||
|
||||
|
||||
def boot_ip_argument(
|
||||
guest_ip: IPv4Address,
|
||||
gateway: IPv4Address,
|
||||
network: IPv4Network,
|
||||
) -> str:
|
||||
return f"ip={guest_ip}::{gateway}:{network.netmask}::eth0:off"
|
||||
|
||||
|
||||
def build_config(
|
||||
settings: Settings,
|
||||
vm: VmRecord,
|
||||
kernel: Path,
|
||||
rootfs: Path,
|
||||
) -> dict[str, Any]:
|
||||
boot_args = " ".join(
|
||||
(
|
||||
"console=ttyS0",
|
||||
"reboot=k",
|
||||
"panic=1",
|
||||
"pci=off",
|
||||
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"boot-source": {
|
||||
"kernel_image_path": str(kernel),
|
||||
"boot_args": boot_args,
|
||||
},
|
||||
"drives": [
|
||||
{
|
||||
"drive_id": "rootfs",
|
||||
"path_on_host": str(rootfs),
|
||||
"is_root_device": True,
|
||||
"is_read_only": False,
|
||||
}
|
||||
],
|
||||
"machine-config": {
|
||||
"vcpu_count": vcpu_count(vm.cpu),
|
||||
"mem_size_mib": vm.ram_mib,
|
||||
},
|
||||
"network-interfaces": [
|
||||
{
|
||||
"iface_id": "eth0",
|
||||
"guest_mac": vm.mac,
|
||||
"host_dev_name": vm.tap,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_config(path: Path, config: Mapping[str, Any]) -> None:
|
||||
"""Atomically publish a rendered Firecracker configuration file."""
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(config, temporary_file, indent=2)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_name, path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Firecracker process lifecycle and PID identity handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from ..config import Settings
|
||||
from ..domain import VmRecord
|
||||
from ..errors import FirecrackerError
|
||||
|
||||
|
||||
class _PopenLike(Protocol):
|
||||
pid: int
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessInfo:
|
||||
pid: int
|
||||
start_time: str
|
||||
|
||||
|
||||
class FirecrackerProcessManager:
|
||||
"""Start, observe, and terminate detached Firecracker processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
popen: Callable[..., _PopenLike] = subprocess.Popen,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._popen = popen
|
||||
self._sleep = sleep
|
||||
self._monotonic = monotonic
|
||||
|
||||
def start(self, vm: VmRecord) -> ProcessInfo:
|
||||
socket_path = Path(vm.socket)
|
||||
if socket_path.exists():
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except OSError as error:
|
||||
raise FirecrackerError(
|
||||
f"could not remove stale Firecracker socket {socket_path}: {error}"
|
||||
) from error
|
||||
|
||||
log_path = Path(vm.log)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
process: _PopenLike | None = None
|
||||
try:
|
||||
with log_path.open("ab", buffering=0) as log_file:
|
||||
process = self._popen(
|
||||
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
self._wait_for_socket(process, socket_path, log_path)
|
||||
start_time = self.process_start_time(process.pid)
|
||||
if start_time is None:
|
||||
raise FirecrackerError(
|
||||
f"could not establish a safe process identity for Firecracker process {process.pid}"
|
||||
)
|
||||
return ProcessInfo(pid=process.pid, start_time=start_time)
|
||||
except OSError as error:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
raise FirecrackerError(f"could not start Firecracker: {error}") from error
|
||||
except BaseException:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
raise
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(vm.pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
return self.process_start_time(vm.pid) == vm.process_start_time
|
||||
|
||||
def terminate(self, vm: VmRecord) -> None:
|
||||
if vm.pid is None:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
if vm.pid <= 0:
|
||||
raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}")
|
||||
if vm.process_start_time is None:
|
||||
if not self._pid_exists(vm.pid):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
raise FirecrackerError(
|
||||
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
|
||||
)
|
||||
if not self.is_alive(vm):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(vm.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
except OSError as error:
|
||||
raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error
|
||||
|
||||
deadline = self._monotonic() + self._settings.terminate_timeout_s
|
||||
while self.is_alive(vm) and self._monotonic() < deadline:
|
||||
self._sleep(0.05)
|
||||
if self.is_alive(vm):
|
||||
self._terminate_process_id(vm.pid, force=True)
|
||||
self._remove_socket(Path(vm.socket))
|
||||
|
||||
def process_start_time(self, pid: int) -> str | None:
|
||||
"""Read Linux proc start time so a reused PID is not mistaken for a VM."""
|
||||
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
closing_parenthesis = stat.rfind(")")
|
||||
if closing_parenthesis < 0:
|
||||
return None
|
||||
fields = stat[closing_parenthesis + 2 :].split()
|
||||
try:
|
||||
return fields[19]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
def _wait_for_socket(
|
||||
self,
|
||||
process: _PopenLike,
|
||||
socket_path: Path,
|
||||
log_path: Path,
|
||||
) -> None:
|
||||
deadline = self._monotonic() + self._settings.api_socket_timeout_s
|
||||
while self._monotonic() < deadline:
|
||||
if socket_path.exists():
|
||||
return
|
||||
if process.poll() is not None:
|
||||
raise FirecrackerError(f"Firecracker exited early; see {log_path}")
|
||||
self._sleep(0.05)
|
||||
raise FirecrackerError("timed out waiting for Firecracker API socket")
|
||||
|
||||
@staticmethod
|
||||
def _remove_socket(socket_path: Path) -> None:
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
# A stale socket is cleaned on the next launch; never hide a successful stop.
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _pid_exists(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
def _cleanup_started_process(self, process: _PopenLike, socket_path: Path) -> None:
|
||||
try:
|
||||
self._terminate_process_id(process.pid)
|
||||
except FirecrackerError:
|
||||
pass
|
||||
deadline = self._monotonic() + self._settings.terminate_timeout_s
|
||||
while process.poll() is None and self._monotonic() < deadline:
|
||||
self._sleep(0.05)
|
||||
if process.poll() is None:
|
||||
try:
|
||||
self._terminate_process_id(process.pid, force=True)
|
||||
except FirecrackerError:
|
||||
pass
|
||||
self._remove_socket(socket_path)
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process_id(pid: int, *, force: bool = False) -> None:
|
||||
if pid <= 0:
|
||||
raise FirecrackerError(f"invalid Firecracker PID: {pid}")
|
||||
signal_to_send = signal.SIGKILL if force else signal.SIGTERM
|
||||
try:
|
||||
os.kill(pid, signal_to_send)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError as error:
|
||||
action = "force-stop" if force else "stop"
|
||||
raise FirecrackerError(f"could not {action} Firecracker process {pid}: {error}") from error
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
"""Guest asset lookup and per-VM writable root filesystem handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY = (
|
||||
"ssh-rsa "
|
||||
"AAAAB3NzaC1yc2EAAAADAQABAAABAQCirWKrc1zDyvZufHGinIRNoeIot+C3idANxtqZyDHL9mYm"
|
||||
"NeQzx9CjbjMgSDJ3xhIPP9mu3MP4Py/u3X5Wey98zN3EPboKdGRf6T2fFviK4i0q85LueDtsK0"
|
||||
"IoWR459w87tC9NMwPb27C8jPqFod6nWfccdhEdM+veKkFh4Dk5TrPfYHDayXDPFEdz7jl0GedEH"
|
||||
"fP9w11LPfa66D7731CdD3tMHAWLYxYmeXo58RXUaP6AgUK8uF/hL+E21q+wgTNPOQuRn2ekOjdu"
|
||||
"J34oHkJ2i48tLqKdGKU6RwfFc3rW3TkeYTUqi3UY9EnwNWFJicez+nZ5bhr5KvRsfSZ2QvBj"
|
||||
)
|
||||
_MODE_PATTERN = re.compile(r"Mode:\s+0*([0-7]+)")
|
||||
_OWNER_PATTERN = re.compile(r"User:\s+(\d+)\s+Group:\s+(\d+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuestAssets:
|
||||
kernel: Path
|
||||
rootfs: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GuestFile:
|
||||
path: Path
|
||||
mode: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
class ImageStore:
|
||||
"""Treat downloaded guest assets as templates, never as writable VM disks."""
|
||||
|
||||
def __init__(self, settings: Settings, runner: CommandRunner | None = None) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner or CommandRunner()
|
||||
|
||||
def installed_assets(self) -> GuestAssets:
|
||||
kernel = self._settings.kernel_image
|
||||
rootfs = self._settings.rootfs_image
|
||||
if not kernel.exists() or not rootfs.exists():
|
||||
raise UvmError("guest assets missing. Run: sudo uvm install")
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
kernel_checksum = self._settings.kernel_sha256
|
||||
rootfs_checksum = self._settings.rootfs_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
kernel_checksum = kernel_checksum or manifest.checksums.get("kernel")
|
||||
rootfs_checksum = rootfs_checksum or manifest.checksums.get("rootfs")
|
||||
verify_file(
|
||||
kernel,
|
||||
kernel_checksum,
|
||||
"guest kernel",
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
verify_file(
|
||||
rootfs,
|
||||
rootfs_checksum,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||
|
||||
def create_vm_disk(self, source: Path, destination: Path) -> Path:
|
||||
"""Copy the template before Firecracker opens it read-write for a VM."""
|
||||
|
||||
if destination.exists():
|
||||
raise UvmError(f"VM disk already exists: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
destination.chmod(0o600)
|
||||
except OSError as error:
|
||||
try:
|
||||
destination.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
||||
return destination
|
||||
|
||||
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
|
||||
password_hash = self._password_hash(password)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
|
||||
temporary = Path(temporary_name)
|
||||
passwd = self._read_guest_file(disk, "/etc/passwd", temporary / "passwd")
|
||||
shadow = self._read_guest_file(disk, "/etc/shadow", temporary / "shadow")
|
||||
sshd_config = self._read_guest_file(
|
||||
disk,
|
||||
"/etc/ssh/sshd_config",
|
||||
temporary / "sshd_config",
|
||||
)
|
||||
authorized_keys = self._read_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
temporary / "authorized_keys",
|
||||
required=False,
|
||||
)
|
||||
assert passwd is not None
|
||||
assert shadow is not None
|
||||
assert sshd_config is not None
|
||||
|
||||
account_names = {
|
||||
line.split(":", 1)[0]
|
||||
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
}
|
||||
if username not in account_names:
|
||||
raise UvmError(f"guest user does not exist in rootfs: {username}")
|
||||
|
||||
shadow.path.write_text(
|
||||
_set_shadow_password(
|
||||
shadow.path.read_text(encoding="utf-8"),
|
||||
username,
|
||||
password_hash,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
shadow.path.chmod(shadow.mode)
|
||||
|
||||
sshd_config.path.write_text(
|
||||
_enable_ssh_password_authentication(
|
||||
sshd_config.path.read_text(encoding="utf-8"),
|
||||
username,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sshd_config.path.chmod(sshd_config.mode)
|
||||
|
||||
self._write_guest_file(disk, "/etc/shadow", shadow)
|
||||
self._write_guest_file(disk, "/etc/ssh/sshd_config", sshd_config)
|
||||
if authorized_keys is not None:
|
||||
self._remove_firecracker_demo_key(disk, authorized_keys)
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise UvmError(f"could not provision guest credentials: {error}") from error
|
||||
|
||||
def _password_hash(self, password: str) -> str:
|
||||
result = self._runner.run(
|
||||
("openssl", "passwd", "-6", "-stdin"),
|
||||
capture=True,
|
||||
input_text=f"{password}\n",
|
||||
sensitive=True,
|
||||
)
|
||||
password_hash = result.stdout.strip()
|
||||
if not password_hash.startswith("$6$") or any(
|
||||
character in password_hash for character in ("\n", "\r", ":")
|
||||
):
|
||||
raise UvmError("openssl returned an invalid guest password hash")
|
||||
return password_hash
|
||||
|
||||
def _read_guest_file(
|
||||
self,
|
||||
disk: Path,
|
||||
guest_path: str,
|
||||
destination: Path,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> _GuestFile | None:
|
||||
stat_result = self._runner.run(
|
||||
("debugfs", "-R", f"stat {guest_path}", disk),
|
||||
check=False,
|
||||
capture=True,
|
||||
)
|
||||
mode_match = _MODE_PATTERN.search(stat_result.stdout)
|
||||
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
|
||||
if mode_match is None or owner_match is None:
|
||||
if required:
|
||||
raise UvmError(f"guest rootfs is missing required file: {guest_path}")
|
||||
return None
|
||||
|
||||
self._runner.run(
|
||||
("debugfs", "-R", f"dump {guest_path} {destination}", disk),
|
||||
capture=True,
|
||||
)
|
||||
if not destination.is_file():
|
||||
raise UvmError(f"could not read guest rootfs file: {guest_path}")
|
||||
return _GuestFile(
|
||||
path=destination,
|
||||
mode=int(mode_match.group(1), 8),
|
||||
uid=int(owner_match.group(1)),
|
||||
gid=int(owner_match.group(2)),
|
||||
)
|
||||
|
||||
def _write_guest_file(self, disk: Path, guest_path: str, source: _GuestFile) -> None:
|
||||
self._runner.run(("debugfs", "-w", "-R", f"rm {guest_path}", disk), capture=True)
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", f"write {source.path} {guest_path}", disk),
|
||||
capture=True,
|
||||
)
|
||||
for field, value in (
|
||||
("mode", f"0{0o100000 | source.mode:o}"),
|
||||
("uid", str(source.uid)),
|
||||
("gid", str(source.gid)),
|
||||
):
|
||||
self._runner.run(
|
||||
(
|
||||
"debugfs",
|
||||
"-w",
|
||||
"-R",
|
||||
f"set_inode_field {guest_path} {field} {value}",
|
||||
disk,
|
||||
),
|
||||
capture=True,
|
||||
)
|
||||
|
||||
verification = source.path.with_name(f"{source.path.name}.verify")
|
||||
written = self._read_guest_file(disk, guest_path, verification)
|
||||
assert written is not None
|
||||
if (
|
||||
verification.read_bytes() != source.path.read_bytes()
|
||||
or written.mode != source.mode
|
||||
or written.uid != source.uid
|
||||
or written.gid != source.gid
|
||||
):
|
||||
raise UvmError(f"could not verify updated guest rootfs file: {guest_path}")
|
||||
|
||||
def _remove_firecracker_demo_key(self, disk: Path, authorized_keys: _GuestFile) -> None:
|
||||
contents = authorized_keys.path.read_text(encoding="utf-8")
|
||||
updated = _without_firecracker_demo_key(contents)
|
||||
if updated == contents:
|
||||
return
|
||||
if updated is not None:
|
||||
authorized_keys.path.write_text(updated, encoding="utf-8")
|
||||
authorized_keys.path.chmod(authorized_keys.mode)
|
||||
self._write_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
authorized_keys,
|
||||
)
|
||||
return
|
||||
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", "rm /root/.ssh/authorized_keys", disk),
|
||||
capture=True,
|
||||
)
|
||||
if self._read_guest_file(
|
||||
disk,
|
||||
"/root/.ssh/authorized_keys",
|
||||
authorized_keys.path.with_name("authorized_keys.verify"),
|
||||
required=False,
|
||||
) is not None:
|
||||
raise UvmError("could not remove the insecure Firecracker demo SSH key")
|
||||
|
||||
|
||||
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
|
||||
lines = contents.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
fields = line.split(":")
|
||||
if fields[0] == username and len(fields) >= 2:
|
||||
fields[1] = password_hash
|
||||
lines[index] = ":".join(fields)
|
||||
return "\n".join(lines) + "\n"
|
||||
raise UvmError(f"guest rootfs has no shadow entry for user: {username}")
|
||||
|
||||
|
||||
def _enable_ssh_password_authentication(contents: str, username: str) -> str:
|
||||
directives = ["PasswordAuthentication yes"]
|
||||
if username == "root":
|
||||
directives.append("PermitRootLogin yes")
|
||||
return "# Managed by uvm\n" + "\n".join(directives) + "\n" + contents
|
||||
|
||||
|
||||
def _without_firecracker_demo_key(contents: str) -> str | None:
|
||||
lines = contents.splitlines()
|
||||
retained = [
|
||||
line
|
||||
for line in lines
|
||||
if " ".join(line.split()[:2]) != _FIRECRACKER_DEMO_PUBLIC_KEY
|
||||
]
|
||||
if len(retained) == len(lines):
|
||||
return contents
|
||||
return "\n".join(retained) + "\n" if retained else None
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
"""Installation of host prerequisites, Firecracker, and guest assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
|
||||
from .errors import UvmError
|
||||
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
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Install exactly the local host dependencies used by the uvm CLI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
runner: CommandRunner,
|
||||
state_store: StateStore,
|
||||
*,
|
||||
emit: Callable[[str], None] | None = print,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner
|
||||
self._state_store = state_store
|
||||
self._emit = emit
|
||||
|
||||
def install(self, *, force_assets: bool = False) -> tuple[Path, GuestAssets]:
|
||||
require_root()
|
||||
ensure_data_directories(self._settings)
|
||||
with self._state_store.operation_lock():
|
||||
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(
|
||||
"WARNING: downloads are not checksum verified. Set UVM_*_SHA256 values "
|
||||
"and UVM_ALLOW_UNVERIFIED_DOWNLOADS=0 to enforce verification."
|
||||
)
|
||||
self.install_apt_packages()
|
||||
check_kvm()
|
||||
firecracker = self.install_firecracker_binary(force=force_assets)
|
||||
assets = self.install_guest_assets(force=force_assets)
|
||||
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,
|
||||
"Firecracker archive",
|
||||
"UVM_FIRECRACKER_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
require_checksum(
|
||||
self._settings.kernel_sha256,
|
||||
"guest kernel",
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
require_checksum(
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
|
||||
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
|
||||
write_manifest(
|
||||
self._settings.integrity_manifest_path,
|
||||
{
|
||||
"firecracker": sha256_file(firecracker, "Firecracker binary"),
|
||||
"jailer": sha256_file(self._settings.jailer_binary, "Jailer binary"),
|
||||
"kernel": sha256_file(assets.kernel, "guest kernel"),
|
||||
"rootfs": sha256_file(assets.rootfs, "guest root filesystem"),
|
||||
},
|
||||
verified=all(
|
||||
(
|
||||
self._settings.firecracker_sha256,
|
||||
self._settings.kernel_sha256,
|
||||
self._settings.rootfs_sha256,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def install_apt_packages(self) -> None:
|
||||
packages = (
|
||||
"curl",
|
||||
"jq",
|
||||
"iproute2",
|
||||
"iptables",
|
||||
"e2fsprogs",
|
||||
"util-linux",
|
||||
"ca-certificates",
|
||||
"openssl",
|
||||
"openssh-client",
|
||||
)
|
||||
self._runner.run(("apt-get", "update"))
|
||||
self._runner.run(("apt-get", "install", "-y", *packages))
|
||||
|
||||
def install_firecracker_binary(self, *, force: bool = False) -> Path:
|
||||
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
|
||||
if (
|
||||
firecracker.exists()
|
||||
and jailer.exists()
|
||||
and self._settings.allow_unverified_downloads
|
||||
and not force
|
||||
):
|
||||
return firecracker
|
||||
|
||||
archive = self._settings.base / (
|
||||
f"firecracker-{self._settings.firecracker_version}-{architecture}.tgz"
|
||||
)
|
||||
url = (
|
||||
"https://github.com/firecracker-microvm/firecracker/releases/download/"
|
||||
f"{self._settings.firecracker_version}/"
|
||||
f"firecracker-{self._settings.firecracker_version}-{architecture}.tgz"
|
||||
)
|
||||
if force or not archive.exists():
|
||||
self.download(
|
||||
url,
|
||||
archive,
|
||||
expected_sha256=self._settings.firecracker_sha256,
|
||||
label="Firecracker archive",
|
||||
environment_name="UVM_FIRECRACKER_SHA256",
|
||||
)
|
||||
verify_file(
|
||||
archive,
|
||||
self._settings.firecracker_sha256,
|
||||
"Firecracker archive",
|
||||
"UVM_FIRECRACKER_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
|
||||
try:
|
||||
with tarfile.open(archive, "r:gz") as tar:
|
||||
self._extract_archive_safely(tar, self._settings.base)
|
||||
except (OSError, tarfile.TarError) as error:
|
||||
raise UvmError(f"could not extract Firecracker archive {archive}: {error}") from error
|
||||
|
||||
release_dir = self._settings.base / (
|
||||
f"release-{self._settings.firecracker_version}-{architecture}"
|
||||
)
|
||||
source_firecracker = self._find_release_binary(release_dir, "firecracker-")
|
||||
source_jailer = self._find_release_binary(release_dir, "jailer-")
|
||||
if source_firecracker is None or source_jailer is None:
|
||||
raise UvmError("Firecracker archive layout was not recognized")
|
||||
|
||||
try:
|
||||
self._settings.bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_firecracker, firecracker)
|
||||
firecracker.chmod(0o755)
|
||||
shutil.copy2(source_jailer, jailer)
|
||||
jailer.chmod(0o755)
|
||||
except OSError as error:
|
||||
raise UvmError(f"could not install Firecracker binaries: {error}") from error
|
||||
return firecracker
|
||||
|
||||
def install_guest_assets(self, *, force: bool = False) -> GuestAssets:
|
||||
kernel = self._settings.kernel_image
|
||||
rootfs = self._settings.rootfs_image
|
||||
if force or not kernel.exists():
|
||||
self.download(
|
||||
self._settings.kernel_url,
|
||||
kernel,
|
||||
expected_sha256=self._settings.kernel_sha256,
|
||||
label="guest kernel",
|
||||
environment_name="UVM_KERNEL_SHA256",
|
||||
)
|
||||
if force or not rootfs.exists():
|
||||
self.download(
|
||||
self._settings.rootfs_url,
|
||||
rootfs,
|
||||
expected_sha256=self._settings.rootfs_sha256,
|
||||
label="guest root filesystem",
|
||||
environment_name="UVM_ROOTFS_SHA256",
|
||||
)
|
||||
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,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return GuestAssets(kernel=kernel, rootfs=rootfs)
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
expected_sha256: str | None,
|
||||
label: str,
|
||||
environment_name: str,
|
||||
) -> None:
|
||||
"""Download to a sibling temporary file before atomically publishing it."""
|
||||
|
||||
if self._emit is not None:
|
||||
self._emit(f"Downloading {url}")
|
||||
require_checksum(
|
||||
expected_sha256,
|
||||
label,
|
||||
environment_name,
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=destination.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as temporary_file:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
shutil.copyfileobj(response, temporary_file)
|
||||
except (OSError, urllib.error.URLError) as error:
|
||||
raise UvmError(f"could not download {url}: {error}") from error
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
verify_file(
|
||||
Path(temporary_name),
|
||||
expected_sha256,
|
||||
label,
|
||||
environment_name,
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
os.replace(temporary_name, destination)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _extract_archive_safely(tar: tarfile.TarFile, destination: Path) -> None:
|
||||
root = destination.resolve()
|
||||
for member in tar.getmembers():
|
||||
target = (destination / member.name).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
raise UvmError("Firecracker archive contains an unsafe path")
|
||||
tar.extractall(destination, filter="data")
|
||||
|
||||
@staticmethod
|
||||
def _find_release_binary(release_dir: Path, prefix: str) -> Path | None:
|
||||
if not release_dir.exists():
|
||||
return None
|
||||
candidates = sorted(
|
||||
path
|
||||
for path in release_dir.rglob(f"{prefix}*")
|
||||
if path.is_file() and path.name.startswith(prefix)
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Checksum policy for downloaded executable and guest-image artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from .errors import UvmError
|
||||
|
||||
|
||||
_SHA256_PATTERN = re.compile(r"[0-9a-fA-F]{64}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrityManifest:
|
||||
checksums: dict[str, str]
|
||||
verified: bool
|
||||
|
||||
|
||||
def normalize_sha256(value: str, environment_name: str) -> str:
|
||||
digest = value.strip().lower()
|
||||
if not _SHA256_PATTERN.fullmatch(digest):
|
||||
raise UvmError(f"{environment_name} must contain a 64-character SHA-256 digest")
|
||||
return digest
|
||||
|
||||
|
||||
def require_checksum(
|
||||
expected: str | None,
|
||||
label: str,
|
||||
environment_name: str,
|
||||
*,
|
||||
allow_unverified: bool,
|
||||
) -> str | None:
|
||||
if expected is not None:
|
||||
return normalize_sha256(expected, environment_name)
|
||||
if allow_unverified:
|
||||
return None
|
||||
raise UvmError(
|
||||
f"{label} checksum is required. Set {environment_name} to a trusted SHA-256 "
|
||||
"or explicitly set UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 for local development."
|
||||
)
|
||||
|
||||
|
||||
def verify_file(
|
||||
path: Path,
|
||||
expected: str | None,
|
||||
label: str,
|
||||
environment_name: str,
|
||||
*,
|
||||
allow_unverified: bool,
|
||||
) -> None:
|
||||
expected_digest = require_checksum(
|
||||
expected,
|
||||
label,
|
||||
environment_name,
|
||||
allow_unverified=allow_unverified,
|
||||
)
|
||||
if expected_digest is None:
|
||||
return
|
||||
|
||||
actual_digest = sha256_file(path, label)
|
||||
if not hmac.compare_digest(actual_digest, expected_digest):
|
||||
raise UvmError(
|
||||
f"SHA-256 mismatch for {label}: expected {expected_digest}, got {actual_digest}"
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path, label: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as artifact:
|
||||
for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as error:
|
||||
raise UvmError(f"could not checksum {label} at {path}: {error}") from error
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> IntegrityManifest:
|
||||
if not path.exists():
|
||||
return IntegrityManifest(checksums={}, verified=False)
|
||||
try:
|
||||
with path.open(encoding="utf-8") as manifest_file:
|
||||
raw = json.load(manifest_file)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise UvmError(f"cannot read integrity manifest {path}: {error}") from error
|
||||
if not isinstance(raw, dict):
|
||||
raise UvmError(f"integrity manifest {path} is not an object")
|
||||
|
||||
if "checksums" in raw:
|
||||
raw_checksums = raw["checksums"]
|
||||
verified = raw.get("verified")
|
||||
if not isinstance(raw_checksums, dict) or not isinstance(verified, bool):
|
||||
raise UvmError(f"integrity manifest {path} has an invalid format")
|
||||
else:
|
||||
# Flat manifests were written by earlier uvm versions without provenance.
|
||||
raw_checksums = raw
|
||||
verified = False
|
||||
|
||||
manifest: dict[str, str] = {}
|
||||
for key, value in raw_checksums.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise UvmError(f"integrity manifest {path} contains an invalid entry")
|
||||
manifest[key] = normalize_sha256(value, f"integrity manifest entry {key}")
|
||||
return IntegrityManifest(checksums=manifest, verified=verified)
|
||||
|
||||
|
||||
def write_manifest(path: Path, values: Mapping[str, str], *, verified: bool) -> None:
|
||||
normalized = {
|
||||
key: normalize_sha256(value, f"integrity manifest entry {key}")
|
||||
for key, value in values.items()
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(
|
||||
{"checksums": normalized, "verified": verified},
|
||||
temporary_file,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_name, path)
|
||||
os.chmod(path, 0o644)
|
||||
except OSError as error:
|
||||
raise UvmError(f"cannot write integrity manifest {path}: {error}") from error
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Ordered VM lifecycle operations and compensation for partial failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord, VmSpec, new_vm_id
|
||||
from .errors import TapCreationError, UvmError
|
||||
from .firecracker.api import FirecrackerClient
|
||||
from .firecracker.config import build_config, write_config
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .images import ImageStore
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
from .system import check_kvm, ensure_data_directories, require_root
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ListedVm:
|
||||
vm: VmRecord
|
||||
observed_status: str
|
||||
|
||||
|
||||
class LifecycleService:
|
||||
"""The only module allowed to coordinate multiple VM host resources."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
state_store: StateStore,
|
||||
images: ImageStore,
|
||||
network: NetworkManager,
|
||||
process: FirecrackerProcessManager,
|
||||
*,
|
||||
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._state_store = state_store
|
||||
self._images = images
|
||||
self._network = network
|
||||
self._process = process
|
||||
self._client_factory = client_factory
|
||||
self._clock = clock
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
"""Reserve identity first, then create all VM resources in dependency order."""
|
||||
|
||||
require_root()
|
||||
ensure_data_directories(self._settings)
|
||||
check_kvm()
|
||||
if not self._settings.firecracker_binary.exists():
|
||||
raise UvmError("Firecracker is not installed. Run: sudo uvm install")
|
||||
with self._state_store.operation_lock():
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
firecracker_checksum = self._settings.firecracker_binary_sha256
|
||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||
firecracker_checksum = firecracker_checksum or manifest.checksums.get("firecracker")
|
||||
verify_file(
|
||||
self._settings.firecracker_binary,
|
||||
firecracker_checksum,
|
||||
"Firecracker binary",
|
||||
"UVM_FIRECRACKER_BINARY_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
return self._create_locked(spec)
|
||||
|
||||
def _create_locked(self, spec: VmSpec) -> VmRecord:
|
||||
assets = self._images.installed_assets()
|
||||
vm = self._reserve_vm(spec)
|
||||
|
||||
tap_created = False
|
||||
try:
|
||||
runtime_dir = self._settings.vm_dir(vm.id)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=False)
|
||||
runtime_dir.chmod(0o700)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
self._network.ensure_bridge()
|
||||
try:
|
||||
self._network.create_tap(vm.tap)
|
||||
except TapCreationError as error:
|
||||
tap_created = error.tap_created
|
||||
raise
|
||||
else:
|
||||
tap_created = True
|
||||
|
||||
config = build_config(self._settings, vm, assets.kernel, disk)
|
||||
write_config(Path(vm.config), config)
|
||||
|
||||
process_info = self._process.start(vm)
|
||||
vm.pid = process_info.pid
|
||||
vm.process_start_time = process_info.start_time
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
|
||||
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
|
||||
client.configure_and_start(config)
|
||||
except BaseException as error:
|
||||
cleanup_errors = self._rollback_create(vm, tap_created)
|
||||
message = f"failed to create {vm.id}: {error}"
|
||||
if cleanup_errors:
|
||||
message = f"{message}; cleanup failed: {'; '.join(cleanup_errors)}"
|
||||
vm.status = "failed"
|
||||
vm.last_error = message
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
else:
|
||||
self._remove_vm_if_present(vm.id)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(message) from error
|
||||
|
||||
vm.status = "running"
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
state = self._state_store.load()
|
||||
listed: list[ListedVm] = []
|
||||
for vm in state.vms.values():
|
||||
observed_status = vm.status
|
||||
if vm.status in {"starting", "running", "stopping", "terminating"}:
|
||||
if not self._process.is_alive(vm):
|
||||
observed_status = "dead"
|
||||
listed.append(ListedVm(vm=vm, observed_status=observed_status))
|
||||
return listed
|
||||
|
||||
def find_for_ssh(self, identifier: str) -> VmRecord:
|
||||
return self._state_store.find_by_id_or_ip(identifier)
|
||||
|
||||
def stop(self, vm_id: str) -> VmRecord:
|
||||
"""Stop one VM while retaining its private writable disk for future use."""
|
||||
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._stop_locked(vm_id)
|
||||
|
||||
def _stop_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "stopping")
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
self._network.delete_tap(vm.tap)
|
||||
except BaseException as error:
|
||||
vm.status = "failed"
|
||||
vm.last_error = f"failed to stop VM: {error}"
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
vm.status = "stopped"
|
||||
vm.pid = None
|
||||
vm.process_start_time = None
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
|
||||
def destroy(self, vm_id: str) -> VmRecord:
|
||||
"""Stop a VM, remove its private resources, then release its allocation."""
|
||||
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._destroy_locked(vm_id)
|
||||
|
||||
def _destroy_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "terminating")
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
self._network.delete_tap(vm.tap)
|
||||
try:
|
||||
shutil.rmtree(self._settings.vm_dir(vm.id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
vm.status = "failed"
|
||||
vm.last_error = f"failed to destroy VM: {error}"
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(vm.last_error) from error
|
||||
|
||||
self._remove_vm_if_present(vm.id)
|
||||
return vm
|
||||
|
||||
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
vm_id = new_vm_id()
|
||||
used_taps = {existing_vm.tap for existing_vm in state.vms.values()}
|
||||
while vm_id in state.vms or self._network.tap_name(vm_id) in used_taps:
|
||||
vm_id = new_vm_id()
|
||||
guest_ip = self._network.allocate_ip(state.vms.values(), spec.guest_ip)
|
||||
mac = self._network.mac_for(state.next_mac_index)
|
||||
state.next_mac_index += 1
|
||||
now = self._now()
|
||||
runtime_dir = self._settings.vm_dir(vm_id)
|
||||
vm = VmRecord(
|
||||
id=vm_id,
|
||||
cpu=spec.cpu,
|
||||
ram_mib=spec.ram_mib,
|
||||
guest_ip=str(guest_ip),
|
||||
gateway=str(self._settings.gateway),
|
||||
tap=self._network.tap_name(vm_id),
|
||||
mac=mac,
|
||||
socket=str(runtime_dir / "firecracker.sock"),
|
||||
config=str(runtime_dir / "config.json"),
|
||||
log=str(runtime_dir / "firecracker.log"),
|
||||
disk=str(runtime_dir / "rootfs.ext4"),
|
||||
username=spec.username,
|
||||
password=spec.password,
|
||||
status="starting",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
state.vms[vm.id] = vm
|
||||
return vm
|
||||
|
||||
def _begin_transition(self, vm_id: str, target_status: str) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
try:
|
||||
vm = state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
if vm.status in {"stopping", "terminating"}:
|
||||
raise UvmError(f"VM operation is already in progress: {vm_id}")
|
||||
vm.status = target_status
|
||||
vm.updated_at = self._now()
|
||||
state.vms[vm_id] = vm
|
||||
return vm
|
||||
|
||||
def _replace_vm(self, vm: VmRecord) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
if vm.id not in state.vms:
|
||||
raise UvmError(f"VM not found: {vm.id}")
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
def _replace_vm_if_present(self, vm: VmRecord) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
if vm.id in state.vms:
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
def _remove_vm_if_present(self, vm_id: str) -> None:
|
||||
with self._state_store.transaction() as state:
|
||||
state.vms.pop(vm_id, None)
|
||||
|
||||
def _rollback_create(self, vm: VmRecord, tap_created: bool) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if vm.pid is not None:
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
except Exception as error:
|
||||
errors.append(f"process: {error}")
|
||||
if tap_created:
|
||||
try:
|
||||
self._network.delete_tap(vm.tap)
|
||||
except Exception as error:
|
||||
errors.append(f"network: {error}")
|
||||
try:
|
||||
shutil.rmtree(self._settings.vm_dir(vm.id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as error:
|
||||
errors.append(f"files: {error}")
|
||||
return errors
|
||||
|
||||
def _now(self) -> int:
|
||||
return int(self._clock())
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"""Host bridge, TAP, NAT, and address allocation for local microVMs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from .config import Settings
|
||||
from .domain import VmRecord
|
||||
from .errors import TapCreationError, UvmError, ValidationError
|
||||
from .system import CommandRunner
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
"""Manage the shared bridge and one TAP device per persisted VM."""
|
||||
|
||||
def __init__(self, settings: Settings, runner: CommandRunner) -> None:
|
||||
self._settings = settings
|
||||
self._runner = runner
|
||||
|
||||
def allocate_ip(
|
||||
self,
|
||||
vms: Iterable[VmRecord],
|
||||
requested: IPv4Address | None,
|
||||
) -> IPv4Address:
|
||||
used = {vm.guest_ip for vm in vms}
|
||||
if requested is not None:
|
||||
if requested not in self._settings.network or requested in {
|
||||
self._settings.network.network_address,
|
||||
self._settings.network.broadcast_address,
|
||||
self._settings.gateway,
|
||||
}:
|
||||
raise ValidationError(
|
||||
"guest IP must be an unused address inside "
|
||||
f"{self._settings.network}, excluding {self._settings.gateway}"
|
||||
)
|
||||
if str(requested) in used:
|
||||
raise ValidationError(f"IP already allocated: {requested}")
|
||||
return requested
|
||||
|
||||
for host in self._settings.network.hosts():
|
||||
if host == self._settings.gateway:
|
||||
continue
|
||||
if str(host) not in used:
|
||||
return host
|
||||
raise UvmError(f"no free IPs in {self._settings.network}")
|
||||
|
||||
@staticmethod
|
||||
def mac_for(index: int) -> str:
|
||||
if index < 1 or index > 0xFFFFFF:
|
||||
raise UvmError("no free locally administered MAC addresses remain")
|
||||
return (
|
||||
f"02:fc:00:{(index >> 16) & 255:02x}:"
|
||||
f"{(index >> 8) & 255:02x}:{index & 255:02x}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def tap_name(vm_id: str) -> str:
|
||||
suffix = re.sub(r"[^a-zA-Z0-9]", "", vm_id)[-11:]
|
||||
if not suffix:
|
||||
raise UvmError("cannot derive a TAP name from an empty VM ID")
|
||||
return f"uvm-{suffix}"[:15]
|
||||
|
||||
def ensure_bridge(self) -> None:
|
||||
bridge = self._settings.bridge
|
||||
existing = self._runner.run(("ip", "link", "show", bridge), check=False, capture=True)
|
||||
if existing.returncode != 0:
|
||||
self._runner.run(("ip", "link", "add", bridge, "type", "bridge"))
|
||||
else:
|
||||
self._ensure_existing_link_is_bridge(bridge)
|
||||
self._runner.run(
|
||||
(
|
||||
"ip",
|
||||
"addr",
|
||||
"replace",
|
||||
f"{self._settings.gateway}/{self._settings.network.prefixlen}",
|
||||
"dev",
|
||||
bridge,
|
||||
)
|
||||
)
|
||||
self._runner.run(("ip", "link", "set", bridge, "up"))
|
||||
self._runner.run(("sysctl", "-w", "net.ipv4.ip_forward=1"))
|
||||
self._ensure_masquerade()
|
||||
|
||||
def _ensure_existing_link_is_bridge(self, bridge: str) -> None:
|
||||
details = self._runner.run(
|
||||
("ip", "-j", "-d", "link", "show", "dev", bridge), capture=True
|
||||
)
|
||||
try:
|
||||
links = json.loads(details.stdout)
|
||||
kind = links[0]["linkinfo"]["info_kind"]
|
||||
except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error:
|
||||
raise UvmError(f"could not determine whether existing interface {bridge} is a bridge") from error
|
||||
if kind != "bridge":
|
||||
raise UvmError(f"configured bridge {bridge} exists but is not a Linux bridge")
|
||||
|
||||
def create_tap(self, name: str) -> None:
|
||||
existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
||||
if existing.returncode == 0:
|
||||
raise UvmError(f"TAP device already exists: {name}")
|
||||
created = False
|
||||
try:
|
||||
self._runner.run(("ip", "tuntap", "add", "dev", name, "mode", "tap"))
|
||||
created = True
|
||||
self._runner.run(("ip", "link", "set", name, "master", self._settings.bridge))
|
||||
self._runner.run(("ip", "link", "set", name, "up"))
|
||||
except BaseException as error:
|
||||
if created:
|
||||
try:
|
||||
self.delete_tap(name)
|
||||
except UvmError as cleanup_error:
|
||||
raise TapCreationError(
|
||||
f"could not configure TAP device {name}; cleanup also failed: {cleanup_error}",
|
||||
tap_created=True,
|
||||
) from error
|
||||
raise
|
||||
|
||||
def delete_tap(self, name: str) -> None:
|
||||
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
|
||||
if deleted.returncode == 0:
|
||||
return
|
||||
remaining = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
||||
if remaining.returncode != 0:
|
||||
return
|
||||
detail = deleted.stderr.strip() or deleted.stdout.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise UvmError(f"could not delete TAP device {name}{suffix}")
|
||||
|
||||
def _ensure_masquerade(self) -> None:
|
||||
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",
|
||||
)
|
||||
)
|
||||
self._ensure_iptables_rule(
|
||||
(
|
||||
"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,
|
||||
check_rule: tuple[str, ...],
|
||||
add_rule: tuple[str, ...],
|
||||
) -> None:
|
||||
present = self._runner.run(check_rule, check=False)
|
||||
if present.returncode != 0:
|
||||
self._runner.run(add_rule)
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI routers for UVM's local management API."""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared FastAPI dependencies for accessing and protecting UVM services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import cast
|
||||
|
||||
from fastapi import Header, HTTPException, Request, status
|
||||
|
||||
from ..app import Application
|
||||
|
||||
|
||||
def get_application(request: Request) -> Application:
|
||||
return cast(Application, request.app.state.uvm_application)
|
||||
|
||||
|
||||
def get_authorized_application(
|
||||
request: Request,
|
||||
x_uvm_token: str | None = Header(default=None),
|
||||
) -> Application:
|
||||
application = get_application(request)
|
||||
expected_token = application.settings.api_token
|
||||
if not expected_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="UVM_API_TOKEN is not configured",
|
||||
)
|
||||
if not hmac.compare_digest(x_uvm_token or "", expected_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing or invalid X-UVM-Token",
|
||||
)
|
||||
return application
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Unauthenticated health endpoint for local liveness checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..api_models import HealthResponse
|
||||
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Host installation endpoint backed by the existing UVM installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..api_models import InstallRequest, InstallResponse
|
||||
from ..app import Application
|
||||
from .dependencies import get_authorized_application
|
||||
|
||||
|
||||
router = APIRouter(tags=["installation"])
|
||||
|
||||
|
||||
@router.post("/install", response_model=InstallResponse)
|
||||
def install(
|
||||
request: InstallRequest,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> InstallResponse:
|
||||
firecracker, assets = application.installer.install(force_assets=request.force)
|
||||
application.state_store.initialize()
|
||||
return InstallResponse(
|
||||
firecracker=str(firecracker),
|
||||
kernel=str(assets.kernel),
|
||||
rootfs=str(assets.rootfs),
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""VM lifecycle endpoints backed by the existing UVM lifecycle service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
|
||||
from ..app import Application
|
||||
from ..domain import VmSpec
|
||||
from ..validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
from .dependencies import get_authorized_application
|
||||
|
||||
|
||||
router = APIRouter(prefix="/vms", tags=["vms"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[VmResponse])
|
||||
def list_vms(
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> list[VmResponse]:
|
||||
return [
|
||||
vm_response(listed.vm, observed_status=listed.observed_status)
|
||||
for listed in application.lifecycle.list_vms()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=VmResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_vm(
|
||||
request: VmCreateRequest,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
vm = application.lifecycle.create(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(str(request.cpu)),
|
||||
ram_mib=parse_ram(str(request.ram)),
|
||||
guest_ip=request.guest_ip,
|
||||
username=parse_username(request.username),
|
||||
password=parse_password(request.password.get_secret_value()),
|
||||
)
|
||||
)
|
||||
return vm_response(vm)
|
||||
|
||||
|
||||
@router.post("/{vm_id}/stop", response_model=VmResponse)
|
||||
def stop_vm(
|
||||
vm_id: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
return vm_response(application.lifecycle.stop(vm_id))
|
||||
|
||||
|
||||
@router.delete("/{vm_id}", response_model=DestroyResponse)
|
||||
def destroy_vm(
|
||||
vm_id: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> DestroyResponse:
|
||||
vm = application.lifecycle.destroy(vm_id)
|
||||
return DestroyResponse(id=vm.id)
|
||||
|
||||
|
||||
@router.get("/{identifier}", response_model=VmResponse)
|
||||
def get_vm(
|
||||
identifier: str,
|
||||
application: Application = Depends(get_authorized_application),
|
||||
) -> VmResponse:
|
||||
for listed in application.lifecycle.list_vms():
|
||||
vm = listed.vm
|
||||
if identifier in {vm.id, vm.guest_ip}:
|
||||
return vm_response(vm, observed_status=listed.observed_status)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="VM not found")
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
"""FastAPI application factory and Uvicorn server launcher for uvm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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, UvmError, ValidationError
|
||||
|
||||
|
||||
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
|
||||
"""Build the HTTP API after validating its intended bind address."""
|
||||
|
||||
if host is None:
|
||||
raise UvmError("create_api requires an explicit host. Start the API with uvm --serve.")
|
||||
|
||||
resolved_application = application or build_application(emit=None)
|
||||
_validate_server_settings(
|
||||
resolved_application.settings.api_token,
|
||||
resolved_application.settings.api_tls_cert,
|
||||
resolved_application.settings.api_tls_key,
|
||||
host,
|
||||
)
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
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.installation import router as installation_router
|
||||
from .routers.vms import router as vms_router
|
||||
|
||||
api = FastAPI(
|
||||
title="uvm",
|
||||
version="0.1.0",
|
||||
description="Local Firecracker microVM management API.",
|
||||
)
|
||||
api.state.uvm_application = resolved_application
|
||||
|
||||
@api.exception_handler(UvmError)
|
||||
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
|
||||
return JSONResponse(
|
||||
status_code=_http_status_for(error),
|
||||
content={"error": {"message": str(error)}},
|
||||
)
|
||||
|
||||
api.include_router(health_router)
|
||||
api.include_router(installation_router)
|
||||
api.include_router(vms_router)
|
||||
return api
|
||||
|
||||
|
||||
def run_server(application: Application, *, host: str, port: int) -> None:
|
||||
"""Run Uvicorn after enforcing the local-management security boundary."""
|
||||
|
||||
settings = application.settings
|
||||
_validate_server_settings(
|
||||
settings.api_token,
|
||||
settings.api_tls_cert,
|
||||
settings.api_tls_key,
|
||||
host,
|
||||
)
|
||||
try:
|
||||
import uvicorn
|
||||
except ModuleNotFoundError as error:
|
||||
raise UvmError(
|
||||
"Uvicorn server support is not installed. Install this project with its dependencies."
|
||||
) from error
|
||||
|
||||
options: dict[str, Any] = {"host": host, "port": port}
|
||||
if settings.api_tls_cert is not None:
|
||||
options["ssl_certfile"] = str(settings.api_tls_cert)
|
||||
options["ssl_keyfile"] = str(settings.api_tls_key)
|
||||
uvicorn.run(create_api(application, host=host), **options)
|
||||
|
||||
|
||||
def _is_loopback_host(host: str) -> bool:
|
||||
try:
|
||||
return ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_server_settings(
|
||||
api_token: str | None,
|
||||
cert: Path | None,
|
||||
key: Path | None,
|
||||
host: str,
|
||||
) -> None:
|
||||
if not api_token:
|
||||
raise UvmError("UVM_API_TOKEN is required before starting the management API")
|
||||
if (cert is None) != (key is None):
|
||||
raise UvmError("UVM_API_TLS_CERT and UVM_API_TLS_KEY must be configured together")
|
||||
if cert is not None:
|
||||
assert key is not None
|
||||
if not cert.is_file() or not key.is_file():
|
||||
raise UvmError("configured API TLS certificate or key does not exist")
|
||||
try:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||
except (OSError, ssl.SSLError) as error:
|
||||
raise UvmError(f"configured API TLS certificate or key is invalid: {error}") from error
|
||||
if not _is_loopback_host(host) and cert is None:
|
||||
raise UvmError(
|
||||
"refusing to bind the API to a non-loopback host without TLS. "
|
||||
"Set UVM_API_TLS_CERT and UVM_API_TLS_KEY or bind behind a TLS reverse proxy."
|
||||
)
|
||||
|
||||
|
||||
def _http_status_for(error: UvmError) -> int:
|
||||
if isinstance(error, (ConfigurationError, ValidationError)):
|
||||
return 422
|
||||
message = str(error)
|
||||
if message.startswith("VM not found:"):
|
||||
return 404
|
||||
if "operation is already in progress" in message:
|
||||
return 409
|
||||
if "needs root" in message:
|
||||
return 403
|
||||
return 500
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Locked, atomic persistence for the local JSON VM inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import State, VmRecord
|
||||
from .errors import StateError, UvmError
|
||||
from .system import ensure_data_directories
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Own the JSON state file so callers cannot race IP and VM allocation."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
def initialize(self) -> None:
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked():
|
||||
if not self._settings.state_path.exists():
|
||||
self._save_unlocked(State())
|
||||
else:
|
||||
try:
|
||||
os.chmod(self._settings.state_path, 0o600)
|
||||
except OSError as error:
|
||||
raise StateError(
|
||||
f"cannot update permissions on {self._settings.state_path}: {error}"
|
||||
) from error
|
||||
|
||||
def load(self) -> State:
|
||||
# Reads do not create /var/lib/uvm, so `uvm list` remains usable before install.
|
||||
# Atomic replacement makes an unlocked reader see either the old or new full document.
|
||||
return self._load_unlocked()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[State]:
|
||||
"""Load and commit one state mutation while holding an exclusive lock."""
|
||||
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked():
|
||||
state = self._load_unlocked()
|
||||
yield state
|
||||
self._save_unlocked(state)
|
||||
|
||||
@contextmanager
|
||||
def operation_lock(self) -> Iterator[None]:
|
||||
"""Serialize external VM lifecycle work across concurrent CLI processes."""
|
||||
|
||||
ensure_data_directories(self._settings)
|
||||
with self._locked_path(self._settings.operation_lock_path):
|
||||
yield
|
||||
|
||||
def get(self, vm_id: str) -> VmRecord:
|
||||
state = self.load()
|
||||
try:
|
||||
return state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
|
||||
def find_by_id_or_ip(self, identifier: str) -> VmRecord:
|
||||
state = self.load()
|
||||
if identifier in state.vms:
|
||||
return state.vms[identifier]
|
||||
for vm in state.vms.values():
|
||||
if vm.guest_ip == identifier:
|
||||
return vm
|
||||
raise UvmError(f"VM not found: {identifier}")
|
||||
|
||||
@contextmanager
|
||||
def _locked(self) -> Iterator[None]:
|
||||
with self._locked_path(self._settings.state_lock_path):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def _locked_path(self, lock_path: Path) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def _load_unlocked(self) -> State:
|
||||
path = self._settings.state_path
|
||||
if not path.exists():
|
||||
return State()
|
||||
try:
|
||||
with path.open(encoding="utf-8") as state_file:
|
||||
raw = json.load(state_file)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise StateError(f"cannot read {path}: {error}") from error
|
||||
return State.from_dict(raw)
|
||||
|
||||
def _save_unlocked(self, state: State) -> None:
|
||||
path = self._settings.state_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary_path = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
|
||||
json.dump(state.to_dict(), temporary_file, indent=2, sort_keys=True)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
os.chmod(path, 0o600)
|
||||
self._fsync_parent(path)
|
||||
except OSError as error:
|
||||
raise StateError(f"cannot write {path}: {error}") from error
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fsync_parent(path: Path) -> None:
|
||||
directory = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Small, testable wrappers around required host-level operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import CommandError, UvmError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommandResult:
|
||||
args: tuple[str, ...]
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Execute host commands while keeping command construction testable."""
|
||||
|
||||
def __init__(self, emit: Callable[[str], None] | None = print) -> None:
|
||||
self._emit = emit
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[str | Path],
|
||||
*,
|
||||
check: bool = True,
|
||||
capture: bool = False,
|
||||
input_text: str | None = None,
|
||||
sensitive: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> CommandResult:
|
||||
args = tuple(str(part) for part in command)
|
||||
if self._emit is not None:
|
||||
self._emit(f"+ {shlex.join(args)}")
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
check=False,
|
||||
text=True,
|
||||
input=input_text,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.PIPE if capture else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise CommandError(f"required command was not found: {args[0]}") from error
|
||||
except OSError as error:
|
||||
raise CommandError(f"could not run {args[0]}: {error}") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise CommandError(f"command timed out: {shlex.join(args)}") from error
|
||||
|
||||
result = CommandResult(
|
||||
args=args,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = "" if sensitive else result.stderr.strip() or result.stdout.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise CommandError(
|
||||
f"command failed ({result.returncode}): {shlex.join(args)}{suffix}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def require_root() -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise UvmError("this command needs root. Run it with sudo.")
|
||||
|
||||
|
||||
def check_kvm() -> None:
|
||||
kvm = Path("/dev/kvm")
|
||||
if not kvm.exists():
|
||||
raise UvmError("/dev/kvm does not exist. Enable hardware virtualization/KVM first.")
|
||||
if not os.access(kvm, os.R_OK | os.W_OK):
|
||||
raise UvmError("no read/write access to /dev/kvm. Run as root or grant KVM access.")
|
||||
|
||||
|
||||
def ensure_data_directories(settings: Settings) -> None:
|
||||
for path in (settings.base, settings.bin_dir, settings.images_dir, settings.vms_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Validation and unit conversion for command-line resource options."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from .errors import ValidationError
|
||||
|
||||
|
||||
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]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),
|
||||
"k": Decimal(1) / Decimal(1024),
|
||||
"kb": Decimal(1) / Decimal(1024),
|
||||
"ki": Decimal(1) / Decimal(1024),
|
||||
"kib": Decimal(1) / Decimal(1024),
|
||||
"m": Decimal(1),
|
||||
"mb": Decimal(1),
|
||||
"mi": Decimal(1),
|
||||
"mib": Decimal(1),
|
||||
"g": Decimal(1024),
|
||||
"gb": Decimal(1024),
|
||||
"gi": Decimal(1024),
|
||||
"gib": Decimal(1024),
|
||||
}
|
||||
|
||||
|
||||
def parse_ram(value: str) -> int:
|
||||
"""Parse a MiB-default RAM value, including B/K/M/G suffixes."""
|
||||
|
||||
match = _RAM_PATTERN.fullmatch(value)
|
||||
if not match:
|
||||
raise ValidationError(f"invalid RAM value: {value}")
|
||||
|
||||
try:
|
||||
amount = Decimal(match.group(1))
|
||||
except InvalidOperation as error:
|
||||
raise ValidationError(f"invalid RAM value: {value}") from error
|
||||
|
||||
unit = (match.group(2) or "MiB").lower()
|
||||
mib = int(amount * _MIB_FACTORS[unit])
|
||||
if mib < 128:
|
||||
raise ValidationError("RAM must be at least 128 MiB")
|
||||
return mib
|
||||
|
||||
|
||||
def parse_cpu(value: str) -> float:
|
||||
"""Parse a positive finite CPU capacity request."""
|
||||
|
||||
try:
|
||||
cpu = float(value)
|
||||
except ValueError as error:
|
||||
raise ValidationError(f"invalid CPU value: {value}") from error
|
||||
|
||||
if not math.isfinite(cpu) or cpu <= 0:
|
||||
raise ValidationError("CPU must be a finite value greater than 0")
|
||||
return cpu
|
||||
|
||||
|
||||
def parse_username(value: str) -> str:
|
||||
"""Validate a conventional Linux account name."""
|
||||
|
||||
if not _USERNAME_PATTERN.fullmatch(value):
|
||||
raise ValidationError(
|
||||
"username must start with a lowercase letter or underscore and contain "
|
||||
"at most 32 lowercase letters, digits, underscores, or hyphens"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def parse_password(value: str) -> str:
|
||||
"""Validate a guest password without including it in error messages."""
|
||||
|
||||
if not value:
|
||||
raise ValidationError("password must not be empty")
|
||||
if len(value) > 128:
|
||||
raise ValidationError("password must be at most 128 characters")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ValidationError("password must not contain control characters")
|
||||
return value
|
||||
Reference in New Issue
Block a user