Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309f28018e | ||
|
|
c75c7d9baf | ||
|
|
871afcf454 | ||
|
|
4a46685a4c | ||
|
|
fb80ce7c8a |
@@ -0,0 +1,5 @@
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
**.pyc
|
||||
+110
-16
@@ -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
|
||||
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
|
||||
expose health, installation, VM lifecycle controls, and managed port-policy
|
||||
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 >= 5.3 with native bridge conntrack and safe pidfd support
|
||||
- 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
|
||||
- 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
|
||||
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
|
||||
│ ├── install.py # Apt, Firecracker, kernel, rootfs installation
|
||||
│ ├── 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
|
||||
│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup
|
||||
│ ├── 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.
|
||||
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
|
||||
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
|
||||
@@ -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`.
|
||||
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;
|
||||
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
|
||||
checks, not per-VM repair or guest application probes.
|
||||
|
||||
@@ -185,6 +186,7 @@ process:
|
||||
| CommandRunner | `CommandRunner` | Logged/testable subprocess execution |
|
||||
| StateStore | `StateStore` | Inventory, locks, JSON persistence |
|
||||
| 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 |
|
||||
| FirewallManager | Constructed by `NetworkManager` | Owned native bridge policy and scoped offline conntrack cleanup |
|
||||
| FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control |
|
||||
@@ -230,11 +232,11 @@ state file.
|
||||
### State Schema
|
||||
|
||||
[`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
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"next_mac_index": 1,
|
||||
"vms": {}
|
||||
}
|
||||
@@ -245,7 +247,8 @@ record. Do not make API models or CLI namespaces the persistence format.
|
||||
|
||||
| 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 |
|
||||
| `socket`, `config`, `log`, `disk` | Canonical per-VM runtime paths |
|
||||
| `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.
|
||||
- State reads are lock-free because atomic replacement gives readers either a
|
||||
complete old file or a complete new file.
|
||||
- Legacy versions 0 (including unversioned state) and 1 are loaded into schema
|
||||
2 in memory and written as schema 2 on the next transaction. Newer schemas
|
||||
- Legacy versions 0 (including unversioned state), 1, and 2 are loaded into schema
|
||||
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.
|
||||
- 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`,
|
||||
@@ -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.
|
||||
- Records created before credential provisioning default to username `root`
|
||||
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
|
||||
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`.
|
||||
2. Check KVM, verified Firecracker, firewall capability, and installed templates.
|
||||
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,
|
||||
provision credentials/host keys, and persist `disk_ready=True`.
|
||||
4. Create the owned mode-0700 runtime directory, sparse-copy a mode-0600 private
|
||||
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**.
|
||||
6. Apply/read back the nftables bridge policy, then activate the TAP.
|
||||
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
|
||||
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
|
||||
|
||||
```text
|
||||
@@ -380,7 +417,7 @@ stop:
|
||||
|
||||
delete (destroy alias):
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees
|
||||
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
|
||||
`/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
|
||||
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
|
||||
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
|
||||
all effective objects, ordered rules, hooks, identity, and the full port set;
|
||||
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
|
||||
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
|
||||
release the address. Rule removal also requires an absent TAP and verified
|
||||
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
|
||||
|
||||
@@ -612,6 +681,15 @@ do not import its unimplemented topology checks into current documentation.
|
||||
`Installer` is the only owner of apt installation, Firecracker artifact
|
||||
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
|
||||
existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux
|
||||
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 |
|
||||
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
|
||||
| `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 |
|
||||
| `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}/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 |
|
||||
| `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` |
|
||||
@@ -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
|
||||
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
|
||||
resource/status fields and username. The schema excludes password and
|
||||
disk/socket/config/log path fields; error messages may still contain paths.
|
||||
`ports: null` in a legacy response still means unmanaged. Start/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
|
||||
`{"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
|
||||
@@ -788,6 +872,16 @@ prove the kernel's actual packet behavior.
|
||||
|
||||
FastAPI route tests are conditionally enabled when both `fastapi` and `httpx`
|
||||
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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`uvm` is a small, local Firecracker microVM command-line manager for Ubuntu.
|
||||
It creates one Firecracker process per VM, attaches it to a host TAP device,
|
||||
and 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.
|
||||
|
||||
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
|
||||
- KVM enabled and accessible at `/dev/kvm`
|
||||
- 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`,
|
||||
`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
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
run. Reinstall with trusted digests to establish a verified local manifest.
|
||||
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
|
||||
@@ -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.
|
||||
# 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.
|
||||
sudo ./uvm.py create --name web
|
||||
|
||||
# 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
|
||||
|
||||
# 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 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.
|
||||
sudo ./uvm.py stop --all --dry-run --json
|
||||
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
|
||||
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
|
||||
a guest-coordinated shutdown**. Start boots the retained disk without resetting
|
||||
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
|
||||
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
|
||||
|
||||
```sh
|
||||
@@ -241,11 +283,12 @@ The server exposes these routes:
|
||||
| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view |
|
||||
| `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` |
|
||||
| `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 |
|
||||
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
|
||||
| `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}/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 |
|
||||
| `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 |
|
||||
@@ -253,7 +296,9 @@ The server exposes these routes:
|
||||
| `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"}`;
|
||||
`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
|
||||
stopped and use `set` to establish a managed policy. Responses include `name`,
|
||||
`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
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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_GATEWAY` | Bridge/guest gateway; defaults to `10.42.0.1` |
|
||||
| `UVM_BRIDGE` | Linux bridge name; defaults to `uvm0` |
|
||||
| `UVM_KERNEL_URL` | Guest kernel download URL |
|
||||
| `UVM_ROOTFS_URL` | Guest rootfs download URL |
|
||||
| `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
|
||||
| `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_KERNEL_SHA256` | Trusted guest kernel 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
|
||||
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
|
||||
namespaces**, which may require privileges and the installed network tools:
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ You need:
|
||||
- KVM enabled and readable/writable at `/dev/kvm`.
|
||||
- Linux >= 5.3 with native bridge conntrack and safe pidfd support.
|
||||
- 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.
|
||||
- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`,
|
||||
- `iproute2`, `iptables`, `nftables`, `conntrack`, `coreutils`, `e2fsprogs`, `util-linux`,
|
||||
`openssl`, and `openssh-client`; `install` installs the host packages.
|
||||
- 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? |
|
||||
|---|---|
|
||||
| `install`, `create`, `start`, `stop`, `restart`, `delete`/`destroy` | Yes |
|
||||
| `install`, `create`, `start`, `stop`, `restart`, `resize`, `delete`/`destroy` | Yes |
|
||||
| `ports open/close/set/sync` | Yes |
|
||||
| `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 |
|
||||
@@ -102,7 +102,7 @@ and do not run every destructive example as a single script.
|
||||
|
||||
```text
|
||||
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]
|
||||
[--ports PORTS]... [--no-default-ports] [--json]
|
||||
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 stop (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 destroy (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json]
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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. |
|
||||
| `checksum is required` | Provide the requested `UVM_*_SHA256` variable. |
|
||||
| `SHA-256 mismatch` | Stop and obtain the correct digest or artifact URL. |
|
||||
| `unsupported host architecture` | Use x86_64 defaults or provide compatible assets. |
|
||||
| `default guest assets support x86_64 only` | Configure both custom kernel and rootfs URLs for ARM. |
|
||||
| `unsupported host architecture` | Use an x86_64 or ARM64 Linux host. |
|
||||
|
||||
## 5. Create VMs
|
||||
|
||||
### 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:
|
||||
|
||||
```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
|
||||
scope. UVM does not install the services named by these labels.
|
||||
|
||||
### Scenario: Choose CPU and RAM
|
||||
### Scenario: Choose CPU, RAM, and Storage
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```sh
|
||||
@@ -296,7 +321,8 @@ UVM performs these operations in order:
|
||||
|
||||
1. Checks root access, KVM, artifact integrity, and nftables/conntrack support.
|
||||
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.
|
||||
5. Installs and verifies the nftables bridge policy, then activates the TAP.
|
||||
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;
|
||||
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
|
||||
|
||||
```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
|
||||
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.
|
||||
`--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
|
||||
@@ -410,7 +469,7 @@ for one connection:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```sh
|
||||
@@ -459,8 +546,10 @@ sudo ./uvm.py delete web -y
|
||||
# Equivalent destructive command: sudo ./uvm.py destroy web -y
|
||||
```
|
||||
|
||||
Delete performs the stop cleanup, then removes the runtime directory/private
|
||||
disk and registry record, releasing the name and IP. `destroy` is the same
|
||||
Delete performs the stop cleanup, removes the runtime directory/private disk,
|
||||
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
|
||||
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
|
||||
|
||||
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`
|
||||
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:
|
||||
@@ -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
|
||||
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
|
||||
locked mutation only if its saved start time, running Firecracker executable,
|
||||
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 |
|
||||
| `POST` | `/vms/{identifier}/start` | Start a retained VM |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
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`,
|
||||
`operation`, `pid`, timestamps, and `last_error`. The response schema excludes
|
||||
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 \
|
||||
-H "Content-Type: application/json" \
|
||||
-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"
|
||||
```
|
||||
|
||||
@@ -739,7 +834,7 @@ curl -X POST \
|
||||
"$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` 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,
|
||||
@@ -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
|
||||
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.
|
||||
`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
|
||||
`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
|
||||
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
|
||||
|
||||
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_GATEWAY` | Guest gateway / bridge IP; default `10.42.0.1` |
|
||||
| `UVM_BRIDGE` | Linux bridge name; default `uvm0` |
|
||||
| `UVM_KERNEL_URL` | Custom kernel download URL |
|
||||
| `UVM_ROOTFS_URL` | Custom rootfs download URL |
|
||||
| `UVM_KNOWN_HOSTS_FILES` | Override deletion's local SSH cache files; colon-separated absolute paths |
|
||||
| `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_KERNEL_SHA256` | Strict kernel checksum |
|
||||
| `UVM_ROOTFS_SHA256` | Strict rootfs checksum |
|
||||
|
||||
@@ -296,7 +296,7 @@ def _sandbox(parent_fd):
|
||||
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
|
||||
|
||||
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")
|
||||
return set(re.findall(r"\bid=(\d+)", listing))
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ class CliCreateTests(unittest.TestCase):
|
||||
command="create",
|
||||
cpu="1",
|
||||
ram="512",
|
||||
storage="30G",
|
||||
host_ip=None,
|
||||
username="admin",
|
||||
password="custom-secret",
|
||||
|
||||
@@ -30,7 +30,7 @@ class CliTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.vm = replace(
|
||||
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.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.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(
|
||||
settings=Settings(), lifecycle=self.lifecycle,
|
||||
state_store=Mock(spec_set=("operation_lock",)),
|
||||
@@ -186,10 +187,38 @@ class CliParsingTests(CliTestCase):
|
||||
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):
|
||||
def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
|
||||
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",
|
||||
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
|
||||
], stdin=io.StringIO(" stdin-secret \r\n"))
|
||||
@@ -199,6 +228,7 @@ class CliCreateCommandTests(CliTestCase):
|
||||
self.assertEqual(spec.name, "web")
|
||||
self.assertEqual(spec.cpu, 0.5)
|
||||
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.username, "admin")
|
||||
self.assertEqual(spec.password, " stdin-secret ")
|
||||
@@ -222,6 +252,7 @@ class CliCreateCommandTests(CliTestCase):
|
||||
code, stdout, stderr = self.invoke(["create", *options, "--json"])
|
||||
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].storage_mib, 30 * 1024)
|
||||
self.assertNotIn("WARNING", stdout)
|
||||
self.assertIn("default password", stderr)
|
||||
self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
|
||||
|
||||
@@ -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()
|
||||
@@ -19,6 +19,33 @@ class RecordingFirecrackerClient(FirecrackerClient):
|
||||
|
||||
|
||||
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:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
|
||||
+89
-6
@@ -102,9 +102,9 @@ class FakeRunner:
|
||||
self.after_batch()
|
||||
elif args == ("conntrack", "--version"):
|
||||
stdout = "conntrack v1.4.8 (conntrack-tools)"
|
||||
elif args[:2] == ("conntrack", "--delete"):
|
||||
elif args[:2] == ("conntrack", "-D"):
|
||||
code = self.ct_delete_code
|
||||
elif args[:2] == ("conntrack", "--list"):
|
||||
elif args[:2] == ("conntrack", "-L"):
|
||||
stdout = self.ct_remaining
|
||||
else:
|
||||
raise AssertionError(f"unexpected command: {args}")
|
||||
@@ -376,6 +376,34 @@ class FirewallManagerTests(unittest.TestCase):
|
||||
exprs[:-1] = reversed(exprs[:-1])
|
||||
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):
|
||||
self.vm.ports = ()
|
||||
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.manager.cleanup_connections(self.vm)
|
||||
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),
|
||||
[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"):
|
||||
self.manager.cleanup_connections(self.vm)
|
||||
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):
|
||||
self.manager.cleanup_connections(self.vm)
|
||||
|
||||
@@ -455,7 +483,7 @@ class FirewallManagerTests(unittest.TestCase):
|
||||
expected = [("ip", "-j", "-d", "link", "show")]
|
||||
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
|
||||
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.
|
||||
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):
|
||||
self.runner.calls.clear()
|
||||
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.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.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):
|
||||
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
|
||||
dumped = json.loads('''{"nftables": [
|
||||
@@ -706,6 +760,35 @@ class NftDumpFixtureTests(unittest.TestCase):
|
||||
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")
|
||||
class NftJsonParserTests(unittest.TestCase):
|
||||
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
|
||||
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, host: FakeHost, network: FakeNetwork) -> None:
|
||||
self.host = host
|
||||
@@ -282,12 +293,26 @@ class LifecycleTestCase(unittest.TestCase):
|
||||
self.store = TrackedStateStore(self.settings)
|
||||
self.host = FakeHost(self.store)
|
||||
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.copy_disk = self.enterContext(patch.object(
|
||||
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
|
||||
))
|
||||
self.network = FakeNetwork(self.settings, self.host)
|
||||
self.process = FakeProcess(self.host)
|
||||
self.known_hosts = FakeKnownHosts(self.host)
|
||||
self.clients: dict[str, FakeClient] = {}
|
||||
self.service = LifecycleService(
|
||||
self.settings,
|
||||
@@ -295,6 +320,7 @@ class LifecycleTestCase(unittest.TestCase):
|
||||
self.images,
|
||||
self.network, # 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]
|
||||
)
|
||||
self.require_root = self.enterContext(patch("uvm.lifecycle.require_root"))
|
||||
@@ -307,6 +333,13 @@ class LifecycleTestCase(unittest.TestCase):
|
||||
self.clients[client.vm_id] = 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:
|
||||
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
|
||||
|
||||
@@ -510,6 +543,7 @@ class LifecycleTests(LifecycleTestCase):
|
||||
settings = replace(self.settings, **changed)
|
||||
service = LifecycleService(
|
||||
settings, self.store, self.images, self.network, self.process,
|
||||
known_hosts=self.known_hosts,
|
||||
client_factory=self.make_client,
|
||||
)
|
||||
self.host.events.clear()
|
||||
@@ -542,6 +576,7 @@ class LifecycleTests(LifecycleTestCase):
|
||||
settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway))
|
||||
service = LifecycleService(
|
||||
settings, self.store, self.images, self.network, self.process,
|
||||
known_hosts=self.known_hosts,
|
||||
client_factory=self.make_client,
|
||||
)
|
||||
with (
|
||||
|
||||
@@ -591,7 +591,8 @@ class LifecycleInspectionTests(LifecycleTestCase):
|
||||
|
||||
def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None:
|
||||
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.resolve_targets([], all_vms=True), [])
|
||||
for method in (service.inspect, service.find_for_ssh, service.log_path):
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
from uvm.cli import build_parser, main
|
||||
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.lifecycle import ListedVm, OperationResult
|
||||
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",
|
||||
default_vcpu=1,
|
||||
default_ram_mib=512,
|
||||
default_storage_mib=30 * 1024,
|
||||
default_ssh_user="root",
|
||||
api_token=api_token,
|
||||
api_tls_cert=None,
|
||||
@@ -145,6 +146,7 @@ if FASTAPI_AVAILABLE:
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec: VmSpec | None = None
|
||||
self.last_resize: dict | None = None
|
||||
self.observed_status = "running"
|
||||
self.policy_status = "enforced"
|
||||
self.vm = VmRecord(
|
||||
@@ -152,6 +154,7 @@ class FakeLifecycle:
|
||||
name="test-vm",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
storage_mib=30 * 1024,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
@@ -187,6 +190,7 @@ class FakeLifecycle:
|
||||
self.last_spec = spec
|
||||
self.vm.cpu = spec.cpu
|
||||
self.vm.ram_mib = spec.ram_mib
|
||||
self.vm.storage_mib = spec.storage_mib
|
||||
if spec.guest_ip is not None:
|
||||
self.vm.guest_ip = str(spec.guest_ip)
|
||||
self.vm.username = spec.username
|
||||
@@ -222,6 +226,16 @@ class FakeLifecycle:
|
||||
self.policy_status = "inactive"
|
||||
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:
|
||||
self.inspect(identifier)
|
||||
return self.vm
|
||||
@@ -319,6 +333,7 @@ class ApiRouterTests(unittest.TestCase):
|
||||
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
|
||||
("POST", "/vms/test-vm/start", {}),
|
||||
("POST", "/vms/test-vm/restart", {}),
|
||||
("POST", "/vms/test-vm/resize", {"cpu": 2}),
|
||||
("POST", "/vms/test-vm/stop", {}),
|
||||
("DELETE", "/vms/test-vm", {}),
|
||||
("POST", "/vms/test-vm/ports", {}),
|
||||
@@ -376,6 +391,68 @@ class ApiRouterTests(unittest.TestCase):
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
||||
self.assertIsNone(self.lifecycle.last_spec.name)
|
||||
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:
|
||||
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:
|
||||
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",
|
||||
"created_at", "updated_at", "last_error",
|
||||
}
|
||||
@@ -683,6 +760,7 @@ class ApiRouterTests(unittest.TestCase):
|
||||
("/vms/{identifier}/logs", "get"),
|
||||
("/vms/{vm_id}/start", "post"),
|
||||
("/vms/{vm_id}/restart", "post"),
|
||||
("/vms/{vm_id}/resize", "post"),
|
||||
("/vms/{vm_id}/stop", "post"),
|
||||
("/vms/{vm_id}", "delete"),
|
||||
("/vms/{vm_id}/ports", "post"),
|
||||
|
||||
+26
-11
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
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.state import StateStore
|
||||
|
||||
@@ -61,13 +61,13 @@ class StateStoreTests(unittest.TestCase):
|
||||
|
||||
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
for version in (None, 1):
|
||||
for version in (None, 1, 2):
|
||||
with self.subTest(version=version):
|
||||
raw_vm = make_vm().to_dict()
|
||||
for field in (
|
||||
"disk", "username", "password", "updated_at", "name", "ports",
|
||||
"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)
|
||||
legacy = {"vms": {"vm-test": raw_vm}}
|
||||
@@ -77,7 +77,7 @@ class StateStoreTests(unittest.TestCase):
|
||||
|
||||
loaded = self.store.load()
|
||||
vm = loaded.vms["vm-test"]
|
||||
self.assertEqual(loaded.schema_version, 2)
|
||||
self.assertEqual(loaded.schema_version, 3)
|
||||
self.assertEqual(vm.disk, "")
|
||||
self.assertEqual(vm.username, "root")
|
||||
self.assertIsNone(vm.password)
|
||||
@@ -85,6 +85,8 @@ class StateStoreTests(unittest.TestCase):
|
||||
self.assertIsNone(vm.ports)
|
||||
self.assertIsNone(vm.network)
|
||||
self.assertIsNone(vm.bridge)
|
||||
self.assertIsNone(vm.storage_mib)
|
||||
self.assertIsNone(vm.pending_resize)
|
||||
self.assertFalse(vm.disk_ready)
|
||||
self.assertTrue(vm.runtime_owned)
|
||||
self.assertFalse(vm.tap_owned)
|
||||
@@ -99,7 +101,7 @@ class StateStoreTests(unittest.TestCase):
|
||||
state.vms["vm-test"].status = "stopped"
|
||||
|
||||
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["vms"]["vm-test"]["status"], "stopped")
|
||||
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
|
||||
@@ -114,16 +116,17 @@ class StateStoreTests(unittest.TestCase):
|
||||
runtime_owned=True, tap_owned=True, operation="configuring",
|
||||
boot_id="host-boot-id", spawn_pending=True, pid=123,
|
||||
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:
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
loaded = self.store.load()
|
||||
self.assertEqual(loaded.schema_version, 2)
|
||||
self.assertEqual(loaded.schema_version, 3)
|
||||
self.assertEqual(loaded.vms[vm.id], vm)
|
||||
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(
|
||||
raw["vms"][vm.id]["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:
|
||||
self.base.mkdir(parents=True)
|
||||
for version in (0, 1, 2):
|
||||
for version in (0, 1, 2, 3):
|
||||
with self.subTest(version=version):
|
||||
raw = {
|
||||
"schema_version": version,
|
||||
@@ -302,11 +305,23 @@ class DomainStateTests(unittest.TestCase):
|
||||
self.assertIs(vm.spawn_pending, operation == "spawning")
|
||||
self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm)
|
||||
|
||||
def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None:
|
||||
self.assertEqual(State().schema_version, 2)
|
||||
def test_new_state_uses_schema_three_and_rejects_future_versions(self) -> None:
|
||||
self.assertEqual(State().schema_version, 3)
|
||||
with self.assertRaisesRegex(StateError, "newer than"):
|
||||
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:
|
||||
first, second = new_vm_id(), new_vm_id()
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
@@ -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)
|
||||
@@ -5,11 +5,26 @@ import unittest
|
||||
from uvm.domain import PortRule
|
||||
from uvm.errors import ValidationError
|
||||
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):
|
||||
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:
|
||||
self.assertEqual(parse_ram("512"), 512)
|
||||
self.assertEqual(parse_ram("1G"), 1024)
|
||||
|
||||
@@ -40,6 +40,7 @@ class VmCreateRequest(BaseModel):
|
||||
|
||||
cpu: float = Field(default=1, gt=0)
|
||||
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
|
||||
username: str = "root"
|
||||
password: SecretStr = SecretStr("root")
|
||||
@@ -48,11 +49,27 @@ class VmCreateRequest(BaseModel):
|
||||
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):
|
||||
id: str
|
||||
name: str | None
|
||||
cpu: float
|
||||
ram_mib: int
|
||||
storage_mib: int | None
|
||||
pending_resize: VmResourcesModel | None
|
||||
guest_ip: str
|
||||
gateway: str
|
||||
mac: str
|
||||
@@ -124,6 +141,8 @@ def vm_response(
|
||||
name=vm.name,
|
||||
cpu=vm.cpu,
|
||||
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,
|
||||
gateway=vm.gateway,
|
||||
mac=vm.mac,
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ from .config import Settings
|
||||
from .firecracker.process import FirecrackerProcessManager
|
||||
from .images import ImageStore
|
||||
from .install import Installer
|
||||
from .known_hosts import KnownHostsManager
|
||||
from .lifecycle import LifecycleService
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
@@ -32,7 +33,8 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
|
||||
images = ImageStore(settings, runner)
|
||||
network = NetworkManager(settings, runner)
|
||||
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)
|
||||
return Application(
|
||||
settings=settings,
|
||||
|
||||
+40
-2
@@ -22,6 +22,7 @@ from .validation import (
|
||||
parse_password,
|
||||
parse_ports,
|
||||
parse_ram,
|
||||
parse_storage,
|
||||
parse_timeout,
|
||||
parse_username,
|
||||
)
|
||||
@@ -97,6 +98,10 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=str(application.settings.default_ram_mib),
|
||||
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(
|
||||
"--guest-ip",
|
||||
"--host-ip",
|
||||
@@ -130,6 +135,14 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
)
|
||||
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.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
|
||||
output = listing.add_mutually_exclusive_group()
|
||||
@@ -293,6 +306,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
VmSpec(
|
||||
cpu=parse_cpu(args.cpu),
|
||||
ram_mib=parse_ram(args.ram),
|
||||
storage_mib=parse_storage(args.storage),
|
||||
guest_ip=guest_ip,
|
||||
username=parse_username(args.username),
|
||||
password=parse_password(password),
|
||||
@@ -321,6 +335,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
print(f" Name: {vm.name}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" Storage: {vm.storage_mib} MiB")
|
||||
print(f" CPU: {vm.cpu}")
|
||||
print(f" TAP: {vm.tap}")
|
||||
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}")
|
||||
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":
|
||||
vms = [
|
||||
listed for listed in application.lifecycle.list_vms()
|
||||
@@ -349,14 +385,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
print(
|
||||
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:
|
||||
vm = listed.vm
|
||||
pid = vm.pid if vm.pid is not None else ""
|
||||
print(
|
||||
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
|
||||
|
||||
@@ -543,6 +579,8 @@ def _vm_output(
|
||||
"name": vm.name,
|
||||
"cpu": vm.cpu,
|
||||
"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,
|
||||
"gateway": vm.gateway,
|
||||
"tap": vm.tap,
|
||||
|
||||
+37
-13
@@ -3,21 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ConfigurationError
|
||||
|
||||
|
||||
DEFAULT_KERNEL_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/kernels/vmlinux.bin"
|
||||
)
|
||||
DEFAULT_ROOTFS_URL = (
|
||||
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
|
||||
"x86_64/rootfs/bionic.rootfs.ext4"
|
||||
)
|
||||
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
|
||||
MIN_STORAGE_MIB = 30 * 1024
|
||||
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -29,12 +24,14 @@ class Settings:
|
||||
network: IPv4Network = IPv4Network("10.42.0.0/24")
|
||||
gateway: IPv4Address = IPv4Address("10.42.0.1")
|
||||
bridge: str = "uvm0"
|
||||
architecture: str = field(default_factory=lambda: os.uname().machine)
|
||||
firecracker_version: str = "v1.16.1"
|
||||
default_ram_mib: int = 512
|
||||
default_storage_mib: int = MIN_STORAGE_MIB
|
||||
default_vcpu: float = 1
|
||||
default_ssh_user: str = "root"
|
||||
kernel_url: str = DEFAULT_KERNEL_URL
|
||||
rootfs_url: str = DEFAULT_ROOTFS_URL
|
||||
kernel_url: str = ""
|
||||
rootfs_url: str = ""
|
||||
firecracker_sha256: str | None = None
|
||||
firecracker_binary_sha256: str | None = None
|
||||
kernel_sha256: str | None = None
|
||||
@@ -43,11 +40,25 @@ class Settings:
|
||||
api_token: str | None = None
|
||||
api_tls_cert: Path | None = None
|
||||
api_tls_key: Path | None = None
|
||||
known_hosts_files: tuple[Path, ...] | None = None
|
||||
api_timeout_s: float = 2.0
|
||||
api_socket_timeout_s: float = 5.0
|
||||
terminate_timeout_s: float = 5.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
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:
|
||||
raise ConfigurationError(
|
||||
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")
|
||||
if len(self.bridge) > 15:
|
||||
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 not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
|
||||
raise ConfigurationError(
|
||||
@@ -124,8 +137,8 @@ class Settings:
|
||||
network=network,
|
||||
gateway=gateway,
|
||||
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
|
||||
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
|
||||
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
|
||||
kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
|
||||
rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
|
||||
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
|
||||
firecracker_binary_sha256=_optional_environment_value(
|
||||
"UVM_FIRECRACKER_BINARY_SHA256"
|
||||
@@ -138,6 +151,7 @@ class Settings:
|
||||
api_token=_optional_environment_value("UVM_API_TOKEN"),
|
||||
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
|
||||
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
|
||||
@@ -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 code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -878,8 +879,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
|
||||
.field input { font-size: 12px; }
|
||||
.detail-delete-row { align-items: flex-start; }
|
||||
.detail-delete-row > span { max-width: 170px; }
|
||||
.ports-panel > .section-heading, .log-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, .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, .resources-panel > .section-heading > div:first-child { flex: 1; }
|
||||
.auth-screen { padding: 20px; }
|
||||
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
|
||||
.auth-story { min-height: 240px; padding: 26px; border-right: 0; }
|
||||
|
||||
@@ -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 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>
|
||||
<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 & 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 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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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">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>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
detailId: null,
|
||||
detail: null,
|
||||
portEditing: false,
|
||||
resizeEditing: false,
|
||||
selected: new Set(),
|
||||
filter: 'all',
|
||||
query: '',
|
||||
@@ -86,6 +87,10 @@
|
||||
return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`;
|
||||
}
|
||||
|
||||
function formatStorage(value) {
|
||||
return value == null ? 'Not recorded' : formatRam(value);
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
if (value == null || value === '') return NaN;
|
||||
if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) {
|
||||
@@ -396,7 +401,7 @@
|
||||
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><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="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>`;
|
||||
@@ -598,7 +603,7 @@
|
||||
: actionButton('start', 'Start machine', 'play', true));
|
||||
html('#detail-config', detailRows([
|
||||
['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)],
|
||||
]));
|
||||
html('#detail-network', detailRows([
|
||||
@@ -625,6 +630,19 @@
|
||||
$('#sync-ports-btn').disabled = busy;
|
||||
$('#open-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) {
|
||||
@@ -756,6 +774,67 @@
|
||||
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) {
|
||||
event.preventDefault();
|
||||
if (!state.detail || state.busy.has(state.detail.id)) return;
|
||||
@@ -810,8 +889,10 @@
|
||||
|
||||
function updateCreateSummary() {
|
||||
let memory = '—';
|
||||
let storage = '—';
|
||||
let ports = 'Check port rules';
|
||||
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 {
|
||||
const rules = parsePorts($('#create-ports').value);
|
||||
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.');
|
||||
html('#create-summary-details', detailRows([
|
||||
['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],
|
||||
]));
|
||||
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
||||
@@ -849,12 +930,14 @@
|
||||
let payload;
|
||||
try {
|
||||
parseRam($('#create-ram').value);
|
||||
parseStorage($('#create-storage').value);
|
||||
const cpu = Number($('#create-cpu').value);
|
||||
if (!Number.isFinite(cpu) || cpu <= 0) throw new Error('CPU capacity must be a positive number.');
|
||||
payload = {
|
||||
name: $('#create-name').value.trim() || null,
|
||||
cpu,
|
||||
ram: $('#create-ram').value.trim(),
|
||||
storage: $('#create-storage').value.trim(),
|
||||
guest_ip: $('#create-guest-ip').value.trim() || null,
|
||||
username: $('#create-username').value.trim(),
|
||||
password: $('#create-password').value,
|
||||
@@ -1048,6 +1131,8 @@
|
||||
state.detailId = identifier;
|
||||
state.detail = null;
|
||||
state.portEditing = false;
|
||||
state.resizeEditing = false;
|
||||
text('#resize-error', '');
|
||||
closeMenu();
|
||||
closeSearch();
|
||||
setMobileNav(false);
|
||||
@@ -1156,6 +1241,7 @@
|
||||
state.detail = null;
|
||||
state.detailId = null;
|
||||
state.portEditing = false;
|
||||
state.resizeEditing = false;
|
||||
state.query = '';
|
||||
state.filter = 'all';
|
||||
state.sort = 'recent';
|
||||
@@ -1176,6 +1262,7 @@
|
||||
$('#token-input').disabled = false;
|
||||
text('#create-error', '');
|
||||
text('#port-error', '');
|
||||
text('#resize-error', '');
|
||||
text('#auth-error', message);
|
||||
$('#main-app').hidden = true;
|
||||
$('#auth-screen').hidden = false;
|
||||
@@ -1209,6 +1296,13 @@
|
||||
$('#port-input').focus();
|
||||
}
|
||||
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') {
|
||||
const visible = $('#create-password').type === 'password';
|
||||
$('#create-password').type = visible ? 'text' : 'password';
|
||||
@@ -1224,6 +1318,7 @@
|
||||
$('#create-form').addEventListener('submit', submitCreate);
|
||||
$('#create-form').addEventListener('input', updateCreateSummary);
|
||||
$('#port-editor').addEventListener('submit', submitPorts);
|
||||
$('#resize-form').addEventListener('submit', submitResize);
|
||||
$('#bulk-run-btn').addEventListener('click', runBulkAction);
|
||||
$('#log-tail').addEventListener('change', loadLogs);
|
||||
$('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); });
|
||||
|
||||
+49
-1
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any
|
||||
|
||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
|
||||
from .errors import StateError, ValidationError
|
||||
|
||||
|
||||
STATE_SCHEMA_VERSION = 2
|
||||
STATE_SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -52,6 +54,40 @@ class VmSpec:
|
||||
password: str = field(default="root", repr=False)
|
||||
name: str | None = None
|
||||
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)
|
||||
@@ -87,12 +123,16 @@ class VmRecord:
|
||||
spawn_pending: bool = False
|
||||
network: str | None = None
|
||||
bridge: str | None = None
|
||||
storage_mib: int | None = None
|
||||
pending_resize: VmResources | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"cpu": self.cpu,
|
||||
"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,
|
||||
"gateway": self.gateway,
|
||||
"tap": self.tap,
|
||||
@@ -155,6 +195,12 @@ class VmRecord:
|
||||
if ports is not None and len({port.port for port in ports}) != len(ports):
|
||||
raise StateError("VM has duplicate TCP port rules")
|
||||
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"):
|
||||
if name in value and type(value[name]) is not bool:
|
||||
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"),
|
||||
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,
|
||||
storage_mib=storage_mib,
|
||||
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise StateError(f"invalid VM record: {error}") from error
|
||||
|
||||
@@ -44,6 +44,8 @@ def build_config(
|
||||
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
|
||||
)
|
||||
)
|
||||
if settings.architecture == "aarch64":
|
||||
boot_args = f"keep_bootcon {boot_args}"
|
||||
return {
|
||||
"boot-source": {
|
||||
"kernel_image_path": str(kernel),
|
||||
|
||||
+31
-19
@@ -161,8 +161,8 @@ class FirewallManager:
|
||||
address = str(address)
|
||||
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
|
||||
selector = ("--family", "ipv4", field, address)
|
||||
self._run(("conntrack", "--delete", *selector), check=False)
|
||||
remaining = self._run(("conntrack", "--list", *selector))
|
||||
self._run(("conntrack", "-D", *selector), check=False)
|
||||
remaining = self._run(("conntrack", "-L", *selector))
|
||||
if remaining.stdout.strip():
|
||||
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:
|
||||
"""Canonical nft JSON, retaining every effective object, rule and verdict.
|
||||
|
||||
Ignore only handles, empty set presentation and nft's redundant protocol
|
||||
dependencies. Rule order is significant; pure conjunctions and sets are not.
|
||||
Normalize handles, empty set presentation and implied protocol dependencies
|
||||
without losing address-family constraints. Rule order is significant;
|
||||
pure conjunctions and sets are not.
|
||||
Unknown expressions/properties remain different rather than being ignored.
|
||||
"""
|
||||
def canonical(value: Any) -> Any:
|
||||
@@ -442,6 +443,18 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
|
||||
return result
|
||||
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 = [], {}
|
||||
for item in objects:
|
||||
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)
|
||||
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)
|
||||
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
|
||||
count -= 1
|
||||
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
|
||||
rules.setdefault(obj.get("chain", ""), []).append(obj)
|
||||
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
|
||||
|
||||
+83
-6
@@ -2,14 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||
from .errors import UvmError, ValidationError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
|
||||
@@ -88,23 +89,99 @@ class ImageStore:
|
||||
allow_unverified=self._settings.allow_unverified_downloads)
|
||||
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."""
|
||||
|
||||
self._validate_storage(storage_mib)
|
||||
if destination.exists():
|
||||
raise UvmError(f"VM disk already exists: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
self._copy_sparse(source, destination)
|
||||
destination.chmod(0o600)
|
||||
except OSError as error:
|
||||
self._resize_filesystem(destination, storage_mib)
|
||||
except BaseException as error:
|
||||
try:
|
||||
destination.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if isinstance(error, OSError):
|
||||
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
||||
raise
|
||||
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:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
|
||||
|
||||
+3
-15
@@ -11,7 +11,7 @@ import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .images import GuestAssets
|
||||
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)
|
||||
|
||||
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
|
||||
self._validate_guest_asset_architecture()
|
||||
self._validate_integrity_policy()
|
||||
if self._settings.allow_unverified_downloads and self._emit is not None:
|
||||
self._emit(
|
||||
@@ -56,16 +55,6 @@ class Installer:
|
||||
self._write_integrity_manifest(firecracker, assets)
|
||||
return firecracker, assets
|
||||
|
||||
def _validate_guest_asset_architecture(self) -> None:
|
||||
architecture = os.uname().machine
|
||||
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
|
||||
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
|
||||
if architecture == "aarch64" and (default_kernel or default_rootfs):
|
||||
raise UvmError(
|
||||
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
|
||||
"and UVM_ROOTFS_URL to compatible aarch64 assets"
|
||||
)
|
||||
|
||||
def _validate_integrity_policy(self) -> None:
|
||||
require_checksum(
|
||||
self._settings.firecracker_sha256,
|
||||
@@ -112,6 +101,7 @@ class Installer:
|
||||
"iptables",
|
||||
"nftables",
|
||||
"conntrack",
|
||||
"coreutils",
|
||||
"e2fsprogs",
|
||||
"util-linux",
|
||||
"ca-certificates",
|
||||
@@ -122,9 +112,7 @@ class Installer:
|
||||
self._runner.run(("apt-get", "install", "-y", *packages))
|
||||
|
||||
def install_firecracker_binary(self, *, force: bool = False) -> Path:
|
||||
architecture = os.uname().machine
|
||||
if architecture not in ("x86_64", "aarch64"):
|
||||
raise UvmError(f"unsupported host architecture: {architecture}")
|
||||
architecture = self._settings.architecture
|
||||
|
||||
firecracker = self._settings.firecracker_binary
|
||||
jailer = self._settings.jailer_binary
|
||||
|
||||
@@ -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
@@ -11,18 +11,19 @@ from dataclasses import dataclass, replace
|
||||
from ipaddress import IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
|
||||
from .config import MIN_STORAGE_MIB, Settings
|
||||
from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
|
||||
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
|
||||
from .firecracker.api import FirecrackerClient
|
||||
from .firecracker.config import build_config, write_config
|
||||
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
|
||||
from .images import ImageStore
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .known_hosts import KnownHostsManager
|
||||
from .network import NetworkManager
|
||||
from .state import StateStore
|
||||
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)
|
||||
@@ -55,6 +56,7 @@ class LifecycleService:
|
||||
def __init__(
|
||||
self, settings: Settings, state_store: StateStore, images: ImageStore,
|
||||
network: NetworkManager, process: FirecrackerProcessManager, *,
|
||||
known_hosts: KnownHostsManager,
|
||||
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
@@ -63,14 +65,13 @@ class LifecycleService:
|
||||
self._images = images
|
||||
self._network = network
|
||||
self._process = process
|
||||
self._known_hosts = known_hosts
|
||||
self._client_factory = client_factory
|
||||
self._clock = clock
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
require_root()
|
||||
parse_cpu(str(spec.cpu))
|
||||
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")
|
||||
VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
|
||||
parse_username(spec.username)
|
||||
parse_password(spec.password)
|
||||
if spec.name is not None:
|
||||
@@ -86,7 +87,7 @@ class LifecycleService:
|
||||
runtime.mkdir(mode=0o700, exist_ok=False)
|
||||
vm.runtime_owned = True
|
||||
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)
|
||||
vm.disk_ready = True
|
||||
self._save(vm)
|
||||
@@ -164,7 +165,7 @@ class LifecycleService:
|
||||
with self._state_store.operation_lock():
|
||||
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)
|
||||
if vm.ports is None:
|
||||
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
|
||||
@@ -172,22 +173,18 @@ class LifecycleService:
|
||||
if observation == "unknown":
|
||||
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
|
||||
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"}:
|
||||
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
|
||||
return self._sync_ports_locked(vm)
|
||||
if not vm.disk_ready:
|
||||
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
|
||||
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._validate_resume(vm)
|
||||
if kernel is None:
|
||||
self._check_boot()
|
||||
kernel = self._images.installed_kernel()
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
self._apply_pending_resize(vm)
|
||||
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
|
||||
self._boot(vm, kernel)
|
||||
return vm
|
||||
@@ -209,12 +206,13 @@ class LifecycleService:
|
||||
|
||||
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
|
||||
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)
|
||||
try:
|
||||
self._cleanup_runtime(vm, timeout=timeout)
|
||||
vm.status = "stopped"
|
||||
vm.operation = "delete" if deleting else None
|
||||
vm.operation = pending
|
||||
vm.last_error = None
|
||||
self._save(vm)
|
||||
return vm
|
||||
@@ -232,6 +230,73 @@ class LifecycleService:
|
||||
self._stop_locked(vm, timeout=timeout)
|
||||
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:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
@@ -243,6 +308,7 @@ class LifecycleService:
|
||||
try:
|
||||
self._cleanup_runtime(vm, timeout=timeout)
|
||||
self._remove_runtime(vm)
|
||||
self._known_hosts.remove(vm)
|
||||
self._remove_record(vm.id)
|
||||
return vm
|
||||
except BaseException as error:
|
||||
@@ -472,7 +538,8 @@ class LifecycleService:
|
||||
results.append({"name": name, "ok": True, "message": "available"})
|
||||
except (UvmError, OSError) as 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)
|
||||
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
|
||||
return results
|
||||
@@ -529,7 +596,7 @@ class LifecycleService:
|
||||
state.next_mac_index += 1
|
||||
directory = self._settings.vm_dir(vm_id)
|
||||
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),
|
||||
network=str(self._settings.network), bridge=self._settings.bridge,
|
||||
tap=self._network.tap_name(vm_id), mac=mac,
|
||||
|
||||
@@ -13,16 +13,19 @@ from ..api_models import (
|
||||
VmCreateRequest,
|
||||
VmLogResponse,
|
||||
VmResponse,
|
||||
VmResizeRequest,
|
||||
vm_response,
|
||||
)
|
||||
from ..app import Application
|
||||
from ..domain import PortRule, VmRecord, VmSpec
|
||||
from ..errors import ValidationError
|
||||
from ..validation import (
|
||||
parse_cpu,
|
||||
parse_name,
|
||||
parse_password,
|
||||
parse_ports,
|
||||
parse_ram,
|
||||
parse_storage,
|
||||
parse_timeout,
|
||||
parse_username,
|
||||
)
|
||||
@@ -71,6 +74,7 @@ def create_vm(
|
||||
VmSpec(
|
||||
cpu=parse_cpu(str(request.cpu)),
|
||||
ram_mib=parse_ram(str(request.ram)),
|
||||
storage_mib=parse_storage(str(request.storage)),
|
||||
guest_ip=request.guest_ip,
|
||||
username=parse_username(request.username),
|
||||
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))
|
||||
|
||||
|
||||
@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)
|
||||
def destroy_vm(
|
||||
vm_id: str,
|
||||
|
||||
+17
-1
@@ -5,13 +5,15 @@ from __future__ import annotations
|
||||
import math
|
||||
import re
|
||||
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 .domain import PortRule
|
||||
|
||||
|
||||
_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}\$?")
|
||||
_MIB_FACTORS = {
|
||||
"b": Decimal(1) / Decimal(1024 * 1024),
|
||||
@@ -62,6 +64,20 @@ def parse_cpu(value: str) -> float:
|
||||
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:
|
||||
"""Validate a conventional Linux account name."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user