Compare commits

..
5 Commits
Author SHA1 Message Date
kstyagi 309f28018e Merge pull request 'Dev' (#2) from dev into main
Reviewed-on: #2
2026-09-21 15:50:24 +00:00
kstyagi c75c7d9baf feat: enhance known_hosts management with deletion cleanup and SSH cache handling 2026-09-21 21:04:41 +05:30
kstyagi 871afcf454 feat: add tests for known hosts management and enhance validation in known_hosts.py 2026-09-21 20:46:34 +05:30
kstyagi 4a46685a4c feat: implement resource resizing for VMs with storage management
- Added KnownHostsManager to handle cleanup of SSH known_hosts entries upon VM deletion.
- Introduced disk_size_bytes method in ImageStore to read logical disk capacity.
- Enhanced LifecycleService to manage VM resource resizing, including CPU, RAM, and storage.
- Updated application configuration to support known_hosts_files for SSH management.
- Modified dashboard to include resource resizing options in the UI.
- Improved error handling and validation for resource inputs in the dashboard.
- Removed obsolete .pyc files from the repository.
2026-09-21 20:37:52 +05:30
kstyagi fb80ce7c8a feat: Add storage management for VM creation and resizing
- Introduced storage parameter in VmCreateRequest and VmResizeRequest models.
- Implemented storage validation to ensure minimum and maximum limits.
- Enhanced CLI to support storage options during VM creation and resizing.
- Updated lifecycle management to handle pending resize operations for VM resources.
- Added methods for resizing VM disks and validating storage capacity.
- Updated API endpoints to accommodate new storage functionality.
- Refactored related validation and configuration logic to support storage management.
2026-09-21 19:16:33 +05:30
35 changed files with 2140 additions and 162 deletions
+5
View File
@@ -0,0 +1,5 @@
.pytest_cache/
.venv/
*.egg-info/
__pycache__/
**.pyc
+110 -16
View File
@@ -12,7 +12,7 @@ general-purpose Python library or a multi-host control plane.
UVM installs host/guest assets, provisions private disks, and manages named UVM installs host/guest assets, provisions private disks, and manages named
VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk
start/stop/restart, deletion, fixed-target bulk operations, inspection, SSH, start/stop/restart, CPU/RAM/storage resizing, deletion, fixed-target bulk operations, inspection, SSH,
logs, doctor, and explicit port edits/sync. The optional API and local dashboard logs, doctor, and explicit port edits/sync. The optional API and local dashboard
expose health, installation, VM lifecycle controls, and managed port-policy expose health, installation, VM lifecycle controls, and managed port-policy
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
@@ -47,9 +47,9 @@ Requirements for real host operations:
- Linux with KVM enabled and `/dev/kvm` accessible - Linux with KVM enabled and `/dev/kvm` accessible
- Linux >= 5.3 with native bridge conntrack and safe pidfd support - Linux >= 5.3 with native bridge conntrack and safe pidfd support
- Root privileges for installation and lifecycle mutations - Root privileges for installation and lifecycle mutations
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, - `iproute2`, `iptables`, `nftables`, `conntrack`, `coreutils`, `e2fsprogs`, `util-linux`,
`openssl`, and OpenSSH client tools `openssl`, and OpenSSH client tools
- An x86_64 host for the default kernel/rootfs URLs - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
Do not run real integration operations on a developer workstation unless the Do not run real integration operations on a developer workstation unless the
bridge, subnet, firewall changes, and guest workload are acceptable there. bridge, subnet, firewall changes, and guest workload are acceptable there.
@@ -77,6 +77,7 @@ Packet integration tests must remain inside disposable isolated namespaces.
│ ├── integrity.py # SHA-256 and integrity manifest logic │ ├── integrity.py # SHA-256 and integrity manifest logic
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation │ ├── install.py # Apt, Firecracker, kernel, rootfs installation
│ ├── images.py # Template lookup, private disks, and guest credentials │ ├── images.py # Template lookup, private disks, and guest credentials
│ ├── known_hosts.py # Deletion-only cleanup of local SSH host-key caches
│ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules │ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup │ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent │ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
@@ -140,7 +141,7 @@ services. It owns confirmation, terminal/JSON output, exit handling, SSH
`execvp`, local log streaming from a validated path, and `--serve` dispatch. `execvp`, local log streaming from a validated path, and `--serve` dispatch.
It must not coordinate state files, interfaces, or Firecracker sockets itself. It must not coordinate state files, interfaces, or Firecracker sockets itself.
The local surface is install/create/list/inspect/start/stop/restart/delete The local surface is install/create/list/inspect/start/stop/restart/resize/delete
(`destroy` alias)/ssh/logs/doctor and ports list/open/close/set/sync. Identifiers (`destroy` alias)/ssh/logs/doctor and ports list/open/close/set/sync. Identifiers
resolve by exact ID, name, or guest IP, never an ID prefix. Generated-ID-shaped resolve by exact ID, name, or guest IP, never an ID prefix. Generated-ID-shaped
inputs resolve only by ID; names cannot impersonate generated IDs. Ambiguous inputs resolve only by ID; names cannot impersonate generated IDs. Ambiguous
@@ -156,7 +157,7 @@ Bulk lifecycle actions and ports sync resolve/deduplicate IDs before prompting.
Delete and mutating `--all` selections require a TTY confirmation or `--yes`/`-y`. Delete and mutating `--all` selections require a TTY confirmation or `--yes`/`-y`.
Port set/close-all and removal of TCP/22 also prompt, except empty managed Port set/close-all and removal of TCP/22 also prompt, except empty managed
no-ops. `--dry-run` previews without lifecycle/policy mutations or confirmation; no-ops. `--dry-run` previews without lifecycle/policy mutations or confirmation;
it is not a full preflight. Stop/restart/delete accept `--timeout`; start does it is not a full preflight. Stop/restart/resize/delete accept `--timeout`; start does
not. Logs follow does not hold the operation lock. Doctor runs readiness not. Logs follow does not hold the operation lock. Doctor runs readiness
checks, not per-VM repair or guest application probes. checks, not per-VM repair or guest application probes.
@@ -185,6 +186,7 @@ process:
| CommandRunner | `CommandRunner` | Logged/testable subprocess execution | | CommandRunner | `CommandRunner` | Logged/testable subprocess execution |
| StateStore | `StateStore` | Inventory, locks, JSON persistence | | StateStore | `StateStore` | Inventory, locks, JSON persistence |
| ImageStore | `ImageStore` | Template verification, VM disks, and offline credentials | | ImageStore | `ImageStore` | Template verification, VM disks, and offline credentials |
| KnownHostsManager | `KnownHostsManager` | Scoped local SSH cache cleanup before deleted IPs are released |
| NetworkManager | `NetworkManager` | Guest allocation and Linux networking | | NetworkManager | `NetworkManager` | Guest allocation and Linux networking |
| FirewallManager | Constructed by `NetworkManager` | Owned native bridge policy and scoped offline conntrack cleanup | | FirewallManager | Constructed by `NetworkManager` | Owned native bridge policy and scoped offline conntrack cleanup |
| FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control | | FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control |
@@ -230,11 +232,11 @@ state file.
### State Schema ### State Schema
[`domain.py`](uvm/domain.py) is the schema authority. The default registry is [`domain.py`](uvm/domain.py) is the schema authority. The default registry is
`/var/lib/uvm/state.json`; its current schema version is **2**. An empty registry: `/var/lib/uvm/state.json`; its current schema version is **3**. An empty registry:
```json ```json
{ {
"schema_version": 2, "schema_version": 3,
"next_mac_index": 1, "next_mac_index": 1,
"vms": {} "vms": {}
} }
@@ -245,7 +247,8 @@ record. Do not make API models or CLI namespaces the persistence format.
| Record Fields | Purpose | | Record Fields | Purpose |
|---|---| |---|---|
| `id`, `name`, `cpu`, `ram_mib` | Stable identity, optional unique name, resource request | | `id`, `name`, `cpu`, `ram_mib`, `storage_mib` | Stable identity, name, applied resources; storage is null for older disks |
| `pending_resize` | Null or a validated `VmResources` target with CPU, RAM MiB, and storage MiB |
| `guest_ip`, `gateway`, `network`, `bridge`, `tap`, `mac` | Retained network identity/configuration | | `guest_ip`, `gateway`, `network`, `bridge`, `tap`, `mac` | Retained network identity/configuration |
| `socket`, `config`, `log`, `disk` | Canonical per-VM runtime paths | | `socket`, `config`, `log`, `disk` | Canonical per-VM runtime paths |
| `username`, `password` | Guest credentials; password stays out of public output | | `username`, `password` | Guest credentials; password stays out of public output |
@@ -271,8 +274,8 @@ mode. There is no persisted boolean asserting that a policy is enforced.
- `operations.lock` serializes long lifecycle, bulk, policy, and installation operations. - `operations.lock` serializes long lifecycle, bulk, policy, and installation operations.
- State reads are lock-free because atomic replacement gives readers either a - State reads are lock-free because atomic replacement gives readers either a
complete old file or a complete new file. complete old file or a complete new file.
- Legacy versions 0 (including unversioned state) and 1 are loaded into schema - Legacy versions 0 (including unversioned state), 1, and 2 are loaded into schema
2 in memory and written as schema 2 on the next transaction. Newer schemas 3 in memory and written as schema 3 on the next transaction. Newer schemas
are rejected. Reads do not reconcile VMs or adopt process identities. are rejected. Reads do not reconcile VMs or adopt process identities.
- Missing `name`, `ports`, and `boot_id` remain null. No policy or default SSH - Missing `name`, `ports`, and `boot_id` remain null. No policy or default SSH
rule is silently imposed on a legacy VM. Use stop, explicit `ports set`, rule is silently imposed on a legacy VM. Use stop, explicit `ports set`,
@@ -285,6 +288,10 @@ mode. There is no persisted boolean asserting that a policy is enforced.
explicit `runtime_owned=False` on a new interrupted creation stays authoritative. explicit `runtime_owned=False` on a new interrupted creation stays authoritative.
- Records created before credential provisioning default to username `root` - Records created before credential provisioning default to username `root`
and password `null`; UVM does not apply a password retroactively. and password `null`; UVM does not apply a password retroactively.
- Missing `storage_mib` and `pending_resize` remain null. The first explicit
resize infers an older disk's capacity and enforces the 30 GiB minimum; reads
and schema migration never resize a disk. Schema 3 prevents older UVM versions
from ignoring pending resource changes during a boot.
- `next_mac_index` is reconstructed from older locally administered MACs when - `next_mac_index` is reconstructed from older locally administered MACs when
absent. absent.
@@ -326,8 +333,9 @@ a ruleset/attachment snapshot, not application health or a reachability test.
1. Validate the request/root access and acquire `operations.lock`. 1. Validate the request/root access and acquire `operations.lock`.
2. Check KVM, verified Firecracker, firewall capability, and installed templates. 2. Check KVM, verified Firecracker, firewall capability, and installed templates.
3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`. 3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`.
4. Create the owned mode-0700 runtime directory, copy a mode-0600 private disk, 4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
provision credentials/host keys, and persist `disk_ready=True`. disk, resize ext4 to the requested storage capacity, provision credentials/host
keys, and persist `disk_ready=True`.
5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**. 5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**.
6. Apply/read back the nftables bridge policy, then activate the TAP. 6. Apply/read back the nftables bridge policy, then activate the TAP.
7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`, 7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`,
@@ -370,6 +378,35 @@ successful start. Failed resume retains the private disk for retry. Restart
performs stop and start under one operation lock, not two independent public performs stop and start under one operation lock, not two independent public
calls with a race between them. calls with a race between them.
### Resource Resizing
`resize()` accepts a partial CPU/RAM/storage update for one VM. New disks default
to 30720 MiB (30 GiB), also the minimum accepted storage. Storage parsing uses
GiB for bare numbers, binary B/K/M/G/T suffixes, and rounds up to MiB; RAM retains
its MiB-default parsing. Capacities must fit the host's signed 64-bit file offsets.
One `operations.lock` spans preflight, durable `pending_resize` publication,
`_stop_locked()`, offline resource application, and `_start_locked()`. Stop must
prove process exit before any disk work. The complete target is a validated
`VmResources`, while the record's CPU/RAM/storage fields remain the applied values.
Only the resource fields change; identity, networking, credentials, ports, and
guest SSH keys are retained. Even an initially stopped VM boots after resize.
`ImageStore.resize_vm_disk()` sparse-copies the private disk to a mode-0600 sibling,
runs `e2fsck -f -p`, resizes ext4, verifies its geometry using `dumpe2fs`, fsyncs,
then atomically replaces the original and fsyncs the directory. Growth extends
the image before `resize2fs`; shrinking truncates it only after ext4 fits and its
geometry matches. Failed staging leaves the original disk intact. CPU/RAM-only
resizes avoid disk work when its configured capacity is already known.
After disk publication, one state write applies all resource fields and clears
`pending_resize`; boot then follows the normal start path. A crash between disk
publication and that state write is recoverable: the target is durable and the
image layer recognizes an already-completed capacity. A stopped VM's `start`
retries a pending resize before boot; a live pending resize requires `resize`
or `stop` first. Stop retains the target and deletion never applies it. A failed
boot after successful resizing retains the new resources for retry.
### Stop and Delete ### Stop and Delete
```text ```text
@@ -380,7 +417,7 @@ stop:
delete (destroy alias): delete (destroy alias):
persist delete intent -> same runtime cleanup -> remove owned runtime directory persist delete intent -> same runtime cleanup -> remove owned runtime directory
-> remove state record -> release name/IP -> clean local known_hosts entries -> remove state record -> release name/IP
``` ```
Stop signals the **VMM**, not a guest shutdown agent or ACPI-coordinated Stop signals the **VMM**, not a guest shutdown agent or ACPI-coordinated
@@ -393,6 +430,28 @@ Runtime cleanup retains reservations when process exit, TAP ownership, or
connection cleanup is uncertain. A pending spawn cannot be declared gone connection cleanup is uncertain. A pending spawn cannot be declared gone
merely because its PID was never persisted or its socket has not appeared. merely because its PID was never persisted or its socket has not appeared.
`KnownHostsManager` is an explicit lifecycle dependency, also used by API and
bulk deletion. It runs after successful process/network/disk teardown and before
the registry entry is removed, under the same operation lock. Cleanup errors
retain `operation=delete` and the IP reservation; repeating delete retries it.
Stop, restart, and resize retain the existing guest's trusted host-key entries.
Default caches are `known_hosts` and `known_hosts2` under the effective user's
and sudo invoker's account homes (`SUDO_UID`, with a `SUDO_USER` fallback).
`UVM_KNOWN_HOSTS_FILES` overrides this discovery for service deployments using a
colon-separated list of absolute paths. Only local files are managed. Matches
are explicit guest IPs or UVM's `uvm-<VM-ID>` host-key alias, including OpenSSH's
HMAC-SHA1 hashed form. Plain bracketed ports are recognized directly; hashed
bracketed candidates use port 22 and the VM's recorded allowlist. Wildcard rules
and other aliases sharing a line are retained.
File updates use pinned directory descriptors, regular/single-link checks,
an advisory lock, and a sibling temporary file. Owner, group, mode, and extended
attributes are preserved. A changed source snapshot prevents publication over
concurrent edits. A matching-free file is not rewritten and missing files are
not created. Tests inject the dependency to avoid accessing developers' caches,
and verify hashed-entry interoperability using real OpenSSH on temporary files.
### Policy and Bulk Operations ### Policy and Bulk Operations
`set_ports()` takes a frozen registry ID, not a reusable name/IP; the CLI resolves `set_ports()` takes a frozen registry ID, not a reusable name/IP; the CLI resolves
@@ -432,6 +491,8 @@ The `firecracker/` package has three separate responsibilities.
The guest receives a static IP via the kernel `ip=` boot argument. The netmask The guest receives a static IP via the kernel `ip=` boot argument. The netmask
comes from `Settings.network`, not a hard-coded `/24` value. comes from `Settings.network`, not a hard-coded `/24` value.
ARM64 guests also receive `keep_bootcon`, as recommended by Firecracker's
getting-started guide; both architectures use `console=ttyS0`.
### Process Safety ### Process Safety
@@ -468,6 +529,9 @@ make recovery convenient. Proven-gone legacy processes can be cleaned up.
the VM runtime directory before UVM modifies the requested existing account's the VM runtime directory before UVM modifies the requested existing account's
shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees
the resulting private copy. Never change that to a shared writable rootfs. the resulting private copy. Never change that to a shared writable rootfs.
Copies use coreutils `cp --reflink=auto --sparse=always`; the new disk is resized
to the requested capacity before credential provisioning. Existing disk resizes
do not reprovision credentials or host keys.
Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's
`/etc/ssh/sshd_config`, and the requested account. UVM does not create missing `/etc/ssh/sshd_config`, and the requested account. UVM does not create missing
@@ -496,7 +560,7 @@ 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 that manifest as publisher trust. Use `install --force` with trusted digests to
refresh an existing artifact without manually deleting managed files. refresh an existing artifact without manually deleting managed files.
Retain the explicit opt-out on create/start/restart (or the API server) when Retain the explicit opt-out on create/start/restart/resize (or the API server) when
using an unverified development installation. It is not appropriate for using an unverified development installation. It is not appropriate for
untrusted workloads. A bare strict install requires the trusted digests. untrusted workloads. A bare strict install requires the trusted digests.
@@ -563,6 +627,9 @@ exclusive create for its replacement. Foreign ownership is an error, not an
adoption opportunity. Readback normalizes presentation differences but compares adoption opportunity. Readback normalizes presentation differences but compares
all effective objects, ordered rules, hooks, identity, and the full port set; all effective objects, ordered rules, hooks, identity, and the full port set;
rule counts or an owner comment alone are not proof of enforcement. rule counts or an owner comment alone are not proof of enforcement.
Normalization materializes implied EtherType constraints before folding
transport-protocol dependencies. This accepts nftables' omitted redundant
`ether type ip` checks while retaining the IPv4 scope of TCP rules.
The policy has prerouting/input/forward/output hooks. Early prerouting identity The policy has prerouting/input/forward/output hooks. Early prerouting identity
checks run at priority -310, before conntrack and bridge MAC learning; input checks run at priority -310, before conntrack and bridge MAC learning; input
@@ -590,6 +657,8 @@ of conntrack entries scoped to the guest IP in all four original/reply source
and destination fields, including NAT reply tuples. Only then may teardown and destination fields, including NAT reply tuples. Only then may teardown
release the address. Rule removal also requires an absent TAP and verified release the address. Rule removal also requires an absent TAP and verified
table ownership; it never flushes global firewall or conntrack state. table ownership; it never flushes global firewall or conntrack state.
Cleanup uses conntrack's documented `-D` (delete) and `-L` (list) commands,
including on conntrack-tools 1.4.8, where `--list` is not a supported option.
### Coexistence and Recovery ### Coexistence and Recovery
@@ -612,6 +681,15 @@ do not import its unimplemented topology checks into current documentation.
`Installer` is the only owner of apt installation, Firecracker artifact `Installer` is the only owner of apt installation, Firecracker artifact
handling, downloads, and integrity-manifest publication. handling, downloads, and integrity-manifest publication.
`Settings` detects and normalizes the host architecture at construction time
(`amd64` to `x86_64`, `arm64` to `aarch64`) and rejects unsupported architectures.
The same architecture selects the Firecracker release archive, guest kernel,
rootfs, and architecture-specific boot arguments. Default guest URLs use
Firecracker's `img/quickstart_guide/<architecture>/` assets: `kernels/vmlinux.bin`
and `rootfs/bionic.rootfs.ext4`. Kernel and rootfs URL overrides are independent;
custom assets and trusted digests must match the host architecture. ARM64
uses an uncompressed Linux `Image` (PE format) and an ARM64 ext4 rootfs.
The host package list includes `nftables` and `conntrack` as well as the The host package list includes `nftables` and `conntrack` as well as the
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
tools. UVM adds no reconciliation daemon or tested external firewall-manager tools. UVM adds no reconciliation daemon or tested external firewall-manager
@@ -659,11 +737,12 @@ or secret manager when that matters.
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks | | `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
| `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `POST` | `/install` | `installation.py` | `{ "force": false }` body |
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | | `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action | | `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action |
| `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP | | `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk | | `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk |
| `POST` | `/vms/{identifier}/restart` | `vms.py` | Restart; optional `timeout` query parameter | | `POST` | `/vms/{identifier}/restart` | `vms.py` | Restart; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/resize` | `vms.py` | Partial `cpu`/`ram`/`storage` update; stop, resize, boot; optional `timeout` |
| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop; optional `timeout` query parameter | | `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop; optional `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; optional `timeout` query parameter | | `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; optional `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | `vms.py` | Exact ID only; body has `mode` and `ports` | | `POST` | `/vms/{vm_id}/ports` | `vms.py` | Exact ID only; body has `mode` and `ports` |
@@ -685,12 +764,17 @@ default). The router normalizes/deduplicates ports and adds TCP/22 unless
default ports are disabled, even for null/empty input. It never creates an default ports are disabled, even for null/empty input. It never creates an
unmanaged VM from an omitted policy. Names share CLI validation. unmanaged VM from an omitted policy. Names share CLI validation.
Create accepts `storage` (default `"30G"`). Resize requires at least one non-null
`cpu`, `ram`, or `storage`; identity/credential fields are rejected by its schema.
Responses expose applied `storage_mib` and a nullable, resource-only
`pending_resize` object so interrupted requests are visible without secrets.
VM responses include `name`, `ports`, `policy_status`, and `operation` alongside VM responses include `name`, `ports`, `policy_status`, and `operation` alongside
resource/status fields and username. The schema excludes password and resource/status fields and username. The schema excludes password and
disk/socket/config/log path fields; error messages may still contain paths. disk/socket/config/log path fields; error messages may still contain paths.
`ports: null` in a legacy response still means unmanaged. Start/restart/stop `ports: null` in a legacy response still means unmanaged. Start/restart/stop
and port-policy mutations report the resulting policy status. Restart, stop, and port-policy mutations report the resulting policy status. Restart, stop,
and delete accept an optional finite non-negative `timeout` query parameter; resize, and delete accept an optional finite non-negative `timeout` query parameter;
there is no dry-run HTTP schema. Port mutation bodies use there is no dry-run HTTP schema. Port mutation bodies use
`{"mode":"set"|"open"|"close", "ports":[...]}` and always resolve the `{"mode":"set"|"open"|"close", "ports":[...]}` and always resolve the
target by exact ID to prevent name/IP reuse. A legacy policy must be set while target by exact ID to prevent name/IP reuse. A legacy policy must be set while
@@ -788,6 +872,16 @@ prove the kernel's actual packet behavior.
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx` FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
are installed. They should be enabled in CI using the `test` extra. are installed. They should be enabled in CI using the `test` extra.
When `nft` and `conntrack` are installed, command-parser tests append an invalid
final command/option so parsing is checked without executing any mutation.
These checks supplement fixture-based readback and cleanup tests; they do not
exercise the kernel packet path.
`test_storage.py` also exercises real e2fsprogs against temporary unmounted,
sparse ext4 images when the tools are installed: default 30 GiB creation,
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
Lifecycle resize tests use fake processes/networking to verify ordering,
single-lock ownership, identity preservation, pending intent, and failure recovery.
### Isolated Packet Tests ### Isolated Packet Tests
+68 -12
View File
@@ -3,7 +3,7 @@
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu. `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, It creates one Firecracker process per VM, attaches it to a host TAP device,
and keeps a private disk and local registry for named VMs. The CLI supports and keeps a private disk and local registry for named VMs. The CLI supports
create, start, stop, restart, delete, inspection, SSH, logs, diagnostics, and create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics, and
enforced private-IPv4 TCP port allowlists. enforced private-IPv4 TCP port allowlists.
This directory is a self-contained project. All UVM code, tests, packaging This directory is a self-contained project. All UVM code, tests, packaging
@@ -38,9 +38,9 @@ The project is an application, not a reusable Python SDK.
- Root access for UVM commands that read or mutate the protected VM registry - Root access for UVM commands that read or mutate the protected VM registry
- KVM enabled and accessible at `/dev/kvm` - KVM enabled and accessible at `/dev/kvm`
- Linux 5.3 or newer with native bridge conntrack and safe pidfd support - Linux 5.3 or newer with native bridge conntrack and safe pidfd support
- An x86_64 host for the default guest assets - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
- Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`, - Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`,
`e2fsprogs`, `util-linux`, `openssl`, and `openssh-client` `coreutils`, `e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
- An STP-disabled software bridge; no offload path that bypasses bridge hooks - An STP-disabled software bridge; no offload path that bypasses bridge hooks
`uvm install` installs the required host packages. It validates `/dev/kvm` `uvm install` installs the required host packages. It validates `/dev/kvm`
@@ -48,10 +48,17 @@ before downloading VM artifacts. Boot operations additionally check that the
native nftables bridge policy is supported; installing packages alone is not native nftables bridge policy is supported; installing packages alone is not
a connectivity or isolation test. a connectivity or isolation test.
Firecracker, the guest kernel, and the Ubuntu rootfs are selected automatically
for the host architecture. On NVIDIA DGX Spark, UVM uses the ARM64 assets.
`arm64` and `amd64` architecture names are normalized to `aarch64` and
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
must point to assets compatible with the host architecture.
## Trusted Installation ## Trusted Installation
Installation requires trusted SHA-256 digests by default. Supply digests from Installation requires trusted SHA-256 digests by default. Supply digests from
an independently trusted source before running the privileged installer: an independently trusted source for the selected architecture before running
the privileged installer:
```sh ```sh
sudo env \ sudo env \
@@ -82,7 +89,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install
An unverified manifest is not accepted as publisher trust in a later strict An unverified manifest is not accepted as publisher trust in a later strict
run. Reinstall with trusted digests to establish a verified local manifest. run. Reinstall with trusted digests to establish a verified local manifest.
To boot VMs from an unverified local-development install, retain To boot VMs from an unverified local-development install, retain
`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create`, `start`, and `restart` `UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create`, `start`, `restart`, and `resize`
commands and on the API server process. commands and on the API server process.
## Commands ## Commands
@@ -93,12 +100,12 @@ These are separate operation examples, not a script to run from top to bottom.
# Install after supplying the trusted checksum variables shown above. # Install after supplying the trusted checksum variables shown above.
# A bare `install` command fails under the default strict integrity policy. # A bare `install` command fails under the default strict integrity policy.
# Create a named VM: 1 CPU, 512 MiB RAM, root/root, and private TCP/22. # Create a named VM: 1 CPU, 512 MiB RAM, 30 GiB storage, root/root, and private TCP/22.
# Use a unique non-default password for anything beyond a throwaway VM. # Use a unique non-default password for anything beyond a throwaway VM.
sudo ./uvm.py create --name web sudo ./uvm.py create --name web
# Choose resources, a private guest IP, and additional service ports. # Choose resources, a private guest IP, and additional service ports.
sudo ./uvm.py create --name app --cpu 1 --ram 1G \ sudo ./uvm.py create --name app --cpu 1 --ram 1G --storage 50G \
--guest-ip 10.42.0.10 --ports 80:HTTP,443:HTTPS --guest-ip 10.42.0.10 --ports 80:HTTP,443:HTTPS
# Read a password from one line of stdin, rather than a --password argument. # Read a password from one line of stdin, rather than a --password argument.
@@ -126,6 +133,10 @@ sudo ./uvm.py stop web --timeout 5
sudo ./uvm.py start web sudo ./uvm.py start web
sudo ./uvm.py restart web app --timeout 10 sudo ./uvm.py restart web app --timeout 10
# Stop, change resources, and boot the same VM with its existing identity/data.
sudo ./uvm.py resize app --cpu 2 --ram 4G --storage 80G
sudo ./uvm.py resize app --ram 2G
# Preview bulk operations before confirming them. # Preview bulk operations before confirming them.
sudo ./uvm.py stop --all --dry-run --json sudo ./uvm.py stop --all --dry-run --json
sudo ./uvm.py stop --all -y sudo ./uvm.py stop --all -y
@@ -143,6 +154,28 @@ sudo ./uvm.py delete web -y
start. It is a finite non-negative SIGTERM wait in seconds, default `5`, not start. It is a finite non-negative SIGTERM wait in seconds, default `5`, not
a total command deadline or a guest shutdown timeout. a total command deadline or a guest shutdown timeout.
### Storage and Resizing
New VMs default to a **30 GiB private disk**, which is also the minimum.
`--storage` accepts values such as `30G`, `50G`, or `1T`; bare numbers use GiB.
As with RAM, `G`/`GB`/`GiB` are binary units. Capacities round up to whole MiB
and are returned as `storage_mib`. Disks are sparse: the configured capacity
does not immediately reserve that much physical host space.
`resize VM` accepts any combination of `--cpu`, `--ram`, and `--storage`, plus
`--timeout` and `--json`. It stops the VM, resizes the ext4 disk when needed,
updates the resource settings, and boots it. A previously stopped VM also boots
at the end. VM ID, IP/MAC, username/password, port rules, guest files, and SSH
host keys are retained. The dashboard exposes the same operation under
**Resize resources**; the API uses `POST /vms/{identifier}/resize`.
Storage can grow or shrink, provided ext4 can fit its existing data into the
requested capacity. Disk changes are prepared on a temporary sparse copy and
published after verification. Failed resizes retain a `pending_resize` target
for retry; `start` on the stopped VM completes that target before booting.
Older VMs report `storage_mib: null` until their first resize, which retains
their current capacity or grows it to the 30 GiB minimum when storage is omitted.
Stop sends SIGTERM and, if needed, SIGKILL to the Firecracker VMM. It is **not Stop sends SIGTERM and, if needed, SIGKILL to the Firecracker VMM. It is **not
a guest-coordinated shutdown**. Start boots the retained disk without resetting a guest-coordinated shutdown**. Start boots the retained disk without resetting
guest data, SSH host keys, or credentials; it does not restore guest memory. guest data, SSH host keys, or credentials; it does not restore guest memory.
@@ -159,6 +192,15 @@ append a remote command after a literal `--`. `--insecure-host-key` disables
verification for one connection. SSH does not automatically supply the stored verification for one connection. SSH does not automatically supply the stored
password or open a closed port. password or open a closed port.
Deletion removes matching local SSH `known_hosts` entries for the guest IP and
UVM's VM-ID host-key alias before releasing the IP for reuse. It checks the
effective user's files and, under `sudo`, the invoking user's files, including
hashed entries. Unrelated hosts and file ownership/permissions are preserved.
For API services or custom SSH cache locations, set `UVM_KNOWN_HOSTS_FILES` to a
colon-separated list of absolute paths on the UVM host. See the SSH section in
[USAGE.md](USAGE.md#7-connect-with-ssh) for configuration and one-time cleanup of
entries left by older versions.
### Private Ports ### Private Ports
```sh ```sh
@@ -241,11 +283,12 @@ The server exposes these routes:
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view | | `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
| `GET` | `/vms` | List VM records and observed status | | `GET` | `/vms` | List VM records and observed status |
| `POST` | `/vms` | Create; supports `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | | `POST` | `/vms` | Create; supports `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions | | `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions |
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/start` | Boot a retained private disk | | `POST` | `/vms/{identifier}/start` | Boot a retained private disk |
| `POST` | `/vms/{identifier}/restart` | Stop then boot a VM; optional `timeout` query parameter | | `POST` | `/vms/{identifier}/restart` | Stop then boot a VM; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/resize` | Stop, resize CPU/RAM/storage, and boot; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter | | `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter | | `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID | | `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID |
@@ -253,7 +296,9 @@ The server exposes these routes:
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log | | `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`; API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`;
`no_default_ports` is a boolean, default `false`. Port changes use `no_default_ports` is a boolean, default `false`. Create also accepts `storage`,
defaulting to `"30G"`; resize accepts optional `cpu`, `ram`, and `storage` fields.
Port changes use
`{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be `{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be
stopped and use `set` to establish a managed policy. Responses include `name`, stopped and use `set` to establish a managed policy. Responses include `name`,
`ports`, `policy_status`, and `operation`, but never passwords. See `ports`, `policy_status`, and `operation`, but never passwords. See
@@ -372,7 +417,7 @@ The default data directory is `/var/lib/uvm`:
└── state.json # Root-only VM inventory and guest credentials └── state.json # Root-only VM inventory and guest credentials
``` ```
The default registry is `/var/lib/uvm/state.json`, schema version `2`. Writes The default registry is `/var/lib/uvm/state.json`, schema version `3`. Writes
are locked and atomic. Lifecycle, port-policy, and installation mutations are locked and atomic. Lifecycle, port-policy, and installation mutations
share an operation lock; bulk operations use a fixed snapshot of VM IDs. share an operation lock; bulk operations use a fixed snapshot of VM IDs.
@@ -397,6 +442,11 @@ time, running binary, exact API-socket command line, and protected paths all
match. Missing or ambiguous evidence fails closed: UVM will not guess which match. Missing or ambiguous evidence fails closed: UVM will not guess which
process to signal or release a disk while process exit is unconfirmed. process to signal or release a disk while process exit is unconfirmed.
Schema 3 adds disk capacity and durable resize targets. Older registries migrate
on the next write; a recorded pending resize is completed before a stopped VM
can boot. A boot failure after a successful resize retains the new allocation
for a later start.
## Configuration ## Configuration
Host and API settings can be overridden through environment variables: Host and API settings can be overridden through environment variables:
@@ -407,8 +457,9 @@ Host and API settings can be overridden through environment variables:
| `UVM_NETWORK` | Guest private IPv4 CIDR; defaults to `10.42.0.0/24` | | `UVM_NETWORK` | Guest private IPv4 CIDR; defaults to `10.42.0.0/24` |
| `UVM_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` | | `UVM_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` |
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` | | `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
| `UVM_KERNEL_URL` | Guest kernel download URL | | `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
| `UVM_ROOTFS_URL` | Guest rootfs download URL | | `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
| `UVM_ROOTFS_URL` | Override the host-architecture default guest rootfs URL |
| `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 | | `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
| `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 | | `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 |
| `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 | | `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 |
@@ -458,6 +509,11 @@ retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery,
and API routes. API route tests run when the FastAPI and `httpx` test and API routes. API route tests run when the FastAPI and `httpx` test
dependencies are installed. dependencies are installed.
When `nft` and `conntrack` are installed, the suite also checks their command
parsers using deliberately invalid final commands/options that prevent mutations.
With e2fsprogs installed, offline disk tests grow and shrink temporary sparse ext4
images and check that data and credential/key fixtures survive; no VM is booted.
Opt-in packet checks run **only in disposable isolated user/network Opt-in packet checks run **only in disposable isolated user/network
namespaces**, which may require privileges and the installed network tools: namespaces**, which may require privileges and the installed network tools:
+136 -21
View File
@@ -44,9 +44,9 @@ You need:
- KVM enabled and readable/writable at `/dev/kvm`. - KVM enabled and readable/writable at `/dev/kvm`.
- Linux >= 5.3 with native bridge conntrack and safe pidfd support. - Linux >= 5.3 with native bridge conntrack and safe pidfd support.
- Root access for installation and VM lifecycle operations. - Root access for installation and VM lifecycle operations.
- An x86_64 host for the default guest kernel/rootfs downloads. - An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark.
- Internet access if downloading Firecracker and guest assets. - Internet access if downloading Firecracker and guest assets.
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, - `iproute2`, `iptables`, `nftables`, `conntrack`, `coreutils`, `e2fsprogs`, `util-linux`,
`openssl`, and `openssh-client`; `install` installs the host packages. `openssl`, and `openssh-client`; `install` installs the host packages.
- An STP-disabled software bridge without traffic offload around its hooks. - An STP-disabled software bridge without traffic offload around its hooks.
@@ -82,7 +82,7 @@ the full path to that environment's console script when necessary.
| Operation | Root required? | | Operation | Root required? |
|---|---| |---|---|
| `install`, `create`, `start`, `stop`, `restart`, `delete`/`destroy` | Yes | | `install`, `create`, `start`, `stop`, `restart`, `resize`, `delete`/`destroy` | Yes |
| `ports open/close/set/sync` | Yes | | `ports open/close/set/sync` | Yes |
| `list`, `inspect`, `ports list`, dry-run target lookup | Access to the default root-owned registry requires root | | `list`, `inspect`, `ports list`, dry-run target lookup | Access to the default root-owned registry requires root |
| `ssh`, `logs` | Access to the protected registry/runtime files requires root | | `ssh`, `logs` | Access to the protected registry/runtime files requires root |
@@ -102,7 +102,7 @@ and do not run every destructive example as a single script.
```text ```text
uvm install [--force] uvm install [--force]
uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--guest-ip GUEST_IP] uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--storage STORAGE] [--guest-ip GUEST_IP]
[--username USER] [--password PASSWORD | --password-stdin] [--username USER] [--password PASSWORD | --password-stdin]
[--ports PORTS]... [--no-default-ports] [--json] [--ports PORTS]... [--no-default-ports] [--json]
uvm list [--status STATUS]... [--json | -q | --quiet] uvm list [--status STATUS]... [--json | -q | --quiet]
@@ -110,6 +110,7 @@ uvm inspect VM [--json]
uvm start (VM [VM ...] | --all) [--yes | -y] [--dry-run] [--json] uvm start (VM [VM ...] | --all) [--yes | -y] [--dry-run] [--json]
uvm stop (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] uvm stop (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json]
uvm restart (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] uvm restart (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json]
uvm resize VM [--cpu CPU] [--ram RAM] [--storage STORAGE] [--timeout SECONDS] [--json]
uvm delete (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] uvm delete (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json]
uvm destroy (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] uvm destroy (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json]
uvm ssh VM [--user USER] [--key PATH] [--port PORT] [--insecure-host-key] [-- COMMAND ...] uvm ssh VM [--user USER] [--key PATH] [--port PORT] [--insecure-host-key] [-- COMMAND ...]
@@ -140,6 +141,23 @@ UVM installs host packages, Firecracker, Jailer, a guest kernel, and a guest
rootfs template. It requires SHA-256 checksums by default because it downloads rootfs template. It requires SHA-256 checksums by default because it downloads
artifacts that will be used by a privileged process. artifacts that will be used by a privileged process.
### ARM64 Hosts (NVIDIA DGX Spark)
UVM detects the host architecture and selects matching Firecracker, kernel,
and rootfs downloads. NVIDIA DGX Spark uses the `aarch64` assets; the `arm64`
architecture alias is also accepted. The default ARM64 guest downloads are:
- Kernel: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/kernels/vmlinux.bin`
- Rootfs: `https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64/rootfs/bionic.rootfs.ext4`
Use the installation commands below with checksums for those ARM64 artifacts
and the ARM64 Firecracker release archive. `UVM_KERNEL_URL` and
`UVM_ROOTFS_URL` can override either download independently. Custom images must
match the host architecture and the [guest image contract](README.md#guest-image-contract).
On ARM64, the kernel must be an uncompressed ARM64 Linux `Image` (PE format),
and the rootfs must be an ARM64 ext4 filesystem. UVM adds `keep_bootcon` to the
guest boot arguments for ARM64 console output.
### Scenario: Strict First Installation ### Scenario: Strict First Installation
Obtain trusted checksums independently, then run: Obtain trusted checksums independently, then run:
@@ -182,7 +200,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install
An unverified manifest is not publisher trust. Reinstall with trusted digests An unverified manifest is not publisher trust. Reinstall with trusted digests
to establish a verified installation. Until then, retain the explicit opt-out to establish a verified installation. Until then, retain the explicit opt-out
on later create/start/restart commands and on an API server that creates VMs: on later create/start/restart/resize commands and on an API server that creates VMs:
```sh ```sh
sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py create sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py create
@@ -196,14 +214,13 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM
| `/dev/kvm does not exist` | Enable hardware/nested virtualization first. | | `/dev/kvm does not exist` | Enable hardware/nested virtualization first. |
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. | | `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. | | `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
| `unsupported host architecture` | Use x86_64 defaults or provide compatible assets. | | `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. |
| `default guest assets support x86_64 only` | Configure both custom kernel and rootfs URLs for ARM. |
## 5. Create VMs ## 5. Create VMs
### Scenario: Create a Default VM ### Scenario: Create a Default VM
The default is one CPU, 512 MiB RAM, username `root`, password `root`, and an The default is one CPU, 512 MiB RAM, 30 GiB storage, username `root`, password `root`, and an
allowlist containing private TCP/22: allowlist containing private TCP/22:
```sh ```sh
@@ -265,10 +282,10 @@ sudo ./uvm.py create --name worker --no-default-ports
port mappings. See Private Port Policies below for live edits and network port mappings. See Private Port Policies below for live edits and network
scope. UVM does not install the services named by these labels. scope. UVM does not install the services named by these labels.
### Scenario: Choose CPU and RAM ### Scenario: Choose CPU, RAM, and Storage
```sh ```sh
sudo ./uvm.py create --cpu 2 --ram 2G sudo ./uvm.py create --cpu 2 --ram 2G --storage 50G
sudo ./uvm.py create --cpu 0.5 --ram 512 sudo ./uvm.py create --cpu 0.5 --ram 512
``` ```
@@ -279,6 +296,14 @@ 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 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. vCPU; it does not currently apply a host CPU quota.
Storage defaults to **30 GiB**, with a **30 GiB minimum**. `--storage 50G` gives
the private ext4 root disk 50 GiB of capacity; bare numbers use GiB. Accepted
suffixes include `M`, `G`, `T`, `MiB`, `GiB`, and `TiB` (also `B` and `K`).
`GB` and `TB` use binary units, consistently with RAM. Fractional amounts round
up to whole MiB. The API and JSON output report capacity as `storage_mib`.
Sparse images consume host space as data is written, rather than reserving the
whole configured capacity up front.
### Scenario: Request a Specific Guest IP ### Scenario: Request a Specific Guest IP
```sh ```sh
@@ -296,7 +321,8 @@ UVM performs these operations in order:
1. Checks root access, KVM, artifact integrity, and nftables/conntrack support. 1. Checks root access, KVM, artifact integrity, and nftables/conntrack support.
2. Reserves identity, name/IP, and initial port policy in the registry. 2. Reserves identity, name/IP, and initial port policy in the registry.
3. Copies the template into a private disk and provisions credentials and SSH host keys. 3. Copies the template into a private disk, resizes ext4 to the requested capacity,
and provisions credentials and SSH host keys.
4. Ensures bridge/egress networking and creates an owned TAP attached but down. 4. Ensures bridge/egress networking and creates an owned TAP attached but down.
5. Installs and verifies the nftables bridge policy, then activates the TAP. 5. Installs and verifies the nftables bridge policy, then activates the TAP.
6. Starts Firecracker, persists process identity, and configures/boots the guest. 6. Starts Firecracker, persists process identity, and configures/boots the guest.
@@ -306,6 +332,12 @@ On failure, UVM attempts containment and cleanup. It does not release the
disk or IP reservation if safe process exit or cleanup cannot be confirmed; disk or IP reservation if safe process exit or cleanup cannot be confirmed;
inspect the retained `failed` record and `last_error` before retrying. inspect the retained `failed` record and `last_error` before retrying.
After updating UVM to fix a create/cleanup error, restart the API server to
load the updated code. To discard a failed creation and release its reserved
IP, run `sudo ./uvm.py delete VM -y` with that failed VM's exact ID, or send
an authenticated `DELETE /vms/VM`. Deletion retries the outstanding cleanup
and removes the failed VM's private disk. Then retry the create request.
## 6. Inspect VMs ## 6. Inspect VMs
```sh ```sh
@@ -397,6 +429,33 @@ 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 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. without confusing it with the prior VM's known-hosts entry.
When a VM is deleted, UVM removes its explicit IP and `uvm-<VM-ID>` entries
from local SSH caches. This also covers direct `ssh user@GUEST_IP` connections
and hashed hostnames. Plain `[host]:port` entries are removed for the VM;
hashed port entries are checked for TCP/22 and the ports in the VM's allowlist.
Other hosts (including other aliases on a shared line), wildcard trust rules,
comments, and file ownership/mode are preserved. Missing caches are harmless.
The default files are `~/.ssh/known_hosts` and `~/.ssh/known_hosts2` for the
effective user and, when running under `sudo`, its invoking user. Homes are
resolved from the local account database. The API uses the user context that
launched its server. To select specific local files for a service or custom
SSH configuration, set this variable when launching UVM:
```sh
export UVM_KNOWN_HOSTS_FILES='/root/.ssh/known_hosts:/home/dgxdev/.ssh/known_hosts'
# Pass it explicitly through sudo when starting the API or running deletion.
sudo env UVM_KNOWN_HOSTS_FILES="$UVM_KNOWN_HOSTS_FILES" ./uvm.py delete VM -y
```
These paths are local to the UVM host. An SSH client on another computer manages
its own cache there. For a stale entry left by a VM deleted with an older UVM
version, remove that entry once from the SSH client's account:
```sh
ssh-keygen -f "$HOME/.ssh/known_hosts" -R '10.42.0.2'
```
The key must already be authorized by the guest; UVM does not inject it. The key must already be authorized by the guest; UVM does not inject it.
`--port` selects the guest SSH port (default 22), not a host mapping. The `--port` selects the guest SSH port (default 22), not a host mapping. The
guest's sshd and private allowlist must allow that port. SSH does not feed the guest's sshd and private allowlist must allow that port. SSH does not feed the
@@ -410,7 +469,7 @@ for one connection:
sudo ./uvm.py ssh web --insecure-host-key sudo ./uvm.py ssh web --insecure-host-key
``` ```
## 8. Start, Stop, Restart, and Delete ## 8. Start, Stop, Restart, Resize, and Delete
### Scenario: Stop a VM and Keep Its Disk ### Scenario: Stop a VM and Keep Its Disk
@@ -451,6 +510,34 @@ disk, or pending deletion blocks a new boot; inspect the error rather than
editing registry flags. An interrupted boot with a live VMM must be stopped editing registry flags. An interrupted boot with a live VMM must be stopped
before start. before start.
### Scenario: Resize an Existing VM
```sh
sudo ./uvm.py resize web --cpu 2 --ram 4G --storage 80G
sudo ./uvm.py resize web --ram 2G --timeout 10 --json
sudo ./uvm.py resize web --storage 30G
```
Supply at least one resource; omitted fields retain their current values (or
the pending target when retrying an interrupted resize). UVM validates the
request and boot prerequisites, records the target, stops the VM, applies the
changes, and boots it under one operation lock. A stopped VM also boots after
resize. The operation retains the VM ID, IP/MAC, name, username/password, port
rules, guest data, and SSH host keys. The dashboard provides these controls
under **Resize resources** on a machine's detail page.
CPU and RAM can increase or decrease. Storage can grow or shrink to at least
30 GiB, subject to the existing data fitting in ext4. Disk resizing uses a
temporary sparse copy; the checked and resized filesystem replaces the original
only after success. This may require additional physical host space for the copy.
A failed stop prevents resource changes. A failed disk resize leaves the VM
stopped with its retained disk and a `pending_resize` target; correct the cause
and retry `resize` (use a larger storage target if needed). `start` on the stopped
VM also retries the pending target. A boot failure after the new resources were
applied retains those values for the next start. `stop` preserves a pending
resize; `delete` removes the VM and its disk normally.
### Scenario: Permanently Remove a VM ### Scenario: Permanently Remove a VM
```sh ```sh
@@ -459,8 +546,10 @@ sudo ./uvm.py delete web -y
# Equivalent destructive command: sudo ./uvm.py destroy web -y # Equivalent destructive command: sudo ./uvm.py destroy web -y
``` ```
Delete performs the stop cleanup, then removes the runtime directory/private Delete performs the stop cleanup, removes the runtime directory/private disk,
disk and registry record, releasing the name and IP. `destroy` is the same cleans matching local SSH host-key entries, then removes the registry record,
releasing the name and IP. A cache cleanup error retains the pending deletion
and IP reservation so cleanup can be retried before the IP is reused. `destroy` is the same
command with the same flags. Back up data first. A failed deletion remains command with the same flags. Back up data first. A failed deletion remains
pending; retry delete rather than trying to resurrect it with start. pending; retry delete rather than trying to resurrect it with start.
@@ -575,7 +664,7 @@ commands and starting the API server do not repair lost rules.
### Legacy VM Migration ### Legacy VM Migration
The default registry is `/var/lib/uvm/state.json`, schema version `2`. Older The default registry is `/var/lib/uvm/state.json`, schema version `3`. Older
registries are accepted and migrated on the next write. Missing/null `ports` registries are accepted and migrated on the next write. Missing/null `ports`
stays `None`/`unmanaged`, not an empty policy and not default SSH enforcement. stays `None`/`unmanaged`, not an empty policy and not default SSH enforcement.
Open/close/sync/start do not silently adopt a policy for such a VM: Open/close/sync/start do not silently adopt a policy for such a VM:
@@ -592,6 +681,11 @@ first be set while the legacy VM is stopped. Start also requires a safe,
completely provisioned private disk; very old records without one cannot be completely provisioned private disk; very old records without one cannot be
resumed by copying a new template over their data. resumed by copying a new template over their data.
VMs created before configurable storage retain `storage_mib: null` until their
first resize. That resize uses the existing disk capacity, rounded up to MiB and
at least 30 GiB, unless an explicit storage target is supplied. Merely reading
or migrating the registry does not resize disks.
A live legacy PID missing only the host boot ID can be adopted during a A live legacy PID missing only the host boot ID can be adopted during a
locked mutation only if its saved start time, running Firecracker executable, locked mutation only if its saved start time, running Firecracker executable,
exact API-socket command line, and protected binary/socket paths match. A exact API-socket command line, and protected binary/socket paths match. A
@@ -690,6 +784,7 @@ curl --cacert /path/to/ca.pem \
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/start` | Start a retained VM | | `POST` | `/vms/{identifier}/start` | Start a retained VM |
| `POST` | `/vms/{identifier}/restart` | Restart a VM; accepts `timeout` query parameter | | `POST` | `/vms/{identifier}/restart` | Restart a VM; accepts `timeout` query parameter |
| `POST` | `/vms/{identifier}/resize` | Stop, resize CPU/RAM/storage, and boot; accepts `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | Stop and retain disk; accepts `timeout` query parameter | | `POST` | `/vms/{identifier}/stop` | Stop and retain disk; accepts `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter | | `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID | | `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID |
@@ -698,7 +793,7 @@ curl --cacert /path/to/ca.pem \
Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt
for confirmation. `timeout` is an optional finite, non-negative query parameter for confirmation. `timeout` is an optional finite, non-negative query parameter
on restart, stop, and delete; it has the same SIGTERM wait semantics as the CLI. on restart, stop, resize, and delete; it has the same SIGTERM wait semantics as the CLI.
### Health, List, and Inspect ### Health, List, and Inspect
@@ -711,7 +806,7 @@ curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/vms/10.42.0.30"
# An exact VM ID from create/list can replace the name or IP. # An exact VM ID from create/list can replace the name or IP.
``` ```
Each response contains `id`, `name`, `cpu`, `ram_mib`, `guest_ip`, `gateway`, Each response contains `id`, `name`, `cpu`, `ram_mib`, `storage_mib`, `pending_resize`, `guest_ip`, `gateway`,
`mac`, `username`, `status`, `observed_status`, `ports`, `policy_status`, `mac`, `username`, `status`, `observed_status`, `ports`, `policy_status`,
`operation`, `pid`, timestamps, and `last_error`. The response schema excludes `operation`, `pid`, timestamps, and `last_error`. The response schema excludes
passwords and disk/socket/config/log path fields. Operational error messages passwords and disk/socket/config/log path fields. Operational error messages
@@ -725,7 +820,7 @@ Create a named VM with a chosen guest address and private service ports:
curl -X POST \ curl -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \ -H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"name":"api-web","cpu":1,"ram":"1G","guest_ip":"10.42.0.30","username":"root","password":"replace-me","ports":[{"port":80,"protocol":"HTTP"},{"port":443,"protocol":"HTTPS"}],"no_default_ports":false}' \ -d '{"name":"api-web","cpu":1,"ram":"1G","storage":"50G","guest_ip":"10.42.0.30","username":"root","password":"replace-me","ports":[{"port":80,"protocol":"HTTP"},{"port":443,"protocol":"HTTPS"}],"no_default_ports":false}' \
"$UVM_API_URL/vms" "$UVM_API_URL/vms"
``` ```
@@ -739,7 +834,7 @@ curl -X POST \
"$UVM_API_URL/vms" "$UVM_API_URL/vms"
``` ```
Request fields are `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, Request fields are `cpu`, `ram`, `storage`, `guest_ip`, `username`, `password`, `name`,
`ports`, and `no_default_ports`. The name has the same validation as the CLI. `ports`, and `no_default_ports`. The name has the same validation as the CLI.
`ports` is an array of objects (or null/omitted), not a CLI colon string. `ports` is an array of objects (or null/omitted), not a CLI colon string.
Each object has a strict integer `port` from 1 to 65535 and an optional, Each object has a strict integer `port` from 1 to 65535 and an optional,
@@ -747,6 +842,8 @@ case-sensitive `protocol` of `TCP` (default), `HTTP`, or `HTTPS`.
`no_default_ports` is a strict boolean, default `false`; port 22 is added `no_default_ports` is a strict boolean, default `false`; port 22 is added
unless that flag is true, even if `ports` is null, empty, or contains other unless that flag is true, even if `ports` is null, empty, or contains other
ports. Null create input does not create a legacy/unmanaged VM. ports. Null create input does not create a legacy/unmanaged VM.
`storage` defaults to `"30G"`; numeric storage values use GiB, with the same
30 GiB minimum and suffixes as the CLI.
The response never includes the password. Omitting credentials still uses The response never includes the password. Omitting credentials still uses
`root` / `root`. Extra request fields, invalid ports/names, and conflicting `root` / `root`. Extra request fields, invalid ports/names, and conflicting
@@ -781,6 +878,23 @@ exact `id` and `status: "terminated"`. Start and restart return the updated VM
response. Start preserves the retained private disk and does not restore guest response. Start preserves the retained private disk and does not restore guest
memory. memory.
### Resize CPU, RAM, or Storage
```sh
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"cpu":2,"ram":"4G","storage":"80G"}' \
"$UVM_API_URL/vms/api-web/resize?timeout=10"
```
The body accepts optional `cpu`, `ram`, and `storage` fields; at least one must
be non-null. For example, `{"ram":"2G"}` changes only memory. IP and credentials
are retained and cannot be edited through this endpoint. A successful response
is the updated running VM, including `storage_mib` and `pending_resize: null`.
During a failed/interrupted resize, `pending_resize` contains the full target
as `{ "cpu": 2, "ram_mib": 4096, "storage_mib": 81920 }` until it is applied.
### Bulk Lifecycle Actions ### Bulk Lifecycle Actions
The dashboard batch toolbar uses this endpoint for selected VMs. The server The dashboard batch toolbar uses this endpoint for selected VMs. The server
@@ -938,8 +1052,9 @@ automatically detects every conflict with host routes or other managers.
| `UVM_NETWORK` | Guest subnet; default `10.42.0.0/24` | | `UVM_NETWORK` | Guest subnet; default `10.42.0.0/24` |
| `UVM_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` | | `UVM_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` |
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` | | `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
| `UVM_KERNEL_URL` | Custom kernel download URL | | `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths |
| `UVM_ROOTFS_URL` | Custom rootfs download URL | | `UVM_KERNEL_URL` | Override the host-architecture default kernel URL |
| `UVM_ROOTFS_URL` | Override the host-architecture default rootfs URL |
| `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum | | `UVM_FIRECRACKER_SHA256` | Strict Firecracker archive checksum |
| `UVM_KERNEL_SHA256` | Strict kernel checksum | | `UVM_KERNEL_SHA256` | Strict kernel checksum |
| `UVM_ROOTFS_SHA256` | Strict rootfs checksum | | `UVM_ROOTFS_SHA256` | Strict rootfs checksum |
+1 -1
View File
@@ -296,7 +296,7 @@ def _sandbox(parent_fd):
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True) print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
def service_ids(): def service_ids():
listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp", listing = run("conntrack", "-L", "--family", "ipv4", "--proto", "tcp",
"--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id") "--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id")
return set(re.findall(r"\bid=(\d+)", listing)) return set(re.findall(r"\bid=(\d+)", listing))
+1
View File
@@ -108,6 +108,7 @@ class CliCreateTests(unittest.TestCase):
command="create", command="create",
cpu="1", cpu="1",
ram="512", ram="512",
storage="30G",
host_ip=None, host_ip=None,
username="admin", username="admin",
password="custom-secret", password="custom-secret",
+34 -3
View File
@@ -30,7 +30,7 @@ class CliTestCase(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.vm = replace( self.vm = replace(
make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")), make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")),
status="running", disk_ready=True, status="running", disk_ready=True, storage_mib=30 * 1024,
) )
self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True) self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True)
self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced") self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced")
@@ -45,8 +45,9 @@ class CliTestCase(unittest.TestCase):
] ]
self.lifecycle.create.side_effect = lambda spec: replace( self.lifecycle.create.side_effect = lambda spec: replace(
self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username, self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username,
password=spec.password, name=spec.name, ports=spec.ports, password=spec.password, name=spec.name, ports=spec.ports, storage_mib=spec.storage_mib,
) )
self.lifecycle.resize.return_value = self.vm
self.application = SimpleNamespace( self.application = SimpleNamespace(
settings=Settings(), lifecycle=self.lifecycle, settings=Settings(), lifecycle=self.lifecycle,
state_store=Mock(spec_set=("operation_lock",)), state_store=Mock(spec_set=("operation_lock",)),
@@ -186,10 +187,38 @@ class CliParsingTests(CliTestCase):
self.assertEqual(self.invoke([])[0], 2) self.assertEqual(self.invoke([])[0], 2)
class CliResizeCommandTests(CliTestCase):
def test_resize_forwards_resources_and_timeout_without_changing_identity(self) -> None:
self.lifecycle.resize.return_value = replace(self.vm, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
code, stdout, stderr = self.invoke([
"resize", "web", "--cpu", "2", "--ram", "4G", "--storage", "50G", "--timeout", "0.5", "--json",
])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=2, ram_mib=4096, storage_mib=51200, timeout=0.5)
result = json.loads(stdout)
self.assertEqual(result["storage_mib"], 51200)
self.assertEqual(result["guest_ip"], self.vm.guest_ip)
self.assertEqual(result["username"], self.vm.username)
self.assertNotIn("password", result)
self.assertNotIn(self.vm.password, stdout + stderr)
def test_partial_resize_preserves_omitted_fields(self) -> None:
code, _stdout, stderr = self.invoke(["resize", "web", "--storage", "60G"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=None, ram_mib=None, storage_mib=61440, timeout=None)
def test_invalid_resize_requests_are_rejected_before_lifecycle_calls(self) -> None:
for options in ([], ["--storage", "29G"], ["--ram", "127M"], ["--cpu", "nan"],
["--cpu", "0"], ["--cpu", "2", "--timeout", "-1"], ["--username", "new-user"]):
with self.subTest(options=options):
self.assertEqual(self.invoke(["resize", "web", *options])[0], 2)
self.lifecycle.resize.assert_not_called()
class CliCreateCommandTests(CliTestCase): class CliCreateCommandTests(CliTestCase):
def test_create_combines_default_ports_and_stdin_credentials(self) -> None: def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
code, stdout, stderr = self.invoke([ code, stdout, stderr = self.invoke([
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G", "create", "--name", "web", "--cpu", "0.5", "--ram", "1G", "--storage", "50G",
"--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin", "--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin",
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json", "--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
], stdin=io.StringIO(" stdin-secret \r\n")) ], stdin=io.StringIO(" stdin-secret \r\n"))
@@ -199,6 +228,7 @@ class CliCreateCommandTests(CliTestCase):
self.assertEqual(spec.name, "web") self.assertEqual(spec.name, "web")
self.assertEqual(spec.cpu, 0.5) self.assertEqual(spec.cpu, 0.5)
self.assertEqual(spec.ram_mib, 1024) self.assertEqual(spec.ram_mib, 1024)
self.assertEqual(spec.storage_mib, 50 * 1024)
self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8")) self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8"))
self.assertEqual(spec.username, "admin") self.assertEqual(spec.username, "admin")
self.assertEqual(spec.password, " stdin-secret ") self.assertEqual(spec.password, " stdin-secret ")
@@ -222,6 +252,7 @@ class CliCreateCommandTests(CliTestCase):
code, stdout, stderr = self.invoke(["create", *options, "--json"]) code, stdout, stderr = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 0, stderr) self.assertEqual(code, 0, stderr)
self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected) self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected)
self.assertEqual(self.lifecycle.create.call_args.args[0].storage_mib, 30 * 1024)
self.assertNotIn("WARNING", stdout) self.assertNotIn("WARNING", stdout)
self.assertIn("default password", stderr) self.assertIn("default password", stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "enforced") self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import ConfigurationError
class ArchitectureSettingsTests(unittest.TestCase):
def test_resolves_native_defaults_when_settings_are_created(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("AMD64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
for factory in (Settings, Settings.from_environment):
with (
self.subTest(machine=machine, factory=factory),
patch.dict(os.environ, {}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = factory()
base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
self.assertEqual(settings.architecture, architecture)
self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin")
self.assertEqual(settings.rootfs_url, f"{base}/rootfs/bionic.rootfs.ext4")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
base = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64"
for overrides in (
{"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs},
{"UVM_KERNEL_URL": kernel, "UVM_ROOTFS_URL": rootfs},
):
with (
self.subTest(overrides=overrides),
patch.dict(os.environ, overrides, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="aarch64")),
):
settings = Settings.from_environment()
self.assertEqual(
settings.kernel_url,
overrides.get("UVM_KERNEL_URL", f"{base}/kernels/vmlinux.bin"),
)
self.assertEqual(
settings.rootfs_url,
overrides.get("UVM_ROOTFS_URL", f"{base}/rootfs/bionic.rootfs.ext4"),
)
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
with (
patch.dict(os.environ, {
"UVM_KERNEL_URL": "https://example.com/kernel",
"UVM_ROOTFS_URL": "https://example.com/rootfs.ext4",
}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="riscv64")),
self.assertRaisesRegex(ConfigurationError, "unsupported host architecture: riscv64"),
):
Settings.from_environment()
class KnownHostsSettingsTests(unittest.TestCase):
def test_known_hosts_files_are_loaded_as_explicit_absolute_paths(self) -> None:
with patch.dict(os.environ, {
"UVM_KNOWN_HOSTS_FILES": "/root/.ssh/known_hosts:/home/operator/.ssh/known_hosts",
}, clear=True):
settings = Settings.from_environment()
self.assertEqual(settings.known_hosts_files, (
Path("/root/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts"),
))
with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(Settings.from_environment().known_hosts_files)
def test_relative_or_empty_path_entries_are_rejected(self) -> None:
for value in ("known_hosts", "/tmp/known_hosts:", ":/tmp/known_hosts"):
with self.subTest(value=value), patch.dict(os.environ, {"UVM_KNOWN_HOSTS_FILES": value}, clear=True):
with self.assertRaisesRegex(ConfigurationError, "UVM_KNOWN_HOSTS_FILES"):
Settings.from_environment()
+27
View File
@@ -19,6 +19,33 @@ class RecordingFirecrackerClient(FirecrackerClient):
class FirecrackerConfigTests(unittest.TestCase): class FirecrackerConfigTests(unittest.TestCase):
def test_uses_arm_boot_console_arguments_only_on_arm64(self) -> None:
vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
)
for architecture in ("x86_64", "aarch64", "arm64"):
with self.subTest(architecture=architecture):
config = build_config(
Settings(architecture=architecture),
vm,
Path("/images/vmlinux"),
Path("/vm/rootfs.ext4"),
)
arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments)
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
def test_builds_a_writable_vm_disk_configuration(self) -> None: def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord( vm = VmRecord(
id="vm-test", id="vm-test",
+89 -6
View File
@@ -102,9 +102,9 @@ class FakeRunner:
self.after_batch() self.after_batch()
elif args == ("conntrack", "--version"): elif args == ("conntrack", "--version"):
stdout = "conntrack v1.4.8 (conntrack-tools)" stdout = "conntrack v1.4.8 (conntrack-tools)"
elif args[:2] == ("conntrack", "--delete"): elif args[:2] == ("conntrack", "-D"):
code = self.ct_delete_code code = self.ct_delete_code
elif args[:2] == ("conntrack", "--list"): elif args[:2] == ("conntrack", "-L"):
stdout = self.ct_remaining stdout = self.ct_remaining
else: else:
raise AssertionError(f"unexpected command: {args}") raise AssertionError(f"unexpected command: {args}")
@@ -376,6 +376,34 @@ class FirewallManagerTests(unittest.TestCase):
exprs[:-1] = reversed(exprs[:-1]) exprs[:-1] = reversed(exprs[:-1])
self.assertEqual(self.manager.status(self.vm), "enforced") self.assertEqual(self.manager.status(self.vm), "enforced")
def test_apply_accepts_nft_eliding_ethertype_from_the_ipv4_tcp_guard(self):
self.vm.ports = (PortRule(22),)
def elide_ethertype():
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if not any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
continue
# nft 1.0.9 keeps ip protocol tcp to bind TCP to IPv4 in a
# bridge table, but omits its redundant ether type ip check.
expressions[:] = [expr for expr in expressions if expr != {
"match": {"left": {"payload": {"protocol": "ether", "field": "type"}},
"op": "==", "right": "ip"},
}]
self.runner.after_batch = elide_ethertype
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
# Losing BOTH IPv4 constraints is a real policy change, not formatting.
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
expressions[:] = [expr for expr in expressions if expr.get("match", {}).get("left") != {
"payload": {"protocol": "ip", "field": "protocol"},
}]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self): def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self):
self.vm.ports = () self.vm.ports = ()
self.manager.apply(self.vm) self.manager.apply(self.vm)
@@ -430,7 +458,7 @@ class FirewallManagerTests(unittest.TestCase):
self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too. self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too.
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
for command in ("--delete", "--list"): for command in ("-D", "-L"):
self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip), self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip),
[args for args, _ in self.runner.calls]) [args for args, _ in self.runner.calls])
self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls)) self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls))
@@ -438,7 +466,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.assertRaisesRegex(UvmError, "do not reuse"): with self.assertRaisesRegex(UvmError, "do not reuse"):
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
self.runner.ct_remaining = "" self.runner.ct_remaining = ""
self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip) self.runner.fail_command = ("conntrack", "-L", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
with self.assertRaises(CommandError): with self.assertRaises(CommandError):
self.manager.cleanup_connections(self.vm) self.manager.cleanup_connections(self.vm)
@@ -455,7 +483,7 @@ class FirewallManagerTests(unittest.TestCase):
expected = [("ip", "-j", "-d", "link", "show")] expected = [("ip", "-j", "-d", "link", "show")]
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7") expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst") for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst")
for command in ("--delete", "--list")) for command in ("-D", "-L"))
# No nft policy, publishing, or unrelated address/conntrack flush. # No nft policy, publishing, or unrelated address/conntrack flush.
self.assertEqual([args for args, _ in self.runner.calls], expected) self.assertEqual([args for args, _ in self.runner.calls], expected)
@@ -476,7 +504,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.subTest(address=address, network=network): with self.subTest(address=address, network=network):
self.runner.calls.clear() self.runner.calls.clear()
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")] deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "-D")]
self.assertEqual(len(deletes), 4) self.assertEqual(len(deletes), 4)
self.assertTrue(all(args[-1] == address for args in deletes)) self.assertTrue(all(args[-1] == address for args in deletes))
@@ -688,6 +716,32 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric)) self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric))
self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]'))) self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]')))
def test_bridge_tcp_guard_normalizes_numeric_protocol_and_implicit_ethertype(self):
# nft 1.0.9 src/payload.c payload_may_dependency_kill() removes the
# EtherType before ip protocol, but retains ip protocol before TCP.
# Protocol and flag representations follow the upstream fixtures in
# tests/py/inet/ip_tcp.t.json.output and tests/py/inet/tcp.t.json.
dumped = '''[
{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}},
"op": "==", "right": 6}},
{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}},
{"match": {"left": {"&": [
{"payload": {"protocol": "tcp", "field": "flags"}}, ["fin", "syn", "rst", "ack"]
]}, "op": "!=", "right": "syn"}},
{"drop": null}
]'''
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
guards = [item["rule"] for item in policy if "rule" in item
and any("&" in expr.get("match", {}).get("left", {}) for expr in item["rule"]["expr"])]
self.assertEqual({rule["chain"] for rule in guards}, {"from_vm", "to_vm"})
for rule in guards:
with self.subTest(chain=rule["chain"]):
expected = self.normalize_rule(json.dumps(rule["expr"]))
self.assertEqual(expected, self.normalize_rule(dumped))
self.assertNotEqual(expected, self.normalize_rule(json.dumps(json.loads(dumped)[1:])))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"right": 6', '"right": 17')))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"op": "!="', '"op": "=="')))
def test_upstream_full_dump_ignores_only_handles_and_metainfo(self): def test_upstream_full_dump_ignores_only_handles_and_metainfo(self):
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0. # Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
dumped = json.loads('''{"nftables": [ dumped = json.loads('''{"nftables": [
@@ -706,6 +760,35 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertNotEqual(_normalize(dumped), _normalize(expected)) self.assertNotEqual(_normalize(dumped), _normalize(expected))
@unittest.skipUnless(shutil.which("conntrack"), "conntrack unavailable for nonmutating option checks")
class ConntrackCommandParserTests(unittest.TestCase):
@staticmethod
def parse(command):
# An invalid final option aborts before any netlink operation, even
# when the tests run as root. This also checks delete command syntax.
return subprocess.run(
(*command, "--uvm-parser-sentinel"), text=True, capture_output=True,
timeout=5, env={**os.environ, "LC_ALL": "C"},
)
def test_cleanup_commands_reach_only_the_final_invalid_option(self):
runner = FakeRunner()
FirewallManager(Settings(), runner).cleanup_connections(vm_record())
commands = [args for args, _ in runner.calls if args[0] == "conntrack"]
self.assertEqual(len(commands), 8)
for command in commands:
with self.subTest(command=command):
result = self.parse(command)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertRegex(result.stderr, r"(?:unknown|unrecognized) option [`']--uvm-parser-sentinel'")
def test_invalid_option_is_rejected_before_the_sentinel(self):
result = self.parse(("conntrack", "--uvm-invalid-option"))
self.assertEqual(result.returncode, 2)
self.assertIn("--uvm-invalid-option", result.stderr)
self.assertNotIn("--uvm-parser-sentinel", result.stderr)
@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks") @unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks")
class NftJsonParserTests(unittest.TestCase): class NftJsonParserTests(unittest.TestCase):
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation. """Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import hashlib
import io
import os
import tarfile
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.install import Installer
from uvm.integrity import load_manifest
from uvm.state import StateStore
from uvm.system import CommandRunner
class InstallerArchitectureTests(unittest.TestCase):
def test_installs_matching_native_artifacts_with_a_verified_manifest(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
with (
self.subTest(machine=machine),
tempfile.TemporaryDirectory() as temporary_directory,
patch.dict(os.environ, {"UVM_BASE": temporary_directory}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = Settings.from_environment()
version = settings.firecracker_version
binaries = {
"firecracker": f"firecracker for {architecture}".encode(),
"jailer": f"jailer for {architecture}".encode(),
}
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
for name, content in binaries.items():
member = tarfile.TarInfo(
f"release-{version}-{architecture}/{name}-{version}-{architecture}"
)
member.size = len(content)
member.mode = 0o755
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
kernel_bytes = f"kernel for {architecture}".encode()
rootfs_bytes = f"rootfs for {architecture}".encode()
settings = replace(
settings,
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
)
guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
f"{guest_base}/kernels/vmlinux.bin": kernel_bytes,
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
}
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm"),
patch(
"uvm.install.urllib.request.urlopen",
side_effect=lambda url, timeout: io.BytesIO(payloads[url]),
) as download,
):
firecracker, assets = installer.install()
self.assertEqual(firecracker.read_bytes(), binaries["firecracker"])
self.assertEqual(settings.jailer_binary.read_bytes(), binaries["jailer"])
self.assertEqual(assets.kernel.read_bytes(), kernel_bytes)
self.assertEqual(assets.rootfs.read_bytes(), rootfs_bytes)
self.assertEqual(download.call_count, 3)
self.assertEqual({call.args[0] for call in download.call_args_list}, set(payloads))
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums, {
name: hashlib.sha256(content).hexdigest()
for name, content in {
**binaries, "kernel": kernel_bytes, "rootfs": rootfs_bytes,
}.items()
})
def test_arm64_install_requires_checksums_before_host_commands_or_downloads(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
settings = Settings(base=Path(temporary_directory), architecture="aarch64")
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm") as check_kvm,
patch("uvm.install.urllib.request.urlopen") as download,
self.assertRaisesRegex(UvmError, "Firecracker archive checksum is required"),
):
installer.install()
runner.run.assert_not_called()
check_kvm.assert_not_called()
download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists())
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
import errno
import os
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.test_cli import make_vm
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import Settings
from uvm.domain import PortRule
from uvm.errors import FirecrackerError, UvmError, ValidationError
from uvm.known_hosts import KnownHostsManager, _without_hosts
from uvm.system import CommandRunner
class KnownHostsTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
self.path = self.directory / "known_hosts"
self.vm = replace(make_vm(), ports=(PortRule(22), PortRule(2222)))
self.manager = KnownHostsManager(Settings(known_hosts_files=(self.path,)))
def test_removes_all_vm_key_types_and_aliases_but_preserves_other_hosts(self) -> None:
self.path.write_bytes(
b"# Known hosts\r\n"
b"10.42.0.2 ssh-ed25519 AAAAed first key\n"
b"10.42.0.2 ecdsa-sha2-nistp256 AAAAecdsa second key\n"
b"10.42.0.2 ssh-rsa AAAArsa third key\n"
b"uvm-vm-test ssh-ed25519 AAAAalias\n"
b"[10.42.0.2]:22 ssh-ed25519 AAAAport22\n"
b"[10.42.0.2]:2222 ssh-ed25519 AAAAport2222\n"
b"[10.42.0.2]:8022 ssh-ed25519 AAAAold-port\n"
b"[uvm-vm-test]:2222 ssh-ed25519 AAAAportalias\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,10.42.0.2,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority 10.42.0.2,example.com ssh-ed25519 AAAAca\n"
b"@revoked 10.42.0.2 ssh-ed25519 AAAArevoked\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(),
b"# Known hosts\r\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority example.com ssh-ed25519 AAAAca\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
def test_missing_files_and_unmatched_hosts_are_noops(self) -> None:
missing = self.directory / "missing" / ".ssh" / "known_hosts"
KnownHostsManager(Settings(known_hosts_files=(missing, self.path))).remove(self.vm)
self.assertEqual(list(self.directory.iterdir()), [])
self.path.write_bytes(b"10.42.0.3 ssh-ed25519 AAAAother\n")
before = self.path.stat()
with patch("uvm.known_hosts.os.replace") as publish:
self.manager.remove(self.vm)
publish.assert_not_called()
self.assertEqual(self.path.stat().st_ino, before.st_ino)
self.assertEqual(self.path.stat().st_mtime_ns, before.st_mtime_ns)
def test_rewrite_preserves_ownership_and_file_mode_and_is_idempotent(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.path.chmod(0o640)
before = self.path.stat()
self.manager.remove(self.vm)
after = self.path.stat()
self.assertEqual((after.st_uid, after.st_gid, after.st_mode), (before.st_uid, before.st_gid, before.st_mode))
self.assertEqual(self.path.read_bytes(), b"10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.manager.remove(self.vm)
self.assertEqual(self.path.stat().st_ino, after.st_ino)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_root_rewrite_restores_the_file_owners_uid_and_gid(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
inode = self.path.stat().st_ino
real_fstat, real_stat = os.fstat, os.stat
def ownership(metadata):
if metadata.st_ino != inode:
return metadata
values = list(metadata)
values[4:6] = [12345, 23456]
return os.stat_result(values)
with (
patch("uvm.known_hosts.os.fstat", side_effect=lambda fd: ownership(real_fstat(fd))),
patch("uvm.known_hosts.os.stat", side_effect=lambda *args, **kwargs: ownership(real_stat(*args, **kwargs))),
patch("uvm.known_hosts.os.fchown") as chown,
):
self.manager.remove(self.vm)
self.assertEqual(chown.call_count, 1)
self.assertEqual(chown.call_args.args[1:], (12345, 23456))
self.assertEqual(self.path.read_bytes(), b"")
def test_extended_file_attributes_survive_replacement(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
try:
os.setxattr(self.path, "user.uvm-fixture", b"preserved")
except OSError as error:
if error.errno in (errno.ENOTSUP, errno.EPERM):
self.skipTest("extended file attributes are unavailable")
raise
self.manager.remove(self.vm)
self.assertEqual(os.getxattr(self.path, "user.uvm-fixture"), b"preserved")
def test_default_files_include_root_and_sudo_user_from_passwd_not_home(self) -> None:
root = SimpleNamespace(pw_uid=0, pw_dir="/root")
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", side_effect=lambda uid: {0: root, 1000: user}[uid]),
patch.dict(os.environ, {"HOME": "/wrong-home", "SUDO_UID": "1000"}, clear=True),
):
paths = KnownHostsManager(Settings())._files()
self.assertEqual(paths, {
Path("/root/.ssh/known_hosts"): 0, Path("/root/.ssh/known_hosts2"): 0,
Path("/home/operator/.ssh/known_hosts"): 1000, Path("/home/operator/.ssh/known_hosts2"): 1000,
})
def test_sudo_user_fallback_and_nonroot_context_are_scoped_to_the_launcher(self) -> None:
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", return_value=SimpleNamespace(pw_uid=0, pw_dir="/root")),
patch("uvm.known_hosts.pwd.getpwnam", return_value=user) as lookup,
patch.dict(os.environ, {"SUDO_USER": "operator"}, clear=True),
):
self.assertIn(Path("/home/operator/.ssh/known_hosts"), KnownHostsManager(Settings())._files())
lookup.assert_called_once_with("operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=1000),
patch("uvm.known_hosts.pwd.getpwuid", return_value=user),
patch.dict(os.environ, {"SUDO_UID": "9999"}, clear=True),
):
self.assertEqual(set(KnownHostsManager(Settings())._files()), {
Path("/home/operator/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts2"),
})
def test_publication_failure_preserves_original_and_removes_temporary_file(self) -> None:
contents = b"10.42.0.2 ssh-ed25519 AAAAold\n"
self.path.write_bytes(contents)
with patch("uvm.known_hosts.os.replace", side_effect=OSError("publish failed")):
with self.assertRaisesRegex(UvmError, "publish failed"):
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), contents)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_concurrent_append_is_not_lost_and_cleanup_can_be_retried(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
addition = b"10.42.0.4 ssh-ed25519 AAAAconcurrent\n"
def append(contents, hosts, candidates):
with self.path.open("ab") as output:
output.write(addition)
return _without_hosts(contents, hosts, candidates)
with patch("uvm.known_hosts._without_hosts", side_effect=append):
with self.assertRaisesRegex(UvmError, "changed during cleanup"):
self.manager.remove(self.vm)
self.assertTrue(self.path.read_bytes().endswith(addition))
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), addition)
def test_temporary_name_collision_never_removes_a_foreign_file(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
foreign = self.directory / ".known_hosts.uvm-collision"
foreign.write_bytes(b"keep this")
with patch("uvm.known_hosts.uuid.uuid4", return_value=SimpleNamespace(hex="collision")):
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(foreign.read_bytes(), b"keep this")
self.assertIn(b"10.42.0.2", self.path.read_bytes())
def test_symlink_and_hardlinked_files_are_not_rewritten(self) -> None:
target = self.directory / "target"
target.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
for kind in ("symlink", "hardlink"):
with self.subTest(kind=kind):
self.path.unlink(missing_ok=True)
if kind == "symlink":
self.path.symlink_to(target)
else:
self.path.hardlink_to(target)
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(target.read_bytes(), b"10.42.0.2 ssh-ed25519 AAAAold\n")
@unittest.skipUnless(shutil.which("ssh-keygen"), "OpenSSH is required for hashed-host interoperability")
def test_removes_real_openssh_hashed_hosts_for_all_guest_keys_and_keeps_other_hosts(self) -> None:
runner = CommandRunner(emit=None)
lines = []
for key_type in ("ed25519", "ecdsa"):
key = self.directory / key_type
runner.run(("ssh-keygen", "-q", "-t", key_type, "-N", "", "-f", key), capture=True)
public = key.with_suffix(".pub").read_text().strip()
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222", "10.42.0.3"):
lines.append(f"{host} {public}\n")
self.path.write_text("".join(lines))
runner.run(("ssh-keygen", "-H", "-f", self.path), capture=True)
self.assertNotIn(b"10.42.0.2", self.path.read_bytes())
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True)
self.manager.remove(self.vm)
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
result = runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True, check=False)
self.assertEqual(result.returncode, 1)
runner.run(("ssh-keygen", "-F", "10.42.0.3", "-f", self.path), capture=True)
class DeleteKnownHostsTests(LifecycleTestCase):
def test_deletion_cleans_keys_after_teardown_before_releasing_the_ip(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.destroy(vm.id)
self.assert_call_order(vm, "process.gone", "network.delete_vm_tap", "firewall.remove", "known_hosts.remove")
self.assertEqual(self.host.records["known_hosts.remove", vm.id].operation, "delete")
self.assertNotIn(vm.id, self.store.load().vms)
def test_stop_restart_and_resize_retain_host_keys(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
self.service.start(vm.id)
self.service.restart(vm.id)
self.service.resize(vm.id, ram_mib=1024)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_failed_stop_does_not_remove_host_keys(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("still alive")
with self.assertRaises(UvmError):
self.service.destroy(vm.id)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_cleanup_error_retains_the_ip_reservation_and_delete_retries(self) -> None:
vm = self.create_vm()
self.host.failures["known_hosts.remove", vm.id] = UvmError("known_hosts is not writable")
with self.assertRaisesRegex(UvmError, "known_hosts is not writable"):
self.service.destroy(vm.id)
self.assertEqual(self.store.get(vm.id).operation, "delete")
self.assertFalse(Path(vm.disk).exists())
with self.assertRaises(ValidationError):
self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.service.destroy(vm.id)
created = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(created.guest_ip, vm.guest_ip)
def test_delete_removes_real_cache_entries_and_allows_reuse_without_stale_keys(self) -> None:
vm = self.create_vm()
cache = self.settings.base / "known_hosts"
cache.write_text(f"{vm.guest_ip} ssh-ed25519 AAAAold\n10.42.0.99 ssh-ed25519 AAAAother\n")
self.service._known_hosts = KnownHostsManager(replace(self.settings, known_hosts_files=(cache,)))
self.service.destroy(vm.id)
self.assertEqual(cache.read_text(), "10.42.0.99 ssh-ed25519 AAAAother\n")
replacement = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(replacement.guest_ip, vm.guest_ip)
def test_bulk_delete_uses_the_same_known_hosts_cleanup(self) -> None:
vms = [self.create_vm(), self.create_vm()]
results = self.service.run_many("delete", [vm.id for vm in vms])
self.assertTrue(all(result.outcome == "succeeded" for result in results))
for vm in vms:
self.assertIn("known_hosts.remove", self.host.actions(vm.id))
+35
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
import tempfile import tempfile
import unittest import unittest
from collections.abc import Callable, Iterable, Iterator from collections.abc import Callable, Iterable, Iterator
@@ -80,6 +81,16 @@ class FakeHost:
return [action for action, target in self.events if target == vm_id] return [action for action, target in self.events if target == vm_id]
class FakeKnownHosts:
def __init__(self, host: FakeHost) -> None:
self.host = host
def remove(self, vm: VmRecord) -> None:
self.host.call("known_hosts.remove", vm.id)
if Path(vm.socket).parent.exists():
raise AssertionError("known_hosts cleanup must follow VM runtime deletion")
class FakeFirewall: class FakeFirewall:
def __init__(self, host: FakeHost, network: FakeNetwork) -> None: def __init__(self, host: FakeHost, network: FakeNetwork) -> None:
self.host = host self.host = host
@@ -282,12 +293,26 @@ class LifecycleTestCase(unittest.TestCase):
self.store = TrackedStateStore(self.settings) self.store = TrackedStateStore(self.settings)
self.host = FakeHost(self.store) self.host = FakeHost(self.store)
self.images = ImageStore(self.settings) self.images = ImageStore(self.settings)
self.disk_sizes: dict[Path, int] = {}
self.enterContext(patch.object(self.images, "_copy_sparse", side_effect=shutil.copy2))
self.filesystem_resize = self.enterContext(patch.object(
self.images, "_resize_filesystem",
side_effect=lambda disk, capacity: self.disk_sizes.update({disk: capacity * 1024 * 1024}),
))
self.enterContext(patch.object(
self.images, "disk_size_bytes",
side_effect=lambda disk: self.disk_sizes.get(disk, disk.stat().st_size),
))
self.resize_disk = self.enterContext(patch.object(
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
))
self.provision = self.enterContext(patch.object(self.images, "provision_credentials")) self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
self.copy_disk = self.enterContext(patch.object( self.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk, self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
)) ))
self.network = FakeNetwork(self.settings, self.host) self.network = FakeNetwork(self.settings, self.host)
self.process = FakeProcess(self.host) self.process = FakeProcess(self.host)
self.known_hosts = FakeKnownHosts(self.host)
self.clients: dict[str, FakeClient] = {} self.clients: dict[str, FakeClient] = {}
self.service = LifecycleService( self.service = LifecycleService(
self.settings, self.settings,
@@ -295,6 +320,7 @@ class LifecycleTestCase(unittest.TestCase):
self.images, self.images,
self.network, # type: ignore[arg-type] self.network, # type: ignore[arg-type]
self.process, # type: ignore[arg-type] self.process, # type: ignore[arg-type]
known_hosts=self.known_hosts, # type: ignore[arg-type]
client_factory=self.make_client, # type: ignore[arg-type] client_factory=self.make_client, # type: ignore[arg-type]
) )
self.require_root = self.enterContext(patch("uvm.lifecycle.require_root")) self.require_root = self.enterContext(patch("uvm.lifecycle.require_root"))
@@ -307,6 +333,13 @@ class LifecycleTestCase(unittest.TestCase):
self.clients[client.vm_id] = client self.clients[client.vm_id] = client
return client return client
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
vm_id = disk.parent.name
self.host.call("images.resize_disk", vm_id)
if self.process.observe(self.store.get(vm_id)) != "gone":
raise AssertionError("cannot resize a running or ambiguous VM")
self.disk_sizes[disk] = storage_mib * 1024 * 1024
def create_vm(self, **kwargs) -> VmRecord: def create_vm(self, **kwargs) -> VmRecord:
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs)) return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
@@ -510,6 +543,7 @@ class LifecycleTests(LifecycleTestCase):
settings = replace(self.settings, **changed) settings = replace(self.settings, **changed)
service = LifecycleService( service = LifecycleService(
settings, self.store, self.images, self.network, self.process, settings, self.store, self.images, self.network, self.process,
known_hosts=self.known_hosts,
client_factory=self.make_client, client_factory=self.make_client,
) )
self.host.events.clear() self.host.events.clear()
@@ -542,6 +576,7 @@ class LifecycleTests(LifecycleTestCase):
settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway)) settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway))
service = LifecycleService( service = LifecycleService(
settings, self.store, self.images, self.network, self.process, settings, self.store, self.images, self.network, self.process,
known_hosts=self.known_hosts,
client_factory=self.make_client, client_factory=self.make_client,
) )
with ( with (
+2 -1
View File
@@ -591,7 +591,8 @@ class LifecycleInspectionTests(LifecycleTestCase):
def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None: def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None:
settings = replace(self.settings, base=self.settings.base / "not-installed") settings = replace(self.settings, base=self.settings.base / "not-installed")
service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process) service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process,
known_hosts=self.known_hosts)
self.assertEqual(service.list_vms(), []) self.assertEqual(service.list_vms(), [])
self.assertEqual(service.resolve_targets([], all_vms=True), []) self.assertEqual(service.resolve_targets([], all_vms=True), [])
for method in (service.inspect, service.find_for_ssh, service.log_path): for method in (service.inspect, service.find_for_ssh, service.log_path):
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import MIN_STORAGE_MIB
from uvm.domain import PortRule, VmResources, VmSpec
from uvm.errors import ConflictError, FirecrackerError, UvmError, ValidationError
class LifecycleResizeTests(LifecycleTestCase):
def test_creation_provisions_the_default_or_requested_storage(self) -> None:
for capacity in (MIN_STORAGE_MIB, 80 * 1024):
with self.subTest(capacity=capacity):
vm = self.create_vm(storage_mib=capacity)
self.copy_disk.assert_called_with(
self.settings.rootfs_image, Path(vm.disk), storage_mib=capacity,
)
self.filesystem_resize.assert_called_with(Path(vm.disk), capacity)
self.assertEqual(vm.storage_mib, capacity)
self.assertEqual(self.store.get(vm.id).storage_mib, capacity)
self.assertEqual(VmSpec(cpu=1, ram_mib=512).storage_mib, MIN_STORAGE_MIB)
def test_stop_resize_start_preserves_disk_data_credentials_and_network_identity(self) -> None:
vm = self.create_vm(name="database", username="admin", password="same-secret",
ports=(PortRule(22), PortRule(5432)))
sibling = self.create_vm(name="sibling")
sibling_before = self.store.get(sibling.id).to_dict()
original = replace(vm)
disk = Path(vm.disk)
contents = b"guest data\nunchanged password hash\noriginal SSH host keys\n"
disk.write_bytes(contents)
for cpu, ram_mib, storage_mib in ((2, 4096, 50 * 1024), (0.5, 256, MIN_STORAGE_MIB)):
with self.subTest(cpu=cpu, storage=storage_mib):
previous_pid = self.store.get(vm.id).pid
locks = self.store.operation_locks
self.host.events.clear()
resized = self.service.resize(vm.name, cpu=cpu, ram_mib=ram_mib,
storage_mib=storage_mib, timeout=0.5)
self.assertEqual(self.store.operation_locks - locks, 1)
self.assertEqual((resized.cpu, resized.ram_mib, resized.storage_mib), (cpu, ram_mib, storage_mib))
self.assertEqual(resized.status, "running")
self.assertIsNone(resized.pending_resize)
self.assertIsNone(resized.operation)
self.assertNotEqual(resized.pid, previous_pid)
for field in ("id", "name", "guest_ip", "gateway", "network", "bridge", "mac", "tap",
"disk", "username", "password", "ports", "created_at"):
self.assertEqual(getattr(resized, field), getattr(original, field), field)
self.assertEqual(disk.read_bytes(), contents)
actions = self.host.actions(vm.id)
self.assertLess(actions.index("process.gone"), actions.index("images.resize_disk"))
self.assertLess(actions.index("images.resize_disk"), actions.index("process.start"))
stopped = self.host.records["process.terminate", vm.id]
self.assertEqual(stopped.pending_resize, VmResources(cpu, ram_mib, storage_mib))
self.assertEqual(stopped.operation, "resize")
config = self.clients[vm.id].config
self.assertEqual(config["machine-config"], {"vcpu_count": max(1, int(cpu)), "mem_size_mib": ram_mib})
self.assertEqual(config["drives"][0]["path_on_host"], vm.disk)
self.assertEqual(self.store.get(vm.id).to_dict(), resized.to_dict())
self.assertEqual(self.store.get(sibling.id).to_dict(), sibling_before)
self.assertEqual(self.copy_disk.call_count, 2)
self.assertEqual(self.provision.call_count, 2)
self.assertEqual(self.resize_disk.call_count, 2)
def test_cpu_or_memory_only_resize_keeps_storage_and_boots_an_initially_stopped_vm(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.service.stop(vm.id)
first = self.service.resize(vm.guest_ip, cpu=2)
self.assertEqual((first.cpu, first.ram_mib, first.storage_mib), (2, 512, 50 * 1024))
second = self.service.resize(vm.id, ram_mib=4096)
self.assertEqual((second.cpu, second.ram_mib, second.storage_mib), (2, 4096, 50 * 1024))
self.assertEqual(second.status, "running")
self.resize_disk.assert_not_called()
self.assertEqual(self.provision.call_count, 1)
def test_invalid_resize_requests_do_not_change_state_or_stop_the_machine(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
for changes in ({}, {"cpu": 0}, {"cpu": float("nan")}, {"cpu": True},
{"ram_mib": 127}, {"ram_mib": 512.0}, {"storage_mib": 29 * 1024},
{"storage_mib": True}, {"storage_mib": 2**63}, {"cpu": 2, "timeout": -1}):
with self.subTest(changes=changes), self.assertRaises(ValidationError):
self.service.resize(vm.id, **changes)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
self.resize_disk.assert_not_called()
def test_resize_rejects_unsafe_or_unbootable_vm_before_stopping(self) -> None:
for problem in ("unknown", "delete", "legacy", "unprovisioned", "missing", "topology", "kernel"):
with self.subTest(problem=problem):
vm = self.create_vm()
if problem == "unknown":
self.process.unknown.add(vm.id)
elif problem == "delete":
self.persist(vm, operation="delete")
elif problem == "legacy":
self.persist(vm, ports=None)
elif problem == "unprovisioned":
self.persist(vm, disk_ready=False)
elif problem == "missing":
Path(vm.disk).unlink()
elif problem == "topology":
self.persist(vm, bridge="different")
else:
self.settings.kernel_image.unlink()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaises(UvmError):
self.service.resize(vm.id, cpu=2)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.resize_disk.assert_not_called()
if problem == "topology":
self.persist(vm, bridge="uvm0")
if problem == "kernel":
self.settings.kernel_image.write_bytes(b"kernel")
def test_failed_stop_keeps_old_resources_and_persists_retryable_target(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("exit unconfirmed")
with self.assertRaisesRegex(UvmError, "failed to resize.*exit unconfirmed"):
self.service.resize(vm.id, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.ram_mib, retained.storage_mib), (1, 512, MIN_STORAGE_MIB))
self.assertEqual(retained.pending_resize, VmResources(2, 4096, 50 * 1024))
self.assertEqual(retained.pid, vm.pid)
self.resize_disk.assert_not_called()
self.assertEqual(len(self.process.started), 1)
with self.assertRaisesRegex(ConflictError, "resize is pending"):
self.service.start(vm.id)
retried = self.service.resize(vm.id, cpu=2)
self.assertEqual((retried.cpu, retried.ram_mib, retried.storage_mib), (2, 4096, 50 * 1024))
def test_failed_disk_resize_is_stopped_and_start_retries_persisted_target(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.host.failures["images.resize_disk", vm.id] = UvmError("filesystem does not fit")
with self.assertRaisesRegex(UvmError, "filesystem does not fit"):
self.service.resize(vm.id, cpu=2, storage_mib=MIN_STORAGE_MIB)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (1, 50 * 1024))
self.assertEqual(retained.pending_resize, VmResources(2, 512, MIN_STORAGE_MIB))
self.assertIsNone(retained.pid)
self.assertEqual(len(self.process.started), 1)
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
restarted = self.service.start(vm.id)
self.assertEqual((restarted.cpu, restarted.storage_mib), (2, MIN_STORAGE_MIB))
self.assertIsNone(restarted.pending_resize)
self.assertEqual(self.provision.call_count, 1)
def test_failed_boot_retains_applied_resources_for_a_later_start(self) -> None:
vm = self.create_vm()
self.host.failures["client.configure", vm.id] = FirecrackerError("boot failed")
with self.assertRaisesRegex(UvmError, "boot failed"):
self.service.resize(vm.id, cpu=2, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (2, 50 * 1024))
self.assertIsNone(retained.pending_resize)
self.assertTrue(retained.disk_ready)
restarted = self.service.start(vm.id)
self.assertEqual(restarted.status, "running")
self.resize_disk.assert_called_once_with(Path(vm.disk), 50 * 1024)
def test_legacy_disk_gets_minimum_capacity_on_its_first_resource_resize(self) -> None:
vm = self.create_vm()
self.persist(vm, storage_mib=None)
resized = self.service.resize(vm.id, cpu=2)
self.assertEqual(resized.storage_mib, MIN_STORAGE_MIB)
self.resize_disk.assert_called_once_with(Path(vm.disk), MIN_STORAGE_MIB)
def test_retry_can_change_target_after_disk_publication_before_metadata_save(self) -> None:
for recorded_gib, published_gib in ((30, 50), (50, 30)):
with self.subTest(recorded=recorded_gib, published=published_gib):
vm = self.create_vm(storage_mib=recorded_gib * 1024)
self.service.stop(vm.id)
self.persist(vm, operation="resize", pending_resize=VmResources(2, 4096, published_gib * 1024))
self.disk_sizes[Path(vm.disk)] = published_gib * 1024**3
self.resize_disk.reset_mock()
result = self.service.resize(vm.id, storage_mib=recorded_gib * 1024)
self.resize_disk.assert_called_once_with(Path(vm.disk), recorded_gib * 1024)
self.assertEqual(result.storage_mib * 1024 * 1024, self.disk_sizes[Path(vm.disk)])
self.assertIsNone(result.pending_resize)
def test_interrupted_resize_can_be_stopped_then_resumed_or_deleted(self) -> None:
vm = self.create_vm()
self.host.failures["images.resize_disk", vm.id] = KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
self.service.resize(vm.id, storage_mib=50 * 1024)
stopped = self.service.stop(vm.id)
self.assertEqual(stopped.pending_resize.storage_mib, 50 * 1024)
self.assertEqual(stopped.operation, "resize")
self.service.destroy(vm.id)
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(Path(vm.disk).exists())
+80 -2
View File
@@ -11,7 +11,7 @@ from unittest.mock import patch
from uvm.cli import build_parser, main from uvm.cli import build_parser, main
from uvm.config import Settings from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmSpec from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm, OperationResult from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server from uvm.server import _http_status_for, create_api, run_server
@@ -22,6 +22,7 @@ def fake_application(*, api_token: str | None = None):
app_name="uvm", app_name="uvm",
default_vcpu=1, default_vcpu=1,
default_ram_mib=512, default_ram_mib=512,
default_storage_mib=30 * 1024,
default_ssh_user="root", default_ssh_user="root",
api_token=api_token, api_token=api_token,
api_tls_cert=None, api_tls_cert=None,
@@ -145,6 +146,7 @@ if FASTAPI_AVAILABLE:
class FakeLifecycle: class FakeLifecycle:
def __init__(self) -> None: def __init__(self) -> None:
self.last_spec: VmSpec | None = None self.last_spec: VmSpec | None = None
self.last_resize: dict | None = None
self.observed_status = "running" self.observed_status = "running"
self.policy_status = "enforced" self.policy_status = "enforced"
self.vm = VmRecord( self.vm = VmRecord(
@@ -152,6 +154,7 @@ class FakeLifecycle:
name="test-vm", name="test-vm",
cpu=1, cpu=1,
ram_mib=512, ram_mib=512,
storage_mib=30 * 1024,
guest_ip="10.42.0.2", guest_ip="10.42.0.2",
gateway="10.42.0.1", gateway="10.42.0.1",
tap="uvm-test", tap="uvm-test",
@@ -187,6 +190,7 @@ class FakeLifecycle:
self.last_spec = spec self.last_spec = spec
self.vm.cpu = spec.cpu self.vm.cpu = spec.cpu
self.vm.ram_mib = spec.ram_mib self.vm.ram_mib = spec.ram_mib
self.vm.storage_mib = spec.storage_mib
if spec.guest_ip is not None: if spec.guest_ip is not None:
self.vm.guest_ip = str(spec.guest_ip) self.vm.guest_ip = str(spec.guest_ip)
self.vm.username = spec.username self.vm.username = spec.username
@@ -222,6 +226,16 @@ class FakeLifecycle:
self.policy_status = "inactive" self.policy_status = "inactive"
return self.vm return self.vm
def resize(self, identifier: str, *, cpu=None, ram_mib=None, storage_mib=None, timeout=None) -> VmRecord:
self.inspect(identifier)
self.last_resize = dict(cpu=cpu, ram_mib=ram_mib, storage_mib=storage_mib, timeout=timeout)
for key, value in (("cpu", cpu), ("ram_mib", ram_mib), ("storage_mib", storage_mib)):
if value is not None:
setattr(self.vm, key, value)
self.vm.pending_resize = None
self.vm.operation = None
return self.start(identifier)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord: def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier) self.inspect(identifier)
return self.vm return self.vm
@@ -319,6 +333,7 @@ class ApiRouterTests(unittest.TestCase):
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}), ("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
("POST", "/vms/test-vm/start", {}), ("POST", "/vms/test-vm/start", {}),
("POST", "/vms/test-vm/restart", {}), ("POST", "/vms/test-vm/restart", {}),
("POST", "/vms/test-vm/resize", {"cpu": 2}),
("POST", "/vms/test-vm/stop", {}), ("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}), ("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}), ("POST", "/vms/test-vm/ports", {}),
@@ -376,6 +391,68 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(self.lifecycle.last_spec.password, "root") self.assertEqual(self.lifecycle.last_spec.password, "root")
self.assertIsNone(self.lifecycle.last_spec.name) self.assertIsNone(self.lifecycle.last_spec.name)
self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),)) self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),))
self.assertEqual(self.lifecycle.last_spec.storage_mib, 30 * 1024)
self.assertEqual(response.json()["storage_mib"], 30 * 1024)
def test_create_accepts_custom_storage_and_rejects_capacities_below_minimum(self) -> None:
for capacity, expected in ((50, 50 * 1024), ("50G", 50 * 1024), ("1T", 1024 * 1024)):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(self.lifecycle.last_spec.storage_mib, expected)
self.assertEqual(response.json()["storage_mib"], expected)
self.lifecycle.last_spec = None
for capacity in ("29G", 29, "30M", "bad", True, None, [], {}):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_spec)
def test_resize_accepts_partial_resources_and_returns_the_same_vm_identity(self) -> None:
before = self.lifecycle.vm.to_dict()
response = self.client.post("/vms/test-vm/resize?timeout=0.5", headers=self.headers,
json={"cpu": 2, "ram": "4G", "storage": "50G"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.lifecycle.last_resize, {"cpu": 2, "ram_mib": 4096, "storage_mib": 51200, "timeout": 0.5})
for field in ("id", "name", "guest_ip", "gateway", "mac", "username"):
self.assertEqual(response.json()[field], before[field])
self.assertEqual(response.json()["status"], "running")
self.assertEqual(response.json()["policy_status"], "enforced")
self.assertEqual(response.json()["storage_mib"], 51200)
self.assertIsNone(response.json()["pending_resize"])
self.assertNotIn("password", response.json())
self.assertEqual(self.lifecycle.vm.password, before["password"])
partial = self.client.post("/vms/10.42.0.2/resize", headers=self.headers, json={"ram": "2G"})
self.assertEqual(partial.status_code, 200)
self.assertEqual(self.lifecycle.last_resize, {"cpu": None, "ram_mib": 2048, "storage_mib": None, "timeout": None})
self.assertEqual(partial.json()["storage_mib"], 51200)
def test_resize_rejects_invalid_or_non_resource_edits_before_lifecycle_calls(self) -> None:
for payload in ({}, {"cpu": None}, {"cpu": 0}, {"cpu": True}, {"ram": "127M"},
{"storage": "29G"}, {"storage": False}, {"storage": []},
{"cpu": 2, "username": "new-user"}, {"cpu": 2, "password": "new-secret"},
{"cpu": 2, "guest_ip": "10.42.0.9"}):
with self.subTest(payload=payload):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_resize)
for timeout in ("-1", "nan", "inf"):
response = self.client.post(f"/vms/vm-test/resize?timeout={timeout}", headers=self.headers, json={"cpu": 2})
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.lifecycle.last_resize)
def test_resize_errors_and_pending_resources_are_exposed_without_credentials(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("unknown process"), 409),
(UvmError("filesystem does not fit"), 500)):
with patch.object(self.lifecycle, "resize", side_effect=error):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json={"storage": "30G"})
self.assertEqual(response.status_code, expected)
self.assertEqual(response.json(), {"error": {"message": str(error)}})
target = VmResources(2, 4096, 51200)
self.lifecycle.vm.pending_resize = target
response = self.client.get("/vms/vm-test", headers=self.headers)
self.assertEqual(response.json()["pending_resize"], target.to_dict())
self.assertNotIn("guest-secret", response.text)
def test_create_accepts_valid_names(self) -> None: def test_create_accepts_valid_names(self) -> None:
for name in (None, "a", "web-1", "a" * 63): for name in (None, "a", "web-1", "a" * 63):
@@ -454,7 +531,7 @@ class ApiRouterTests(unittest.TestCase):
def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None: def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None:
expected_fields = { expected_fields = {
"id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username", "id", "name", "cpu", "ram_mib", "storage_mib", "pending_resize", "guest_ip", "gateway", "mac", "username",
"status", "observed_status", "ports", "policy_status", "operation", "pid", "status", "observed_status", "ports", "policy_status", "operation", "pid",
"created_at", "updated_at", "last_error", "created_at", "updated_at", "last_error",
} }
@@ -683,6 +760,7 @@ class ApiRouterTests(unittest.TestCase):
("/vms/{identifier}/logs", "get"), ("/vms/{identifier}/logs", "get"),
("/vms/{vm_id}/start", "post"), ("/vms/{vm_id}/start", "post"),
("/vms/{vm_id}/restart", "post"), ("/vms/{vm_id}/restart", "post"),
("/vms/{vm_id}/resize", "post"),
("/vms/{vm_id}/stop", "post"), ("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"), ("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"), ("/vms/{vm_id}/ports", "post"),
+26 -11
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from uvm.config import Settings from uvm.config import Settings
from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmSpec, new_vm_id from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmResources, VmSpec, new_vm_id
from uvm.errors import ConflictError, NotFoundError, StateError from uvm.errors import ConflictError, NotFoundError, StateError
from uvm.state import StateStore from uvm.state import StateStore
@@ -61,13 +61,13 @@ class StateStoreTests(unittest.TestCase):
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None: def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
self.base.mkdir(parents=True) self.base.mkdir(parents=True)
for version in (None, 1): for version in (None, 1, 2):
with self.subTest(version=version): with self.subTest(version=version):
raw_vm = make_vm().to_dict() raw_vm = make_vm().to_dict()
for field in ( for field in (
"disk", "username", "password", "updated_at", "name", "ports", "disk", "username", "password", "updated_at", "name", "ports",
"disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id", "disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id",
"spawn_pending", "network", "bridge", "spawn_pending", "network", "bridge", "storage_mib", "pending_resize",
): ):
raw_vm.pop(field) raw_vm.pop(field)
legacy = {"vms": {"vm-test": raw_vm}} legacy = {"vms": {"vm-test": raw_vm}}
@@ -77,7 +77,7 @@ class StateStoreTests(unittest.TestCase):
loaded = self.store.load() loaded = self.store.load()
vm = loaded.vms["vm-test"] vm = loaded.vms["vm-test"]
self.assertEqual(loaded.schema_version, 2) self.assertEqual(loaded.schema_version, 3)
self.assertEqual(vm.disk, "") self.assertEqual(vm.disk, "")
self.assertEqual(vm.username, "root") self.assertEqual(vm.username, "root")
self.assertIsNone(vm.password) self.assertIsNone(vm.password)
@@ -85,6 +85,8 @@ class StateStoreTests(unittest.TestCase):
self.assertIsNone(vm.ports) self.assertIsNone(vm.ports)
self.assertIsNone(vm.network) self.assertIsNone(vm.network)
self.assertIsNone(vm.bridge) self.assertIsNone(vm.bridge)
self.assertIsNone(vm.storage_mib)
self.assertIsNone(vm.pending_resize)
self.assertFalse(vm.disk_ready) self.assertFalse(vm.disk_ready)
self.assertTrue(vm.runtime_owned) self.assertTrue(vm.runtime_owned)
self.assertFalse(vm.tap_owned) self.assertFalse(vm.tap_owned)
@@ -99,7 +101,7 @@ class StateStoreTests(unittest.TestCase):
state.vms["vm-test"].status = "stopped" state.vms["vm-test"].status = "stopped"
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8")) persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(persisted["schema_version"], 2) self.assertEqual(persisted["schema_version"], 3)
self.assertEqual(persisted["next_mac_index"], 2) self.assertEqual(persisted["next_mac_index"], 2)
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped") self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
self.assertIsNone(persisted["vms"]["vm-test"]["ports"]) self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
@@ -114,16 +116,17 @@ class StateStoreTests(unittest.TestCase):
runtime_owned=True, tap_owned=True, operation="configuring", runtime_owned=True, tap_owned=True, operation="configuring",
boot_id="host-boot-id", spawn_pending=True, pid=123, boot_id="host-boot-id", spawn_pending=True, pid=123,
process_start_time="456789", network="10.42.0.0/24", bridge="uvm0", process_start_time="456789", network="10.42.0.0/24", bridge="uvm0",
last_error="interrupted boot", last_error="interrupted boot", storage_mib=30 * 1024,
pending_resize=VmResources(cpu=2, ram_mib=4096, storage_mib=50 * 1024),
) )
with self.store.transaction() as state: with self.store.transaction() as state:
state.vms[vm.id] = vm state.vms[vm.id] = vm
loaded = self.store.load() loaded = self.store.load()
self.assertEqual(loaded.schema_version, 2) self.assertEqual(loaded.schema_version, 3)
self.assertEqual(loaded.vms[vm.id], vm) self.assertEqual(loaded.vms[vm.id], vm)
raw = json.loads(self.settings.state_path.read_text(encoding="utf-8")) raw = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(raw["schema_version"], 2) self.assertEqual(raw["schema_version"], 3)
self.assertEqual( self.assertEqual(
raw["vms"][vm.id]["ports"], raw["vms"][vm.id]["ports"],
None if ports is None else [port.to_dict() for port in ports], None if ports is None else [port.to_dict() for port in ports],
@@ -151,7 +154,7 @@ class StateStoreTests(unittest.TestCase):
def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None: def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None:
self.base.mkdir(parents=True) self.base.mkdir(parents=True)
for version in (0, 1, 2): for version in (0, 1, 2, 3):
with self.subTest(version=version): with self.subTest(version=version):
raw = { raw = {
"schema_version": version, "schema_version": version,
@@ -302,11 +305,23 @@ class DomainStateTests(unittest.TestCase):
self.assertIs(vm.spawn_pending, operation == "spawning") self.assertIs(vm.spawn_pending, operation == "spawning")
self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm) self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm)
def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None: def test_new_state_uses_schema_three_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 2) self.assertEqual(State().schema_version, 3)
with self.assertRaisesRegex(StateError, "newer than"): with self.assertRaisesRegex(StateError, "newer than"):
State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}}) State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}})
def test_storage_and_pending_resize_reject_malformed_persisted_values(self) -> None:
raw = make_vm().to_dict()
for value in (0, 1024, True, "30720", 30720.0, -1, {}, [], 2**63):
with self.subTest(storage=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "storage_mib": value})
valid = {"cpu": 2, "ram_mib": 4096, "storage_mib": 50 * 1024}
for value in ({}, [], "resize", {**valid, "cpu": True}, {**valid, "cpu": float("nan")},
{**valid, "ram_mib": 127}, {**valid, "storage_mib": 29 * 1024},
{**valid, "password": "not-a-resource"}):
with self.subTest(pending=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "pending_resize": value})
def test_generated_ids_have_the_reserved_uuid_format(self) -> None: def test_generated_ids_have_the_reserved_uuid_format(self) -> None:
first, second = new_vm_id(), new_vm_id() first, second = new_vm_id(), new_vm_id()
self.assertNotEqual(first, second) self.assertNotEqual(first, second)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import hashlib
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import MIN_STORAGE_MIB, Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import ImageStore
from uvm.system import CommandResult, CommandRunner
MIB = 1024 * 1024
class DiskResizeTests(unittest.TestCase):
def test_grow_extends_the_file_first_and_shrink_truncates_only_after_verified_resize(self) -> None:
for original_mib, target_mib in ((8, 16), (32, 16)):
with self.subTest(original=original_mib), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(original_mib * MIB)
sizes = []
def run(command, **_options):
sizes.append((command[0], disk.stat().st_size))
return CommandResult(args=tuple(map(str, command)), returncode=1 if command[0] == "e2fsck" else 0)
runner = Mock(spec=CommandRunner)
runner.run.side_effect = run
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=target_mib * MIB):
store._resize_filesystem(disk, target_mib)
self.assertEqual(sizes, [("e2fsck", original_mib * MIB), ("resize2fs", max(original_mib, target_mib) * MIB)])
self.assertEqual(disk.stat().st_size, target_mib * MIB)
def test_failed_or_interrupted_staging_never_changes_the_original_disk(self) -> None:
for failure in (UvmError("filesystem cannot fit"), KeyboardInterrupt()):
with self.subTest(failure=type(failure)), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"data, password hash, and SSH host keys")
disk.chmod(0o600)
before = (disk.stat().st_ino, disk.read_bytes())
store = ImageStore(Settings(base=Path(temporary)), Mock(spec=CommandRunner))
def fail(candidate, _capacity):
self.assertNotEqual(candidate, disk)
candidate.write_bytes(b"partly resized staging image")
raise failure
with (
patch.object(store, "_copy_sparse", side_effect=shutil.copyfile),
patch.object(store, "_resize_filesystem", side_effect=fail),
self.assertRaises(type(failure)),
):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
self.assertEqual((disk.stat().st_ino, disk.read_bytes()), before)
self.assertEqual(list(disk.parent.iterdir()), [disk])
def test_filesystem_check_errors_prevent_resize_and_truncation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"original")
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("e2fsck",), 4, stderr="unrepaired errors")
store = ImageStore(Settings(), runner)
with self.assertRaisesRegex(UvmError, "filesystem check failed"):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.read_bytes(), b"original")
self.assertEqual(runner.run.call_count, 1)
def test_geometry_mismatch_prevents_truncating_a_larger_filesystem(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(32 * MIB)
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("tool",), 0)
store = ImageStore(Settings(), runner)
with (
patch.object(store, "_filesystem_size", return_value=32 * MIB),
self.assertRaisesRegex(UvmError, "does not match"),
):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.stat().st_size, 32 * MIB)
def test_retry_after_disk_publication_recognizes_the_completed_capacity(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(MIN_STORAGE_MIB * MIB)
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=MIN_STORAGE_MIB * MIB):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
runner.run.assert_not_called()
def test_resize_rejects_subminimum_capacity_and_shared_or_linked_disks(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"template")
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = Path(temporary) / "linked.ext4"
linked.hardlink_to(disk)
symlink = Path(temporary) / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
with self.assertRaises(ValidationError):
store.resize_vm_disk(disk, MIN_STORAGE_MIB - 1)
for target in (settings.rootfs_image, linked, symlink):
with self.subTest(target=target), self.assertRaisesRegex(UvmError, "unsafe"):
store.resize_vm_disk(target, MIN_STORAGE_MIB)
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "resize2fs", "dumpe2fs", "debugfs", "cp")),
"e2fsprogs and coreutils are required for offline disk checks")
class OfflineExt4ResizeTests(unittest.TestCase):
def test_real_sparse_disk_grows_and_shrinks_with_guest_data_and_keys_intact(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "template.ext4"
root = base / "guest"
root.mkdir()
contents = {
"data": "retained application data\n",
"shadow": "root:$6$unchanged-password-hash:1:0:99999:7:::\n",
"host-key": "unchanged-private-host-key\n",
}
for name, value in contents.items():
(root / name).write_text(value, encoding="utf-8")
with source.open("wb") as image:
image.truncate(32 * MIB)
runner = CommandRunner(emit=None)
runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-N", "128", "-d", root, source), capture=True)
template_digest = hashlib.sha256(source.read_bytes()).hexdigest()
store = ImageStore(Settings(base=base), runner)
disk = store.create_vm_disk(source, base / "rootfs.ext4")
for capacity in (MIN_STORAGE_MIB, 31 * 1024, MIN_STORAGE_MIB):
store.resize_vm_disk(disk, capacity)
self.assertEqual(disk.stat().st_size, capacity * MIB)
self.assertEqual(store._filesystem_size(disk), capacity * MIB)
self.assertEqual(disk.stat().st_mode & 0o777, 0o600)
self.assertLess(disk.stat().st_blocks * 512, disk.stat().st_size // 2)
runner.run(("e2fsck", "-f", "-n", disk), capture=True)
for name, value in contents.items():
result = runner.run(("debugfs", "-R", f"cat /{name}", disk), capture=True)
self.assertEqual(result.stdout, value)
self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), template_digest)
+16 -1
View File
@@ -5,11 +5,26 @@ import unittest
from uvm.domain import PortRule from uvm.domain import PortRule
from uvm.errors import ValidationError from uvm.errors import ValidationError
from uvm.validation import ( from uvm.validation import (
parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_timeout, parse_username, parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_storage, parse_timeout, parse_username,
) )
class ValidationTests(unittest.TestCase): class ValidationTests(unittest.TestCase):
def test_storage_defaults_to_gib_and_supports_larger_and_fractional_units(self) -> None:
for value, expected in (
("30", 30720), ("30G", 30720), ("30GB", 30720), ("30GiB", 30720),
(" 50 g ", 51200), ("30720M", 30720), ("30.5G", 31232),
("1T", 1048576), ("0.5TiB", 524288), ("30.0001G", 30721),
):
with self.subTest(value=value):
self.assertEqual(parse_storage(value), expected)
def test_storage_rejects_subminimum_invalid_and_unrepresentable_capacity(self) -> None:
for value in ("0", "29G", "29.999999G", "30719M", "30M", "", "-30G", "nan",
"inf", "True", "30G garbage", "100000000000000000000000T"):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_storage(value)
def test_parse_ram_uses_mib_by_default(self) -> None: def test_parse_ram_uses_mib_by_default(self) -> None:
self.assertEqual(parse_ram("512"), 512) self.assertEqual(parse_ram("512"), 512)
self.assertEqual(parse_ram("1G"), 1024) self.assertEqual(parse_ram("1G"), 1024)
+19
View File
@@ -40,6 +40,7 @@ class VmCreateRequest(BaseModel):
cpu: float = Field(default=1, gt=0) cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512" ram: str | int | float = "512"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None guest_ip: IPv4Address | None = None
username: str = "root" username: str = "root"
password: SecretStr = SecretStr("root") password: SecretStr = SecretStr("root")
@@ -48,11 +49,27 @@ class VmCreateRequest(BaseModel):
no_default_ports: bool = Field(default=False, strict=True) no_default_ports: bool = Field(default=False, strict=True)
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel): class VmResponse(BaseModel):
id: str id: str
name: str | None name: str | None
cpu: float cpu: float
ram_mib: int ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str guest_ip: str
gateway: str gateway: str
mac: str mac: str
@@ -124,6 +141,8 @@ def vm_response(
name=vm.name, name=vm.name,
cpu=vm.cpu, cpu=vm.cpu,
ram_mib=vm.ram_mib, ram_mib=vm.ram_mib,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip, guest_ip=vm.guest_ip,
gateway=vm.gateway, gateway=vm.gateway,
mac=vm.mac, mac=vm.mac,
+3 -1
View File
@@ -9,6 +9,7 @@ from .config import Settings
from .firecracker.process import FirecrackerProcessManager from .firecracker.process import FirecrackerProcessManager
from .images import ImageStore from .images import ImageStore
from .install import Installer from .install import Installer
from .known_hosts import KnownHostsManager
from .lifecycle import LifecycleService from .lifecycle import LifecycleService
from .network import NetworkManager from .network import NetworkManager
from .state import StateStore from .state import StateStore
@@ -32,7 +33,8 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
images = ImageStore(settings, runner) images = ImageStore(settings, runner)
network = NetworkManager(settings, runner) network = NetworkManager(settings, runner)
process = FirecrackerProcessManager(settings) process = FirecrackerProcessManager(settings)
lifecycle = LifecycleService(settings, state_store, images, network, process) known_hosts = KnownHostsManager(settings)
lifecycle = LifecycleService(settings, state_store, images, network, process, known_hosts=known_hosts)
installer = Installer(settings, runner, state_store, emit=emit) installer = Installer(settings, runner, state_store, emit=emit)
return Application( return Application(
settings=settings, settings=settings,
+40 -2
View File
@@ -22,6 +22,7 @@ from .validation import (
parse_password, parse_password,
parse_ports, parse_ports,
parse_ram, parse_ram,
parse_storage,
parse_timeout, parse_timeout,
parse_username, parse_username,
) )
@@ -97,6 +98,10 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=str(application.settings.default_ram_mib), default=str(application.settings.default_ram_mib),
help="RAM, e.g. 512, 1G, 512M", help="RAM, e.g. 512, 1G, 512M",
) )
create.add_argument(
"--storage", default=f"{application.settings.default_storage_mib}M",
help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)",
)
create.add_argument( create.add_argument(
"--guest-ip", "--guest-ip",
"--host-ip", "--host-ip",
@@ -130,6 +135,14 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
) )
create.add_argument("--json", action="store_true") create.add_argument("--json", action="store_true")
resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM")
resize.add_argument("vm", help="VM ID, name, or guest IP")
resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained")
resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G")
resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB")
resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)")
resize.add_argument("--json", action="store_true")
listing = subcommands.add_parser("list", help="list VMs") listing = subcommands.add_parser("list", help="list VMs")
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)") listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
output = listing.add_mutually_exclusive_group() output = listing.add_mutually_exclusive_group()
@@ -293,6 +306,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
VmSpec( VmSpec(
cpu=parse_cpu(args.cpu), cpu=parse_cpu(args.cpu),
ram_mib=parse_ram(args.ram), ram_mib=parse_ram(args.ram),
storage_mib=parse_storage(args.storage),
guest_ip=guest_ip, guest_ip=guest_ip,
username=parse_username(args.username), username=parse_username(args.username),
password=parse_password(password), password=parse_password(password),
@@ -321,6 +335,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" Name: {vm.name}") print(f" Name: {vm.name}")
print(f" IP: {vm.guest_ip}") print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB") print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" CPU: {vm.cpu}") print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}") print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}") print(f" Username: {vm.username}")
@@ -329,6 +344,27 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" SSH: ssh {vm.username}@{vm.guest_ip}") print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
return 0 return 0
if args.command == "resize":
if args.cpu is None and args.ram is None and args.storage is None:
raise ValidationError("resize requires at least one of --cpu, --ram, or --storage")
vm = application.lifecycle.resize(
args.vm,
cpu=parse_cpu(args.cpu) if args.cpu is not None else None,
ram_mib=parse_ram(args.ram) if args.ram is not None else None,
storage_mib=parse_storage(args.storage) if args.storage is not None else None,
timeout=parse_timeout(args.timeout) if args.timeout is not None else None,
)
if args.json:
_print_json(_vm_output(vm, policy_status="enforced"))
else:
print(f"VM resized and started: {vm.id}")
print(f" CPU: {vm.cpu}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" IP: {vm.guest_ip}")
print(f" Username: {vm.username}")
return 0
if args.command == "list": if args.command == "list":
vms = [ vms = [
listed for listed in application.lifecycle.list_vms() listed for listed in application.lifecycle.list_vms()
@@ -349,14 +385,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
return 0 return 0
print( print(
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} " f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID" f"{'RAM':<8} {'DISK(MiB)':<12} {'STATUS':<12} {'POLICY':<12} PID"
) )
for listed in vms: for listed in vms:
vm = listed.vm vm = listed.vm
pid = vm.pid if vm.pid is not None else "" pid = vm.pid if vm.pid is not None else ""
print( print(
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} " f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}" f"{vm.ram_mib:<8} {vm.storage_mib or '-':<12} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
) )
return 0 return 0
@@ -543,6 +579,8 @@ def _vm_output(
"name": vm.name, "name": vm.name,
"cpu": vm.cpu, "cpu": vm.cpu,
"ram_mib": vm.ram_mib, "ram_mib": vm.ram_mib,
"storage_mib": vm.storage_mib,
"pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None,
"guest_ip": vm.guest_ip, "guest_ip": vm.guest_ip,
"gateway": vm.gateway, "gateway": vm.gateway,
"tap": vm.tap, "tap": vm.tap,
+37 -13
View File
@@ -3,21 +3,16 @@
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass, field
from ipaddress import IPv4Address, IPv4Network from ipaddress import IPv4Address, IPv4Network
from pathlib import Path from pathlib import Path
from .errors import ConfigurationError from .errors import ConfigurationError
DEFAULT_KERNEL_URL = ( DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/" MIN_STORAGE_MIB = 30 * 1024
"x86_64/kernels/vmlinux.bin" MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
)
DEFAULT_ROOTFS_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/rootfs/bionic.rootfs.ext4"
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -29,12 +24,14 @@ class Settings:
network: IPv4Network = IPv4Network("10.42.0.0/24") network: IPv4Network = IPv4Network("10.42.0.0/24")
gateway: IPv4Address = IPv4Address("10.42.0.1") gateway: IPv4Address = IPv4Address("10.42.0.1")
bridge: str = "uvm0" bridge: str = "uvm0"
architecture: str = field(default_factory=lambda: os.uname().machine)
firecracker_version: str = "v1.16.1" firecracker_version: str = "v1.16.1"
default_ram_mib: int = 512 default_ram_mib: int = 512
default_storage_mib: int = MIN_STORAGE_MIB
default_vcpu: float = 1 default_vcpu: float = 1
default_ssh_user: str = "root" default_ssh_user: str = "root"
kernel_url: str = DEFAULT_KERNEL_URL kernel_url: str = ""
rootfs_url: str = DEFAULT_ROOTFS_URL rootfs_url: str = ""
firecracker_sha256: str | None = None firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None kernel_sha256: str | None = None
@@ -43,11 +40,25 @@ class Settings:
api_token: str | None = None api_token: str | None = None
api_tls_cert: Path | None = None api_tls_cert: Path | None = None
api_tls_key: Path | None = None api_tls_key: Path | None = None
known_hosts_files: tuple[Path, ...] | None = None
api_timeout_s: float = 2.0 api_timeout_s: float = 2.0
api_socket_timeout_s: float = 5.0 api_socket_timeout_s: float = 5.0
terminate_timeout_s: float = 5.0 terminate_timeout_s: float = 5.0
def __post_init__(self) -> None: def __post_init__(self) -> None:
architecture = self.architecture.lower()
architecture = {"amd64": "x86_64", "arm64": "aarch64"}.get(architecture, architecture)
if architecture not in ("x86_64", "aarch64"):
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
object.__setattr__(self, "architecture", architecture)
# Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url:
object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin")
if not self.rootfs_url:
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
if self.gateway not in self.network: if self.gateway not in self.network:
raise ConfigurationError( raise ConfigurationError(
f"gateway {self.gateway} is outside configured network {self.network}" f"gateway {self.gateway} is outside configured network {self.network}"
@@ -56,6 +67,8 @@ class Settings:
raise ConfigurationError("gateway must be a usable host address") raise ConfigurationError("gateway must be a usable host address")
if len(self.bridge) > 15: if len(self.bridge) > 15:
raise ConfigurationError("bridge name must be 15 characters or fewer") raise ConfigurationError("bridge name must be 15 characters or fewer")
if self.known_hosts_files is not None and any(not path.is_absolute() for path in self.known_hosts_files):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must contain absolute file paths")
if self.api_token is not None: if self.api_token is not None:
if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token): if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
raise ConfigurationError( raise ConfigurationError(
@@ -124,8 +137,8 @@ class Settings:
network=network, network=network,
gateway=gateway, gateway=gateway,
bridge=os.environ.get("UVM_BRIDGE", "uvm0"), bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL), kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL), rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"), firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
firecracker_binary_sha256=_optional_environment_value( firecracker_binary_sha256=_optional_environment_value(
"UVM_FIRECRACKER_BINARY_SHA256" "UVM_FIRECRACKER_BINARY_SHA256"
@@ -138,6 +151,7 @@ class Settings:
api_token=_optional_environment_value("UVM_API_TOKEN"), api_token=_optional_environment_value("UVM_API_TOKEN"),
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"), api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"), api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
known_hosts_files=_known_hosts_environment_paths(),
) )
@@ -151,6 +165,16 @@ def _optional_environment_path(name: str) -> Path | None:
return Path(value).expanduser() if value else None return Path(value).expanduser() if value else None
def _known_hosts_environment_paths() -> tuple[Path, ...] | None:
value = _optional_environment_value("UVM_KNOWN_HOSTS_FILES")
if value is None:
return None
parts = value.split(os.pathsep)
if any(not part for part in parts):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must be a colon-separated list of file paths")
return tuple(Path(part).expanduser() for part in parts)
def _boolean_environment_value(name: str, *, default: bool) -> bool: def _boolean_environment_value(name: str, *, default: bool) -> bool:
value = os.environ.get(name) value = os.environ.get(name)
if value is None: if value is None:
+4 -3
View File
@@ -469,7 +469,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; } .ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; }
.ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; } .ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; }
.ssh-command .icon-button { width: 27px; height: 27px; } .ssh-command .icon-button { width: 27px; height: 27px; }
.ports-panel, .log-panel { margin-top: 15px; } .ports-panel, .log-panel, .resources-panel { margin-top: 15px; }
.storage-field { margin-top: 18px; }
.port-editor { padding-top: 5px; } .port-editor { padding-top: 5px; }
.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } .log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; } .detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; }
@@ -878,8 +879,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.field input { font-size: 12px; } .field input { font-size: 12px; }
.detail-delete-row { align-items: flex-start; } .detail-delete-row { align-items: flex-start; }
.detail-delete-row > span { max-width: 170px; } .detail-delete-row > span { max-width: 170px; }
.ports-panel > .section-heading, .log-panel > .section-heading { align-items: flex-start; } .ports-panel > .section-heading, .log-panel > .section-heading, .resources-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child { flex: 1; } .ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child, .resources-panel > .section-heading > div:first-child { flex: 1; }
.auth-screen { padding: 20px; } .auth-screen { padding: 20px; }
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; } .auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
.auth-story { min-height: 240px; padding: 26px; border-right: 0; } .auth-story { min-height: 240px; padding: 26px; border-right: 0; }
+11
View File
@@ -171,6 +171,16 @@
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div> <div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div> <div id="detail-error" class="notice notice-error" hidden></div>
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div> <div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel resources-panel">
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
<p id="resize-note" class="panel-footnote">Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.</p>
<form id="resize-form" hidden>
<div class="form-grid"><label class="field" for="resize-cpu"><span>CPU capacity</span><input id="resize-cpu" type="number" min="0.01" step="any" placeholder="Keep current CPU" /></label><label class="field" for="resize-ram"><span>Memory</span><input id="resize-ram" type="text" placeholder="Keep current memory" /><small>At least 128 MiB. For example, 4G.</small></label></div>
<label class="field storage-field" for="resize-storage"><span>Private storage</span><input id="resize-storage" type="text" placeholder="Keep current size (at least 30G)" /><small>Minimum 30 GiB. A smaller disk must still fit the existing filesystem data.</small></label>
<p id="resize-error" class="form-error" role="alert"></p>
<div class="button-row"><button id="resize-submit" class="button button-primary button-small" type="submit">Resize &amp; restart</button><button class="button button-quiet button-small" type="button" data-action="cancel-resize">Cancel</button></div>
</form>
</article>
<article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article> <article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article>
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article> <article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article> <article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
@@ -182,6 +192,7 @@
<div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div> <div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div>
<form id="create-form" class="create-layout"> <form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section> <div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section> <section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div> <section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside> <aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
+98 -3
View File
@@ -27,6 +27,7 @@
detailId: null, detailId: null,
detail: null, detail: null,
portEditing: false, portEditing: false,
resizeEditing: false,
selected: new Set(), selected: new Set(),
filter: 'all', filter: 'all',
query: '', query: '',
@@ -86,6 +87,10 @@
return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`; return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`;
} }
function formatStorage(value) {
return value == null ? 'Not recorded' : formatRam(value);
}
function timestamp(value) { function timestamp(value) {
if (value == null || value === '') return NaN; if (value == null || value === '') return NaN;
if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) { if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) {
@@ -396,7 +401,7 @@
return `<tr class="${state.selected.has(vm.id) ? 'selected' : ''}"> return `<tr class="${state.selected.has(vm.id) ? 'selected' : ''}">
<td class="check-cell"><input type="checkbox" data-select="${id}" aria-label="Select ${esc(machineName(vm))}" ${state.selected.has(vm.id) ? 'checked' : ''} /></td> <td class="check-cell"><input type="checkbox" data-select="${id}" aria-label="Select ${esc(machineName(vm))}" ${state.selected.has(vm.id) ? 'checked' : ''} /></td>
<td><div class="machine-name"><span class="round-icon">${icon('server')}</span><div><a href="${esc(vmHref(vm.id))}" title="${esc(machineName(vm))}">${esc(machineName(vm))}</a><small>${esc(vm.guest_ip || vm.id)}</small></div></div></td> <td><div class="machine-name"><span class="round-icon">${icon('server')}</span><div><a href="${esc(vmHref(vm.id))}" title="${esc(machineName(vm))}">${esc(machineName(vm))}</a><small>${esc(vm.guest_ip || vm.id)}</small></div></div></td>
<td>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))}</small></td> <td>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))} RAM</small><small>${esc(formatStorage(vm.storage_mib))} storage</small></td>
<td><div class="table-ports">${portsMarkup(vm.ports, 2)}</div></td><td>${badge(vm.policy_status || 'unknown')}</td> <td><div class="table-ports">${portsMarkup(vm.ports, 2)}</div></td><td>${badge(vm.policy_status || 'unknown')}</td>
<td><div class="row-controls"><button class="icon-button" type="button" data-action="vm-action" data-operation="${running ? 'stop' : 'start'}" data-id="${id}" aria-label="${running ? 'Stop' : 'Start'} ${esc(machineName(vm))}" title="${running ? 'Stop' : 'Start'} machine" ${busy ? 'disabled' : ''}>${icon(running ? 'stop' : 'play')}</button><button class="icon-button" type="button" data-action="machine-menu" data-id="${id}" aria-label="Actions for ${esc(machineName(vm))}" aria-haspopup="menu" aria-expanded="false">${icon('more')}</button></div></td> <td><div class="row-controls"><button class="icon-button" type="button" data-action="vm-action" data-operation="${running ? 'stop' : 'start'}" data-id="${id}" aria-label="${running ? 'Stop' : 'Start'} ${esc(machineName(vm))}" title="${running ? 'Stop' : 'Start'} machine" ${busy ? 'disabled' : ''}>${icon(running ? 'stop' : 'play')}</button><button class="icon-button" type="button" data-action="machine-menu" data-id="${id}" aria-label="Actions for ${esc(machineName(vm))}" aria-haspopup="menu" aria-expanded="false">${icon('more')}</button></div></td>
</tr>`; </tr>`;
@@ -598,7 +603,7 @@
: actionButton('start', 'Start machine', 'play', true)); : actionButton('start', 'Start machine', 'play', true));
html('#detail-config', detailRows([ html('#detail-config', detailRows([
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))], ['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Guest user', vm.username], ['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
['Created', formatDate(vm.created_at)], ['Last updated', formatDate(vm.updated_at)], ['Created', formatDate(vm.created_at)], ['Last updated', formatDate(vm.updated_at)],
])); ]));
html('#detail-network', detailRows([ html('#detail-network', detailRows([
@@ -625,6 +630,19 @@
$('#sync-ports-btn').disabled = busy; $('#sync-ports-btn').disabled = busy;
$('#open-ports-btn').hidden = vm.ports === null; $('#open-ports-btn').hidden = vm.ports === null;
$('#close-ports-btn').hidden = vm.ports === null; $('#close-ports-btn').hidden = vm.ports === null;
if (!state.resizeEditing) {
const resources = vm.pending_resize || vm;
$('#resize-cpu').value = resources.cpu;
$('#resize-ram').value = `${resources.ram_mib}M`;
$('#resize-storage').value = resources.storage_mib == null ? '' : `${resources.storage_mib / 1024}G`;
}
$('#resize-form').hidden = !state.resizeEditing;
$('#edit-resources-btn').hidden = state.resizeEditing;
$('#edit-resources-btn').disabled = busy || runtimeStatus(vm) === 'unknown' || vm.ports === null || vm.operation === 'delete' || vm.status === 'terminating';
$('#resize-submit').disabled = busy;
text('#resize-note', vm.pending_resize
? `Pending resize: ${number(vm.pending_resize.cpu)} CPU, ${formatRam(vm.pending_resize.ram_mib)} memory, ${formatStorage(vm.pending_resize.storage_mib)} storage. Retry the resize or start the stopped machine to finish it.`
: 'Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.');
} }
async function loadDetail(identifier) { async function loadDetail(identifier) {
@@ -756,6 +774,67 @@
return mib; return mib;
} }
function parseStorage(value) {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?$/i);
if (!match) throw new Error('Use a storage amount such as 30G, 50G, or 1T.');
const factors = { b: 1 / (1024 * 1024), k: 1 / 1024, kb: 1 / 1024, ki: 1 / 1024, kib: 1 / 1024, m: 1, mb: 1, mi: 1, mib: 1, g: 1024, gb: 1024, gi: 1024, gib: 1024, t: 1024 * 1024, tb: 1024 * 1024, ti: 1024 * 1024, tib: 1024 * 1024 };
const amount = Number(match[1]) * factors[(match[2] || 'g').toLowerCase()];
if (!Number.isFinite(amount) || amount < 30 * 1024) throw new Error('Give this machine at least 30 GiB of storage.');
if (amount > 8796093022207) throw new Error('That storage capacity exceeds the supported limit.');
return Math.ceil(amount);
}
async function submitResize(event) {
event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return;
const id = state.detail.id;
const session = state.session;
const payload = {};
try {
const cpu = $('#resize-cpu').value.trim();
const ram = $('#resize-ram').value.trim();
const storage = $('#resize-storage').value.trim();
if (cpu) {
payload.cpu = Number(cpu);
if (!Number.isFinite(payload.cpu) || payload.cpu <= 0) throw new Error('CPU capacity must be a positive number.');
}
if (ram) { parseRam(ram); payload.ram = ram; }
if (storage) { parseStorage(storage); payload.storage = storage; }
if (!Object.keys(payload).length) throw new Error('Enter at least one resource to resize.');
} catch (error) {
text('#resize-error', error.message);
return;
}
text('#resize-error', '');
const button = $('#resize-submit');
const previous = button.textContent;
const unlock = lockForm($('#resize-form'));
state.busy.add(id);
button.textContent = 'Resizing and restarting…';
renderDetail();
try {
await api(vmPath(id, '/resize'), { method: 'POST', body: payload });
if (session !== state.session) return;
if (state.detailId === id) state.resizeEditing = false;
toast('Machine resized and started.');
await refreshAfterMutation(id);
} catch (error) {
if (!isAborted(error) && session === state.session) {
if (state.detailId === id) text('#resize-error', error.message);
else toast(error.message, true);
await refreshAfterMutation(id);
}
} finally {
unlock();
button.textContent = previous;
if (session === state.session) {
state.busy.delete(id);
renderMachines();
renderDetail();
}
}
}
async function submitPorts(event) { async function submitPorts(event) {
event.preventDefault(); event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return; if (!state.detail || state.busy.has(state.detail.id)) return;
@@ -810,8 +889,10 @@
function updateCreateSummary() { function updateCreateSummary() {
let memory = '—'; let memory = '—';
let storage = '—';
let ports = 'Check port rules'; let ports = 'Check port rules';
try { memory = formatRam(parseRam($('#create-ram').value)); } catch { /* The form reports validation on submit. */ } try { memory = formatRam(parseRam($('#create-ram').value)); } catch { /* The form reports validation on submit. */ }
try { storage = formatStorage(parseStorage($('#create-storage').value)); } catch { /* Report invalid capacity on submit. */ }
try { try {
const rules = parsePorts($('#create-ports').value); const rules = parsePorts($('#create-ports').value);
if ($('#create-default-ssh').checked && !rules.some((rule) => rule.port === 22)) rules.unshift({ port: 22, protocol: 'TCP' }); if ($('#create-default-ssh').checked && !rules.some((rule) => rule.port === 22)) rules.unshift({ port: 22, protocol: 'TCP' });
@@ -821,7 +902,7 @@
text('#create-summary-name', $('#create-name').value.trim() || 'Room for an idea.'); text('#create-summary-name', $('#create-name').value.trim() || 'Room for an idea.');
html('#create-summary-details', detailRows([ html('#create-summary-details', detailRows([
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'], ['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
['Memory', memory], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'], ['Memory', memory], ['Storage', storage], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports], ['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
])); ]));
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] }; const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
@@ -849,12 +930,14 @@
let payload; let payload;
try { try {
parseRam($('#create-ram').value); parseRam($('#create-ram').value);
parseStorage($('#create-storage').value);
const cpu = Number($('#create-cpu').value); const cpu = Number($('#create-cpu').value);
if (!Number.isFinite(cpu) || cpu <= 0) throw new Error('CPU capacity must be a positive number.'); if (!Number.isFinite(cpu) || cpu <= 0) throw new Error('CPU capacity must be a positive number.');
payload = { payload = {
name: $('#create-name').value.trim() || null, name: $('#create-name').value.trim() || null,
cpu, cpu,
ram: $('#create-ram').value.trim(), ram: $('#create-ram').value.trim(),
storage: $('#create-storage').value.trim(),
guest_ip: $('#create-guest-ip').value.trim() || null, guest_ip: $('#create-guest-ip').value.trim() || null,
username: $('#create-username').value.trim(), username: $('#create-username').value.trim(),
password: $('#create-password').value, password: $('#create-password').value,
@@ -1048,6 +1131,8 @@
state.detailId = identifier; state.detailId = identifier;
state.detail = null; state.detail = null;
state.portEditing = false; state.portEditing = false;
state.resizeEditing = false;
text('#resize-error', '');
closeMenu(); closeMenu();
closeSearch(); closeSearch();
setMobileNav(false); setMobileNav(false);
@@ -1156,6 +1241,7 @@
state.detail = null; state.detail = null;
state.detailId = null; state.detailId = null;
state.portEditing = false; state.portEditing = false;
state.resizeEditing = false;
state.query = ''; state.query = '';
state.filter = 'all'; state.filter = 'all';
state.sort = 'recent'; state.sort = 'recent';
@@ -1176,6 +1262,7 @@
$('#token-input').disabled = false; $('#token-input').disabled = false;
text('#create-error', ''); text('#create-error', '');
text('#port-error', ''); text('#port-error', '');
text('#resize-error', '');
text('#auth-error', message); text('#auth-error', message);
$('#main-app').hidden = true; $('#main-app').hidden = true;
$('#auth-screen').hidden = false; $('#auth-screen').hidden = false;
@@ -1209,6 +1296,13 @@
$('#port-input').focus(); $('#port-input').focus();
} }
if (action === 'cancel-ports') { state.portEditing = false; text('#port-error', ''); renderDetail(); } if (action === 'cancel-ports') { state.portEditing = false; text('#port-error', ''); renderDetail(); }
if (action === 'edit-resources') {
state.resizeEditing = true;
text('#resize-error', '');
renderDetail();
$('#resize-cpu').focus();
}
if (action === 'cancel-resize') { state.resizeEditing = false; text('#resize-error', ''); renderDetail(); }
if (action === 'toggle-password') { if (action === 'toggle-password') {
const visible = $('#create-password').type === 'password'; const visible = $('#create-password').type === 'password';
$('#create-password').type = visible ? 'text' : 'password'; $('#create-password').type = visible ? 'text' : 'password';
@@ -1224,6 +1318,7 @@
$('#create-form').addEventListener('submit', submitCreate); $('#create-form').addEventListener('submit', submitCreate);
$('#create-form').addEventListener('input', updateCreateSummary); $('#create-form').addEventListener('input', updateCreateSummary);
$('#port-editor').addEventListener('submit', submitPorts); $('#port-editor').addEventListener('submit', submitPorts);
$('#resize-form').addEventListener('submit', submitResize);
$('#bulk-run-btn').addEventListener('click', runBulkAction); $('#bulk-run-btn').addEventListener('click', runBulkAction);
$('#log-tail').addEventListener('change', loadLogs); $('#log-tail').addEventListener('change', loadLogs);
$('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); }); $('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); });
+49 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
import math
import time import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import Any from typing import Any
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError from .errors import StateError, ValidationError
STATE_SCHEMA_VERSION = 2 STATE_SCHEMA_VERSION = 3
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -52,6 +54,40 @@ class VmSpec:
password: str = field(default="root", repr=False) password: str = field(default="root", repr=False)
name: str | None = None name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),) ports: tuple[PortRule, ...] = (PortRule(22),)
storage_mib: int = MIN_STORAGE_MIB
@dataclass(frozen=True, slots=True)
class VmResources:
"""Complete resource target retained while a stop/resize/start is pending."""
cpu: float
ram_mib: int
storage_mib: int
def __post_init__(self) -> None:
try:
valid_cpu = type(self.cpu) in (int, float) and math.isfinite(self.cpu) and self.cpu > 0
except OverflowError:
valid_cpu = False
if not valid_cpu:
raise ValidationError("CPU must be a finite value greater than 0")
if type(self.ram_mib) is not int or self.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
if type(self.storage_mib) is not int or not MIN_STORAGE_MIB <= self.storage_mib <= MAX_STORAGE_MIB:
raise ValidationError(f"storage must be between {MIN_STORAGE_MIB} and {MAX_STORAGE_MIB} MiB (minimum 30 GiB)")
def to_dict(self) -> dict[str, Any]:
return {"cpu": self.cpu, "ram_mib": self.ram_mib, "storage_mib": self.storage_mib}
@classmethod
def from_dict(cls, value: object) -> "VmResources":
if not isinstance(value, dict) or set(value) != {"cpu", "ram_mib", "storage_mib"}:
raise StateError("invalid pending resize resources")
try:
return cls(**value)
except ValidationError as error:
raise StateError(f"invalid pending resize resources: {error}") from error
@dataclass(slots=True) @dataclass(slots=True)
@@ -87,12 +123,16 @@ class VmRecord:
spawn_pending: bool = False spawn_pending: bool = False
network: str | None = None network: str | None = None
bridge: str | None = None bridge: str | None = None
storage_mib: int | None = None
pending_resize: VmResources | None = None
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = { data: dict[str, Any] = {
"id": self.id, "id": self.id,
"cpu": self.cpu, "cpu": self.cpu,
"ram_mib": self.ram_mib, "ram_mib": self.ram_mib,
"storage_mib": self.storage_mib,
"pending_resize": self.pending_resize.to_dict() if self.pending_resize is not None else None,
"guest_ip": self.guest_ip, "guest_ip": self.guest_ip,
"gateway": self.gateway, "gateway": self.gateway,
"tap": self.tap, "tap": self.tap,
@@ -155,6 +195,12 @@ class VmRecord:
if ports is not None and len({port.port for port in ports}) != len(ports): if ports is not None and len({port.port for port in ports}) != len(ports):
raise StateError("VM has duplicate TCP port rules") raise StateError("VM has duplicate TCP port rules")
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"} legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
storage_mib = value.get("storage_mib")
if storage_mib is not None and (
type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB
):
raise StateError("VM storage_mib must be a supported integer capacity of at least 30 GiB or null")
pending_resize = value.get("pending_resize")
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"): for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
if name in value and type(value[name]) is not bool: if name in value and type(value[name]) is not bool:
raise StateError(f"VM {name} must be a boolean") raise StateError(f"VM {name} must be a boolean")
@@ -200,6 +246,8 @@ class VmRecord:
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"), spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
network=str(value["network"]) if value.get("network") is not None else None, network=str(value["network"]) if value.get("network") is not None else None,
bridge=str(value["bridge"]) if value.get("bridge") is not None else None, bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
storage_mib=storage_mib,
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
) )
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error raise StateError(f"invalid VM record: {error}") from error
+2
View File
@@ -44,6 +44,8 @@ def build_config(
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network), boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
) )
) )
if settings.architecture == "aarch64":
boot_args = f"keep_bootcon {boot_args}"
return { return {
"boot-source": { "boot-source": {
"kernel_image_path": str(kernel), "kernel_image_path": str(kernel),
+31 -19
View File
@@ -161,8 +161,8 @@ class FirewallManager:
address = str(address) address = str(address)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address) selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "--delete", *selector), check=False) self._run(("conntrack", "-D", *selector), check=False)
remaining = self._run(("conntrack", "--list", *selector)) remaining = self._run(("conntrack", "-L", *selector))
if remaining.stdout.strip(): if remaining.stdout.strip():
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP") raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
@@ -408,8 +408,9 @@ class FirewallManager:
def _normalize(objects: list[dict[str, Any]]) -> str: def _normalize(objects: list[dict[str, Any]]) -> str:
"""Canonical nft JSON, retaining every effective object, rule and verdict. """Canonical nft JSON, retaining every effective object, rule and verdict.
Ignore only handles, empty set presentation and nft's redundant protocol Normalize handles, empty set presentation and implied protocol dependencies
dependencies. Rule order is significant; pure conjunctions and sets are not. without losing address-family constraints. Rule order is significant;
pure conjunctions and sets are not.
Unknown expressions/properties remain different rather than being ignored. Unknown expressions/properties remain different rather than being ignored.
""" """
def canonical(value: Any) -> Any: def canonical(value: Any) -> Any:
@@ -442,6 +443,18 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
return result return result
return value return value
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
definitions, rules = [], {} definitions, rules = [], {}
for item in objects: for item in objects:
kind, raw = next(iter(item.items())) kind, raw = next(iter(item.items()))
@@ -490,25 +503,24 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
left["&"][1] = bitmask(left["&"][1], tcp_flags) left["&"][1] = bitmask(left["&"][1], tcp_flags)
match["right"] = bitmask(match["right"], tcp_flags) match["right"] = bitmask(match["right"], tcp_flags)
# nft's replayable JSON omits dependencies implied by named payloads.
encoded = json.dumps(expressions, sort_keys=True)
has_tcp = '"protocol": "tcp"' in encoded
has_icmp = '"protocol": "icmp"' in encoded
expressions = [expr for expr in expressions if not (
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
encoded = json.dumps(expressions, sort_keys=True)
expressions = [expr for expr in expressions if not (
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
or (('"protocol": "ip"' in encoded or has_icmp)
and expr == _match(_payload("ether", "type"), "ip"))
)]
count = len(expressions) count = len(expressions)
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}): if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
count -= 1 count -= 1
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]): if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:] matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions obj["expr"] = expressions
rules.setdefault(obj.get("chain", ""), []).append(obj) rules.setdefault(obj.get("chain", ""), []).append(obj)
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True)) definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
+83 -6
View File
@@ -2,14 +2,15 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
import shutil import stat
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .config import Settings from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError from .errors import UvmError, ValidationError
from .integrity import load_manifest, verify_file from .integrity import load_manifest, verify_file
from .system import CommandRunner from .system import CommandRunner
@@ -88,23 +89,99 @@ class ImageStore:
allow_unverified=self._settings.allow_unverified_downloads) allow_unverified=self._settings.allow_unverified_downloads)
return kernel return kernel
def create_vm_disk(self, source: Path, destination: Path) -> Path: def create_vm_disk(
self, source: Path, destination: Path, *, storage_mib: int = MIN_STORAGE_MIB,
) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM.""" """Copy the template before Firecracker opens it read-write for a VM."""
self._validate_storage(storage_mib)
if destination.exists(): if destination.exists():
raise UvmError(f"VM disk already exists: {destination}") raise UvmError(f"VM disk already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
try: try:
shutil.copy2(source, destination) self._copy_sparse(source, destination)
destination.chmod(0o600) destination.chmod(0o600)
except OSError as error: self._resize_filesystem(destination, storage_mib)
except BaseException as error:
try: try:
destination.unlink() destination.unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
if isinstance(error, OSError):
raise UvmError(f"could not create VM disk {destination}: {error}") from error raise UvmError(f"could not create VM disk {destination}: {error}") from error
raise
return destination return destination
def disk_size_bytes(self, disk: Path) -> int:
"""Read logical disk capacity, including a resize published before state was saved."""
try:
return disk.stat().st_size
except OSError as error:
raise UvmError(f"could not inspect private disk capacity: {disk}: {error}") from error
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
"""Resize a proven-offline private disk on a copy, then publish atomically."""
self._validate_storage(storage_mib)
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
target_bytes = storage_mib * 1024 * 1024
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
return # A previous resize may have published its disk before saving metadata.
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(name)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
self._resize_filesystem(temporary, storage_mib)
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
finally:
temporary.unlink(missing_ok=True)
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
def _copy_sparse(self, source: Path, destination: Path) -> None:
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
def _filesystem_size(self, disk: Path) -> int:
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
if blocks is None or block_size is None:
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
return int(blocks.group(1)) * int(block_size.group(1))
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
"""Grow the file before ext4; shrink ext4 before truncating the file."""
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
target_bytes = storage_mib * 1024 * 1024
if disk.stat().st_size < target_bytes:
with disk.open("r+b") as image:
image.truncate(target_bytes)
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
if self._filesystem_size(disk) != target_bytes:
raise UvmError("resized ext4 filesystem does not match the requested capacity")
with disk.open("r+b") as image:
image.truncate(target_bytes)
image.flush()
os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None: def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Set an existing guest account password in an offline ext4 disk.""" """Set an existing guest account password in an offline ext4 disk."""
+3 -15
View File
@@ -11,7 +11,7 @@ import urllib.request
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings from .config import Settings
from .errors import UvmError from .errors import UvmError
from .images import GuestAssets from .images import GuestAssets
from .integrity import require_checksum, sha256_file, verify_file, write_manifest from .integrity import require_checksum, sha256_file, verify_file, write_manifest
@@ -42,7 +42,6 @@ class Installer:
return self._install_locked(force_assets=force_assets) return self._install_locked(force_assets=force_assets)
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]: def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
self._validate_guest_asset_architecture()
self._validate_integrity_policy() self._validate_integrity_policy()
if self._settings.allow_unverified_downloads and self._emit is not None: if self._settings.allow_unverified_downloads and self._emit is not None:
self._emit( self._emit(
@@ -56,16 +55,6 @@ class Installer:
self._write_integrity_manifest(firecracker, assets) self._write_integrity_manifest(firecracker, assets)
return 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: def _validate_integrity_policy(self) -> None:
require_checksum( require_checksum(
self._settings.firecracker_sha256, self._settings.firecracker_sha256,
@@ -112,6 +101,7 @@ class Installer:
"iptables", "iptables",
"nftables", "nftables",
"conntrack", "conntrack",
"coreutils",
"e2fsprogs", "e2fsprogs",
"util-linux", "util-linux",
"ca-certificates", "ca-certificates",
@@ -122,9 +112,7 @@ class Installer:
self._runner.run(("apt-get", "install", "-y", *packages)) self._runner.run(("apt-get", "install", "-y", *packages))
def install_firecracker_binary(self, *, force: bool = False) -> Path: def install_firecracker_binary(self, *, force: bool = False) -> Path:
architecture = os.uname().machine architecture = self._settings.architecture
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {architecture}")
firecracker = self._settings.firecracker_binary firecracker = self._settings.firecracker_binary
jailer = self._settings.jailer_binary jailer = self._settings.jailer_binary
+169
View File
@@ -0,0 +1,169 @@
"""Remove a deleted VM's entries from local OpenSSH host-key caches."""
from __future__ import annotations
import base64
import binascii
import fcntl
import hmac
import os
import pwd
import re
import stat
import uuid
from ipaddress import IPv4Address
from pathlib import Path
from .config import Settings
from .domain import VmRecord
from .errors import UvmError
_HOST_LINE = re.compile(
rb"^[ \t]*(?:@(?:cert-authority|revoked)[ \t]+)?"
rb"(?P<hosts>[^ \t\r\n]+)[ \t]+[^ \t\r\n]+[ \t]+[^ \t\r\n]+"
)
_PORT_HOST = re.compile(rb"\[([^\]]+)\]:(\d+)\Z")
class KnownHostsManager:
"""Deletion-only cleanup; lifecycle owns the lock and confirms VM teardown."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
def remove(self, vm: VmRecord) -> None:
try:
address = str(IPv4Address(vm.guest_ip)).encode("ascii")
except ValueError as error:
raise UvmError("known_hosts cleanup requires a valid guest IPv4 address") from error
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
raise UvmError("known_hosts cleanup requires a valid VM ID")
alias = f"uvm-{vm.id}".encode("ascii")
hosts = {address, alias, alias.lower()}
ports = {22, *(rule.port for rule in vm.ports or ())}
hashed_candidates = hosts | {
b"[" + host + b"]:" + str(port).encode("ascii") for host in hosts for port in ports
}
for path, owner in self._files().items():
self._remove_file(path, hosts, hashed_candidates, owner)
def _files(self) -> dict[Path, int | None]:
if self._settings.known_hosts_files is not None:
return dict.fromkeys(self._settings.known_hosts_files)
try:
effective_uid = os.geteuid()
accounts = [pwd.getpwuid(effective_uid)]
if effective_uid == 0:
sudo_uid = os.environ.get("SUDO_UID")
sudo_user = os.environ.get("SUDO_USER")
if sudo_uid:
if not sudo_uid.isascii() or not sudo_uid.isdigit():
raise ValueError("invalid SUDO_UID")
accounts.append(pwd.getpwuid(int(sudo_uid)))
elif sudo_user:
accounts.append(pwd.getpwnam(sudo_user))
return {
Path(account.pw_dir) / ".ssh" / filename: account.pw_uid
for account in accounts for filename in ("known_hosts", "known_hosts2")
}
except (KeyError, ValueError, OverflowError) as error:
raise UvmError("could not determine SSH user homes; configure UVM_KNOWN_HOSTS_FILES") from error
@staticmethod
def _remove_file(path: Path, hosts: set[bytes], candidates: set[bytes], owner: int | None) -> None:
directory = None
temporary = None
try:
# Pin the directory and reject symlink/FIFO/hard-link targets when
# editing another local user's cache from a privileged process.
try:
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
except FileNotFoundError:
return
with os.fdopen(descriptor, "rb") as source:
metadata = os.fstat(source.fileno())
if not stat.S_ISREG(metadata.st_mode):
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
fcntl.flock(source.fileno(), fcntl.LOCK_EX)
metadata = os.fstat(source.fileno())
if metadata.st_nlink != 1:
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
if owner is not None and metadata.st_uid != owner:
raise UvmError(f"known_hosts has an unexpected owner: {path}")
contents = source.read()
updated = _without_hosts(contents, hosts, candidates)
if updated == contents:
return
candidate = f".{path.name}.uvm-{uuid.uuid4().hex}"
descriptor = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory)
temporary = candidate
with os.fdopen(descriptor, "wb") as output:
output.write(updated)
output.flush()
created = os.fstat(output.fileno())
if (created.st_uid, created.st_gid) != (metadata.st_uid, metadata.st_gid):
os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid)
os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode))
for attribute in os.listxattr(source.fileno()):
os.setxattr(output.fileno(), attribute, os.getxattr(source.fileno(), attribute))
os.fsync(output.fileno())
current = os.stat(path.name, dir_fd=directory, follow_symlinks=False)
fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode", "st_uid", "st_gid", "st_nlink")
if any(getattr(current, field) != getattr(metadata, field) for field in fields):
raise UvmError(f"known_hosts changed during cleanup; retry deletion: {path}")
os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory)
os.fsync(directory)
except OSError as error:
raise UvmError(f"could not clean SSH known_hosts {path}: {error}") from error
finally:
if directory is not None:
try:
if temporary is not None:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
finally:
os.close(directory)
def _without_hosts(contents: bytes, hosts: set[bytes], candidates: set[bytes]) -> bytes:
"""Preserve unrelated aliases, comments, markers, key types, and line endings."""
def matches(token: bytes) -> bool:
if token.startswith(b"|1|"):
try:
_, _version, salt_text, digest_text = token.split(b"|")
salt = base64.b64decode(salt_text, validate=True)
digest = base64.b64decode(digest_text, validate=True)
except (ValueError, binascii.Error):
return False
return len(salt) == len(digest) == 20 and any(
hmac.compare_digest(digest, hmac.digest(salt, candidate, "sha1")) for candidate in candidates
)
token = token.lower()
if token in hosts:
return True
bracketed = _PORT_HOST.fullmatch(token)
return (bracketed is not None and bracketed[1] in hosts
and len(bracketed[2]) <= 5 and 1 <= int(bracketed[2]) <= 65535)
retained = []
for line in contents.splitlines(keepends=True):
if line.lstrip().startswith(b"#"):
retained.append(line)
continue
match = _HOST_LINE.match(line)
if match is None:
retained.append(line)
continue
names = match["hosts"].split(b",")
remaining = [name for name in names if not matches(name)]
if remaining == names:
retained.append(line)
elif remaining:
retained.append(line[:match.start("hosts")] + b",".join(remaining) + line[match.end("hosts"):])
return b"".join(retained)
+88 -21
View File
@@ -11,18 +11,19 @@ from dataclasses import dataclass, replace
from ipaddress import IPv4Network from ipaddress import IPv4Network
from pathlib import Path from pathlib import Path
from .config import Settings from .config import MIN_STORAGE_MIB, Settings
from .domain import PortRule, VmRecord, VmSpec, new_vm_id from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
from .errors import ConflictError, NotFoundError, UvmError, ValidationError from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config from .firecracker.config import build_config, write_config
from .firecracker.process import FirecrackerProcessManager, ProcessInfo from .firecracker.process import FirecrackerProcessManager, ProcessInfo
from .images import ImageStore from .images import ImageStore
from .integrity import load_manifest, verify_file from .integrity import load_manifest, verify_file
from .known_hosts import KnownHostsManager
from .network import NetworkManager from .network import NetworkManager
from .state import StateStore from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root from .system import check_kvm, ensure_data_directories, require_root
from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -55,6 +56,7 @@ class LifecycleService:
def __init__( def __init__(
self, settings: Settings, state_store: StateStore, images: ImageStore, self, settings: Settings, state_store: StateStore, images: ImageStore,
network: NetworkManager, process: FirecrackerProcessManager, *, network: NetworkManager, process: FirecrackerProcessManager, *,
known_hosts: KnownHostsManager,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient, client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
clock: Callable[[], float] = time.time, clock: Callable[[], float] = time.time,
) -> None: ) -> None:
@@ -63,14 +65,13 @@ class LifecycleService:
self._images = images self._images = images
self._network = network self._network = network
self._process = process self._process = process
self._known_hosts = known_hosts
self._client_factory = client_factory self._client_factory = client_factory
self._clock = clock self._clock = clock
def create(self, spec: VmSpec) -> VmRecord: def create(self, spec: VmSpec) -> VmRecord:
require_root() require_root()
parse_cpu(str(spec.cpu)) VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
parse_username(spec.username) parse_username(spec.username)
parse_password(spec.password) parse_password(spec.password)
if spec.name is not None: if spec.name is not None:
@@ -86,7 +87,7 @@ class LifecycleService:
runtime.mkdir(mode=0o700, exist_ok=False) runtime.mkdir(mode=0o700, exist_ok=False)
vm.runtime_owned = True vm.runtime_owned = True
self._save(vm) self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk)) disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
self._images.provision_credentials(disk, vm.username, vm.password) self._images.provision_credentials(disk, vm.username, vm.password)
vm.disk_ready = True vm.disk_ready = True
self._save(vm) self._save(vm)
@@ -164,7 +165,7 @@ class LifecycleService:
with self._state_store.operation_lock(): with self._state_store.operation_lock():
return self._start_locked(self._load_for_operation(identifier)) return self._start_locked(self._load_for_operation(identifier))
def _start_locked(self, vm: VmRecord) -> VmRecord: def _start_locked(self, vm: VmRecord, *, kernel: Path | None = None) -> VmRecord:
self._not_deleting(vm) self._not_deleting(vm)
if vm.ports is None: if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start") raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
@@ -172,22 +173,18 @@ class LifecycleService:
if observation == "unknown": if observation == "unknown":
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying") raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
if observation == "alive": if observation == "alive":
if vm.pending_resize is not None:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}: if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting") raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
return self._sync_ports_locked(vm) return self._sync_ports_locked(vm)
if not vm.disk_ready: self._validate_resume(vm)
raise ConflictError(f"{vm.id} has no completely provisioned private disk") if kernel is None:
disk = Path(vm.disk)
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
self._check_boot() self._check_boot()
kernel = self._images.installed_kernel() kernel = self._images.installed_kernel()
try: try:
self._cleanup_runtime(vm) self._cleanup_runtime(vm)
self._apply_pending_resize(vm)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel) self._boot(vm, kernel)
return vm return vm
@@ -209,12 +206,13 @@ class LifecycleService:
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord: def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
deleting = vm.operation == "delete" or vm.status == "terminating" deleting = vm.operation == "delete" or vm.status == "terminating"
vm.status, vm.operation = "stopping", "delete" if deleting else "stop" pending = "delete" if deleting else "resize" if vm.pending_resize is not None else None
vm.status, vm.operation = "stopping", pending or "stop"
self._save(vm) self._save(vm)
try: try:
self._cleanup_runtime(vm, timeout=timeout) self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped" vm.status = "stopped"
vm.operation = "delete" if deleting else None vm.operation = pending
vm.last_error = None vm.last_error = None
self._save(vm) self._save(vm)
return vm return vm
@@ -232,6 +230,73 @@ class LifecycleService:
self._stop_locked(vm, timeout=timeout) self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm) return self._start_locked(vm)
def resize(
self, identifier: str, *, cpu: float | None = None, ram_mib: int | None = None,
storage_mib: int | None = None, timeout: float | None = None,
) -> VmRecord:
"""Stop, apply resource changes to the retained disk, then boot under one lock."""
require_root()
if cpu is None and ram_mib is None and storage_mib is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
VmResources(cpu if cpu is not None else 1, ram_mib if ram_mib is not None else 128,
storage_mib if storage_mib is not None else MIN_STORAGE_MIB)
if timeout is not None:
timeout = parse_timeout(str(timeout))
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._validate_resume(vm)
if self._process.observe(vm) == "unknown":
raise ConflictError(f"cannot resize {vm.id}: process identity is unknown")
# Verify boot prerequisites before stopping an otherwise healthy VM.
self._check_boot()
kernel = self._images.installed_kernel()
previous = vm.pending_resize
capacity = previous.storage_mib if previous is not None else vm.storage_mib
if capacity is None:
capacity = max(MIN_STORAGE_MIB, (self._images.disk_size_bytes(Path(vm.disk)) + 1024 * 1024 - 1) // (1024 * 1024))
vm.pending_resize = VmResources(
cpu=cpu if cpu is not None else previous.cpu if previous is not None else vm.cpu,
ram_mib=ram_mib if ram_mib is not None else previous.ram_mib if previous is not None else vm.ram_mib,
storage_mib=storage_mib if storage_mib is not None else capacity,
)
vm.operation = "resize"
self._save(vm)
try:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm, kernel=kernel)
except UvmError as error:
raise UvmError(f"failed to resize {vm.id}: {error}") from error
def _validate_resume(self, vm: VmRecord) -> None:
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
def _apply_pending_resize(self, vm: VmRecord) -> None:
target = vm.pending_resize
if target is None:
return
if self._process.observe(vm) != "gone":
raise ConflictError("cannot resize a disk without confirmed process exit")
# A prior attempt may have published a resized image without saving
# its allocation. Check the disk too, including when a retry changes
# the target back to the previously recorded capacity.
if (target.storage_mib != vm.storage_mib
or self._images.disk_size_bytes(Path(vm.disk)) != target.storage_mib * 1024 * 1024):
self._images.resize_vm_disk(Path(vm.disk), target.storage_mib)
vm.cpu, vm.ram_mib, vm.storage_mib = target.cpu, target.ram_mib, target.storage_mib
vm.pending_resize = None
self._save(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord: def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root() require_root()
with self._state_store.operation_lock(): with self._state_store.operation_lock():
@@ -243,6 +308,7 @@ class LifecycleService:
try: try:
self._cleanup_runtime(vm, timeout=timeout) self._cleanup_runtime(vm, timeout=timeout)
self._remove_runtime(vm) self._remove_runtime(vm)
self._known_hosts.remove(vm)
self._remove_record(vm.id) self._remove_record(vm.id)
return vm return vm
except BaseException as error: except BaseException as error:
@@ -472,7 +538,8 @@ class LifecycleService:
results.append({"name": name, "ok": True, "message": "available"}) results.append({"name": name, "ok": True, "message": "available"})
except (UvmError, OSError) as error: except (UvmError, OSError) as error:
results.append({"name": name, "ok": False, "message": str(error)}) results.append({"name": name, "ok": False, "message": str(error)})
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"): for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "e2fsck", "resize2fs", "dumpe2fs",
"cp", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command) path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"}) results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results return results
@@ -529,7 +596,7 @@ class LifecycleService:
state.next_mac_index += 1 state.next_mac_index += 1
directory = self._settings.vm_dir(vm_id) directory = self._settings.vm_dir(vm_id)
vm = VmRecord( vm = VmRecord(
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, storage_mib=spec.storage_mib,
guest_ip=str(guest_ip), gateway=str(self._settings.gateway), guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
network=str(self._settings.network), bridge=self._settings.bridge, network=str(self._settings.network), bridge=self._settings.bridge,
tap=self._network.tap_name(vm_id), mac=mac, tap=self._network.tap_name(vm_id), mac=mac,
+23
View File
@@ -13,16 +13,19 @@ from ..api_models import (
VmCreateRequest, VmCreateRequest,
VmLogResponse, VmLogResponse,
VmResponse, VmResponse,
VmResizeRequest,
vm_response, vm_response,
) )
from ..app import Application from ..app import Application
from ..domain import PortRule, VmRecord, VmSpec from ..domain import PortRule, VmRecord, VmSpec
from ..errors import ValidationError
from ..validation import ( from ..validation import (
parse_cpu, parse_cpu,
parse_name, parse_name,
parse_password, parse_password,
parse_ports, parse_ports,
parse_ram, parse_ram,
parse_storage,
parse_timeout, parse_timeout,
parse_username, parse_username,
) )
@@ -71,6 +74,7 @@ def create_vm(
VmSpec( VmSpec(
cpu=parse_cpu(str(request.cpu)), cpu=parse_cpu(str(request.cpu)),
ram_mib=parse_ram(str(request.ram)), ram_mib=parse_ram(str(request.ram)),
storage_mib=parse_storage(str(request.storage)),
guest_ip=request.guest_ip, guest_ip=request.guest_ip,
username=parse_username(request.username), username=parse_username(request.username),
password=parse_password(request.password.get_secret_value()), password=parse_password(request.password.get_secret_value()),
@@ -141,6 +145,25 @@ def stop_vm(
return vm_response(vm, policy_status=_mutation_policy_status(vm)) return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/resize", response_model=VmResponse)
def resize_vm(
vm_id: str,
request: VmResizeRequest,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
if request.cpu is None and request.ram is None and request.storage is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
vm = application.lifecycle.resize(
vm_id,
cpu=parse_cpu(str(request.cpu)) if request.cpu is not None else None,
ram_mib=parse_ram(str(request.ram)) if request.ram is not None else None,
storage_mib=parse_storage(str(request.storage)) if request.storage is not None else None,
timeout=parse_timeout(str(timeout)) if timeout is not None else None,
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse) @router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm( def destroy_vm(
vm_id: str, vm_id: str,
+17 -1
View File
@@ -5,13 +5,15 @@ from __future__ import annotations
import math import math
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation, ROUND_CEILING
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import ValidationError from .errors import ValidationError
from .domain import PortRule from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE) _RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
_STORAGE_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?\s*", re.IGNORECASE)
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?") _USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
_MIB_FACTORS = { _MIB_FACTORS = {
"b": Decimal(1) / Decimal(1024 * 1024), "b": Decimal(1) / Decimal(1024 * 1024),
@@ -62,6 +64,20 @@ def parse_cpu(value: str) -> float:
return cpu return cpu
def parse_storage(value: str) -> int:
"""Parse GiB-default storage, rounding capacity up to a whole MiB."""
match = _STORAGE_PATTERN.fullmatch(value)
if match is None:
raise ValidationError(f"invalid storage value: {value}; use e.g. 30G, 50G, or 1T")
factors = {**_MIB_FACTORS, **dict.fromkeys(("t", "tb", "ti", "tib"), Decimal(1024 * 1024))}
amount = Decimal(match.group(1)) * factors[(match.group(2) or "g").lower()]
if amount < MIN_STORAGE_MIB:
raise ValidationError("storage must be at least 30 GiB")
if amount > MAX_STORAGE_MIB:
raise ValidationError(f"storage must not exceed {MAX_STORAGE_MIB} MiB")
return int(amount.to_integral_value(rounding=ROUND_CEILING))
def parse_username(value: str) -> str: def parse_username(value: str) -> str:
"""Validate a conventional Linux account name.""" """Validate a conventional Linux account name."""