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