This commit is contained in:
its.kstyagi@gmail.com
2026-09-04 20:56:18 +00:00
commit 1022f24c34
43 changed files with 6160 additions and 0 deletions
+774
View File
@@ -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.
+318
View File
@@ -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.
+774
View File
@@ -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.
+22
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Tests for the local uvm command-line application."""
+160
View File
@@ -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())
+106
View File
@@ -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"])
+99
View File
@@ -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")
+70
View File
@@ -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)
+185
View File
@@ -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, {})
+153
View File
@@ -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,
)
+78
View File
@@ -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)
+241
View File
@@ -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")
+90
View File
@@ -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)
+46
View File
@@ -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))
+45
View File
@@ -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)
Executable
+8
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Internal implementation package for the uvm command-line application."""
+9
View File
@@ -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())
+76
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Internal Firecracker configuration, API, and process adapters."""
+82
View File
@@ -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}"
)
+94
View File
@@ -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
+205
View File
@@ -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
View File
@@ -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
View File
@@ -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
+145
View File
@@ -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
+284
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""FastAPI routers for UVM's local management API."""
+33
View File
@@ -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
+15
View File
@@ -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()
+26
View File
@@ -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),
)
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+91
View File
@@ -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)
+83
View File
@@ -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