commit 1022f24c346dd9f45b7551d3c5cb2196ac07f425 Author: its.kstyagi@gmail.com Date: Fri Sep 4 20:56:18 2026 +0000 __init__ diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..ea4720f --- /dev/null +++ b/DEVELOPER.md @@ -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-/ +│ ├── 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-": { + "id": "vm-", + "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//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- + | +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: +``` + +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//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/`, 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd3510b --- /dev/null +++ b/README.md @@ -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='' \ + UVM_KERNEL_SHA256='' \ + UVM_ROOTFS_SHA256='' \ + ./uvm.py install +``` + +Use `--force` to redownload Firecracker and guest assets when replacing an +image or refreshing artifacts: + +```sh +sudo env \ + UVM_FIRECRACKER_SHA256='' \ + UVM_KERNEL_SHA256='' \ + UVM_ROOTFS_SHA256='' \ + ./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 '' + +# 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- +ssh root@10.42.0.10 + +# Stop or remove a VM. +sudo ./uvm.py stop vm- +sudo ./uvm.py destroy vm- +``` + +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- +``` + +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// # 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. diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..ebb7f3d --- /dev/null +++ b/USAGE.md @@ -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='' \ + UVM_KERNEL_SHA256='' \ + UVM_ROOTFS_SHA256='' \ + ./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='' \ + UVM_KERNEL_SHA256='' \ + UVM_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 '' +``` + +`--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//`. +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/ +sudo cat /var/lib/uvm/vms//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- +ssh root@10.42.0.2 +``` + +### Scenario: Use a Different User and Private Key + +```sh +sudo ./uvm.py ssh vm- --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- --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- +``` + +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- +``` + +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: +``` + +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// # 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//firecracker.log +sudo cat /var/lib/uvm/vms//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//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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e9d1c7b --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ec21f4a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the local uvm command-line application.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..debffb7 --- /dev/null +++ b/tests/test_cli.py @@ -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()) diff --git a/tests/test_firecracker_config.py b/tests/test_firecracker_config.py new file mode 100644 index 0000000..6097371 --- /dev/null +++ b/tests/test_firecracker_config.py @@ -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"]) diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..a92dcb6 --- /dev/null +++ b/tests/test_images.py @@ -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") diff --git a/tests/test_integrity.py b/tests/test_integrity.py new file mode 100644 index 0000000..fbe664e --- /dev/null +++ b/tests/test_integrity.py @@ -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) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..95604ba --- /dev/null +++ b/tests/test_lifecycle.py @@ -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, {}) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..0a3450f --- /dev/null +++ b/tests/test_network.py @@ -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, + ) diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..2cb7f7d --- /dev/null +++ b/tests/test_process.py @@ -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) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..047bece --- /dev/null +++ b/tests/test_server.py @@ -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") diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..3e0add2 --- /dev/null +++ b/tests/test_state.py @@ -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) diff --git a/tests/test_system.py b/tests/test_system.py new file mode 100644 index 0000000..d646e0f --- /dev/null +++ b/tests/test_system.py @@ -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)) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..e7bf8c5 --- /dev/null +++ b/tests/test_validation.py @@ -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) diff --git a/uvm.py b/uvm.py new file mode 100755 index 0000000..f8a4507 --- /dev/null +++ b/uvm.py @@ -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()) diff --git a/uvm/__init__.py b/uvm/__init__.py new file mode 100644 index 0000000..eee7406 --- /dev/null +++ b/uvm/__init__.py @@ -0,0 +1 @@ +"""Internal implementation package for the uvm command-line application.""" diff --git a/uvm/__main__.py b/uvm/__main__.py new file mode 100644 index 0000000..d73334c --- /dev/null +++ b/uvm/__main__.py @@ -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()) diff --git a/uvm/api_models.py b/uvm/api_models.py new file mode 100644 index 0000000..caaba09 --- /dev/null +++ b/uvm/api_models.py @@ -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, + ) diff --git a/uvm/app.py b/uvm/app.py new file mode 100644 index 0000000..53c7086 --- /dev/null +++ b/uvm/app.py @@ -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, + ) diff --git a/uvm/cli.py b/uvm/cli.py new file mode 100644 index 0000000..a538019 --- /dev/null +++ b/uvm/cli.py @@ -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 diff --git a/uvm/config.py b/uvm/config.py new file mode 100644 index 0000000..81084b9 --- /dev/null +++ b/uvm/config.py @@ -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") diff --git a/uvm/domain.py b/uvm/domain.py new file mode 100644 index 0000000..a64bf98 --- /dev/null +++ b/uvm/domain.py @@ -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 diff --git a/uvm/errors.py b/uvm/errors.py new file mode 100644 index 0000000..0a2df8e --- /dev/null +++ b/uvm/errors.py @@ -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 diff --git a/uvm/firecracker/__init__.py b/uvm/firecracker/__init__.py new file mode 100644 index 0000000..e59bf69 --- /dev/null +++ b/uvm/firecracker/__init__.py @@ -0,0 +1 @@ +"""Internal Firecracker configuration, API, and process adapters.""" diff --git a/uvm/firecracker/api.py b/uvm/firecracker/api.py new file mode 100644 index 0000000..99c7ac1 --- /dev/null +++ b/uvm/firecracker/api.py @@ -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}" + ) diff --git a/uvm/firecracker/config.py b/uvm/firecracker/config.py new file mode 100644 index 0000000..29170e7 --- /dev/null +++ b/uvm/firecracker/config.py @@ -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 diff --git a/uvm/firecracker/process.py b/uvm/firecracker/process.py new file mode 100644 index 0000000..e877c36 --- /dev/null +++ b/uvm/firecracker/process.py @@ -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 diff --git a/uvm/images.py b/uvm/images.py new file mode 100644 index 0000000..190cd58 --- /dev/null +++ b/uvm/images.py @@ -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 diff --git a/uvm/install.py b/uvm/install.py new file mode 100644 index 0000000..e381208 --- /dev/null +++ b/uvm/install.py @@ -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 diff --git a/uvm/integrity.py b/uvm/integrity.py new file mode 100644 index 0000000..3326c8b --- /dev/null +++ b/uvm/integrity.py @@ -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 diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py new file mode 100644 index 0000000..bcf2444 --- /dev/null +++ b/uvm/lifecycle.py @@ -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()) diff --git a/uvm/network.py b/uvm/network.py new file mode 100644 index 0000000..ea14571 --- /dev/null +++ b/uvm/network.py @@ -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) diff --git a/uvm/routers/__init__.py b/uvm/routers/__init__.py new file mode 100644 index 0000000..ca136eb --- /dev/null +++ b/uvm/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers for UVM's local management API.""" diff --git a/uvm/routers/dependencies.py b/uvm/routers/dependencies.py new file mode 100644 index 0000000..d9488ca --- /dev/null +++ b/uvm/routers/dependencies.py @@ -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 diff --git a/uvm/routers/health.py b/uvm/routers/health.py new file mode 100644 index 0000000..4ed35fa --- /dev/null +++ b/uvm/routers/health.py @@ -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() diff --git a/uvm/routers/installation.py b/uvm/routers/installation.py new file mode 100644 index 0000000..3d37f3c --- /dev/null +++ b/uvm/routers/installation.py @@ -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), + ) diff --git a/uvm/routers/vms.py b/uvm/routers/vms.py new file mode 100644 index 0000000..9d6b510 --- /dev/null +++ b/uvm/routers/vms.py @@ -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") diff --git a/uvm/server.py b/uvm/server.py new file mode 100644 index 0000000..effb3fe --- /dev/null +++ b/uvm/server.py @@ -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 diff --git a/uvm/state.py b/uvm/state.py new file mode 100644 index 0000000..dce4e14 --- /dev/null +++ b/uvm/state.py @@ -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) diff --git a/uvm/system.py b/uvm/system.py new file mode 100644 index 0000000..a585e19 --- /dev/null +++ b/uvm/system.py @@ -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) diff --git a/uvm/validation.py b/uvm/validation.py new file mode 100644 index 0000000..8eb55e5 --- /dev/null +++ b/uvm/validation.py @@ -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