feat: implement resource resizing for VMs with storage management

- Added KnownHostsManager to handle cleanup of SSH known_hosts entries upon VM deletion.
- Introduced disk_size_bytes method in ImageStore to read logical disk capacity.
- Enhanced LifecycleService to manage VM resource resizing, including CPU, RAM, and storage.
- Updated application configuration to support known_hosts_files for SSH management.
- Modified dashboard to include resource resizing options in the UI.
- Improved error handling and validation for resource inputs in the dashboard.
- Removed obsolete .pyc files from the repository.
This commit is contained in:
2026-09-21 20:37:52 +05:30
parent fb80ce7c8a
commit 4a46685a4c
40 changed files with 1048 additions and 63 deletions
+64 -14
View File
@@ -12,7 +12,7 @@ general-purpose Python library or a multi-host control plane.
UVM installs host/guest assets, provisions private disks, and manages named
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,7 +47,7 @@ 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 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
@@ -140,7 +140,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 +156,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.
@@ -230,11 +230,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 +245,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 +272,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 +286,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 +331,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 +376,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
@@ -470,6 +505,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
@@ -498,7 +536,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.
@@ -675,11 +713,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` |
@@ -701,12 +740,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
@@ -809,6 +853,12 @@ 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
The opt-in packet harness must run **only in disposable isolated user/network