From 4a46685a4c357fc3504666c00763e7be6e4359c7 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Mon, 21 Sep 2026 20:37:52 +0530 Subject: [PATCH] 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. --- .gitignore | 5 + DEVELOPER.md | 78 +++++-- README.md | 52 ++++- USAGE.md | 91 ++++++-- tests/test_cli.py | 1 + tests/test_cli_commands.py | 37 +++- tests/test_lifecycle.py | 35 ++++ tests/test_lifecycle_commands.py | 3 +- tests/test_resize.py | 195 ++++++++++++++++++ tests/test_server.py | 82 +++++++- tests/test_state.py | 37 +++- tests/test_storage.py | 155 ++++++++++++++ tests/test_validation.py | 17 +- uvm/__pycache__/__init__.cpython-312.pyc | Bin 214 -> 0 bytes uvm/__pycache__/app.cpython-312.pyc | Bin 1880 -> 0 bytes uvm/__pycache__/cli.cpython-312.pyc | Bin 37060 -> 0 bytes uvm/__pycache__/config.cpython-312.pyc | Bin 9864 -> 0 bytes uvm/__pycache__/domain.cpython-312.pyc | Bin 15467 -> 0 bytes uvm/__pycache__/errors.cpython-312.pyc | Bin 2818 -> 0 bytes uvm/__pycache__/firewall.cpython-312.pyc | Bin 37326 -> 0 bytes uvm/__pycache__/images.cpython-312.pyc | Bin 19772 -> 0 bytes uvm/__pycache__/install.cpython-312.pyc | Bin 13317 -> 0 bytes uvm/__pycache__/integrity.cpython-312.pyc | Bin 7099 -> 0 bytes uvm/__pycache__/lifecycle.cpython-312.pyc | Bin 42509 -> 0 bytes uvm/__pycache__/network.cpython-312.pyc | Bin 14849 -> 0 bytes uvm/__pycache__/state.cpython-312.pyc | Bin 9911 -> 0 bytes uvm/__pycache__/system.cpython-312.pyc | Bin 5103 -> 0 bytes uvm/__pycache__/validation.cpython-312.pyc | Bin 6574 -> 0 bytes uvm/app.py | 4 +- uvm/config.py | 14 ++ uvm/dashboard/static/css/dashboard.css | 7 +- uvm/dashboard/static/index.html | 11 + uvm/dashboard/static/js/dashboard.js | 101 ++++++++- .../__pycache__/__init__.cpython-312.pyc | Bin 219 -> 0 bytes .../__pycache__/api.cpython-312.pyc | Bin 4854 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 3937 -> 0 bytes .../__pycache__/process.cpython-312.pyc | Bin 23877 -> 0 bytes uvm/images.py | 7 + uvm/known_hosts.py | 167 +++++++++++++++ uvm/lifecycle.py | 12 +- 40 files changed, 1048 insertions(+), 63 deletions(-) create mode 100644 .gitignore create mode 100644 tests/test_resize.py create mode 100644 tests/test_storage.py delete mode 100644 uvm/__pycache__/__init__.cpython-312.pyc delete mode 100644 uvm/__pycache__/app.cpython-312.pyc delete mode 100644 uvm/__pycache__/cli.cpython-312.pyc delete mode 100644 uvm/__pycache__/config.cpython-312.pyc delete mode 100644 uvm/__pycache__/domain.cpython-312.pyc delete mode 100644 uvm/__pycache__/errors.cpython-312.pyc delete mode 100644 uvm/__pycache__/firewall.cpython-312.pyc delete mode 100644 uvm/__pycache__/images.cpython-312.pyc delete mode 100644 uvm/__pycache__/install.cpython-312.pyc delete mode 100644 uvm/__pycache__/integrity.cpython-312.pyc delete mode 100644 uvm/__pycache__/lifecycle.cpython-312.pyc delete mode 100644 uvm/__pycache__/network.cpython-312.pyc delete mode 100644 uvm/__pycache__/state.cpython-312.pyc delete mode 100644 uvm/__pycache__/system.cpython-312.pyc delete mode 100644 uvm/__pycache__/validation.cpython-312.pyc delete mode 100644 uvm/firecracker/__pycache__/__init__.cpython-312.pyc delete mode 100644 uvm/firecracker/__pycache__/api.cpython-312.pyc delete mode 100644 uvm/firecracker/__pycache__/config.cpython-312.pyc delete mode 100644 uvm/firecracker/__pycache__/process.cpython-312.pyc create mode 100644 uvm/known_hosts.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35ca279 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pytest_cache/ +.venv/ +*.egg-info/ +__pycache__/ +**.pyc \ No newline at end of file diff --git a/DEVELOPER.md b/DEVELOPER.md index 961b339..931f656 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -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 diff --git a/README.md b/README.md index 1406719..3e21122 100644 --- a/README.md +++ b/README.md @@ -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 @@ -40,7 +40,7 @@ The project is an application, not a reusable Python SDK. - Linux 5.3 or newer with native bridge conntrack and safe pidfd support - 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` @@ -89,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 @@ -100,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. @@ -133,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 @@ -150,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. @@ -248,11 +274,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 | @@ -260,7 +287,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 @@ -379,7 +408,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. @@ -404,6 +433,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: @@ -467,6 +501,8 @@ 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: diff --git a/USAGE.md b/USAGE.md index 47d1a73..6cfd1fa 100644 --- a/USAGE.md +++ b/USAGE.md @@ -46,7 +46,7 @@ You need: - Root access for installation and VM lifecycle operations. - 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 ...] @@ -199,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 @@ -219,7 +220,7 @@ sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM ### Scenario: Create a Default VM -The default is one CPU, 512 MiB RAM, username `root`, password `root`, and an +The default is one CPU, 512 MiB RAM, 30 GiB storage, username `root`, password `root`, and an allowlist containing private TCP/22: ```sh @@ -281,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 ``` @@ -295,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 @@ -312,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. @@ -432,7 +442,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 @@ -473,6 +483,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 @@ -597,7 +635,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: @@ -614,6 +652,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 @@ -712,6 +755,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 | @@ -720,7 +764,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 @@ -733,7 +777,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 @@ -747,7 +791,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" ``` @@ -761,7 +805,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, @@ -769,6 +813,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 @@ -803,6 +849,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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 749bc4d..ef43c1e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -108,6 +108,7 @@ class CliCreateTests(unittest.TestCase): command="create", cpu="1", ram="512", + storage="30G", host_ip=None, username="admin", password="custom-secret", diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 17b61f0..5b7e625 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -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") diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index d3cd710..b717c49 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -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 ( diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py index 053635b..8ef7c55 100644 --- a/tests/test_lifecycle_commands.py +++ b/tests/test_lifecycle_commands.py @@ -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): diff --git a/tests/test_resize.py b/tests/test_resize.py new file mode 100644 index 0000000..7382b53 --- /dev/null +++ b/tests/test_resize.py @@ -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()) diff --git a/tests/test_server.py b/tests/test_server.py index 5671c0e..fc49cf1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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"), diff --git a/tests/test_state.py b/tests/test_state.py index a217cf0..af6b512 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -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) diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..7e2388a --- /dev/null +++ b/tests/test_storage.py @@ -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) diff --git a/tests/test_validation.py b/tests/test_validation.py index 197eb06..9796426 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -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) diff --git a/uvm/__pycache__/__init__.cpython-312.pyc b/uvm/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 3ce4397c9ce2b2484b557815f3b9f10fa41cacee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 214 zcmXv|yA8rH6tww9NZG-Z6yOCA9R&h00F7nE4`T37i4zg#K%%8(7B)!B0!VaJI0&w} zJL%rLyT|bW1$58X{VMRIF@NBnctt^6kU$$$;Zf@}Uldua`EJUQ?k zkgd&#Pk|JrCW+OW8!=YW0O8IlnS8^O^$6E}#)M56yP;Rp^&ZmHYM^3w6i`wFxu@X= jWlS3B8JoDOj;7j*LcuKSg^Oll-pqTpF~&7oW}RR@%QHMQ diff --git a/uvm/__pycache__/app.cpython-312.pyc b/uvm/__pycache__/app.cpython-312.pyc deleted file mode 100644 index fb8baf7cc701b3efd55abe4d61cbdb2b4d1b82fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1880 zcmYjR&2QX96d!wSuXi{5kxiP9CMBk&AB!M?Yg>?_pq3~lf(9hysCmC7KzxUzincsVFeyUc> z1eX5o_Wk#CLjFJ{U!dbsj(!07g!sfJzT&H)5~;STATET3NV7GGwNQ@?+W@TlMp%qY z+myH%mZGvp{(U3hPaVe}uHM=HpIjlzwyCHEU9FCfHQ{tg;BpS6xC9Z~NqA`0+ zAu6GUUwdBPQRMbOze)VYW8x3*sF`Iadl7q_PP7$&)Nel4?a7Ui)|p51^PNr@c&-SN z*z%I7lkh-dmL$SzC(IJ}sMTGM&>6Y0|8^L})S?fm*A?z+NM~PR@|KcT+&E5zOvFJo z7u+z!0Kg^R6|NV$oWH_7qIZP~5yWd4Ie8;s)MKvqfU=KS;!(~&a$|RmGH{j`BS0%6 zVKUKT%mw6?uEwpPO}z~-q$`xI2Ob90mZ|tMVGm9vSb;qqC#WuDr?}mXW6C~-FLWLz zf_npooY;-13x@Dv@# zPdqT!9p_Y89p^i81R2SH({uNdh|c+I4}H2m7gH|gphxE1PG`2W!GtIQHEiv^56h zD{t{lMGVH2kRExi0@5EXVr4wsU%?D;v6UdxgJ>`^L7s&a*ew^D7^%HBa z(9?Q)&)6C4W3wob54kWG_;FuaX4y2w$iPX!Tj-g+;&V0ku8>w?8Jbk1Q6O3xLp~YC zvr!ahK(xwPS2Wrzi5wcQ2P}yr8Vgp#5Zo@qGqNERCJH>7>=ap}R*B7^0duDYlY=4u zks1Nc9#4yLHdJY`lZ~hRvD^a3)KS{zJr zRxK=dR=f*{u%K{#;q+%Wfx$Pppix$M?739$umCy%>1R_Y>t%otV06g Vs6f>5L#Z~&^?BuQQd2Uv{|9lE)vo{m diff --git a/uvm/__pycache__/cli.cpython-312.pyc b/uvm/__pycache__/cli.cpython-312.pyc deleted file mode 100644 index 81b951168273d679aed7e95247fe14f9058062d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37060 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 diff --git a/uvm/__pycache__/config.cpython-312.pyc b/uvm/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 43272c1efd1dc4524a28e759b337c4a64fc1649d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9864 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 N&0m|%bTyF&{}0k`;n@HH diff --git a/uvm/__pycache__/errors.cpython-312.pyc b/uvm/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index a0033ff341aeca8a433ee804217c995f7224b210..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2818 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 T{+Ofi)1#)#Jl`ds)c5}ZKfQNS diff --git a/uvm/__pycache__/images.cpython-312.pyc b/uvm/__pycache__/images.cpython-312.pyc deleted file mode 100644 index 9c0f05211b702683a37bf20bd6cefb0bd3528b0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19772 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 diff --git a/uvm/__pycache__/integrity.cpython-312.pyc b/uvm/__pycache__/integrity.cpython-312.pyc deleted file mode 100644 index ed2fe4242b910f147d6d78a80c3636f0b4cb4cfa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7099 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 diff --git a/uvm/__pycache__/lifecycle.cpython-312.pyc b/uvm/__pycache__/lifecycle.cpython-312.pyc deleted file mode 100644 index f21e6fae4f1e2ddfdc197e9e275c731b83eba65b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42509 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 diff --git a/uvm/__pycache__/state.cpython-312.pyc b/uvm/__pycache__/state.cpython-312.pyc deleted file mode 100644 index 4c452f93a52ae8b022a6050a3bec3e95a1bc8e5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9911 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 diff --git a/uvm/__pycache__/system.cpython-312.pyc b/uvm/__pycache__/system.cpython-312.pyc deleted file mode 100644 index be6d5a28ec75cd47d30439362dee75a3ef29bf5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5103 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 diff --git a/uvm/app.py b/uvm/app.py index 53c7086..ce9ec9b 100644 --- a/uvm/app.py +++ b/uvm/app.py @@ -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, diff --git a/uvm/config.py b/uvm/config.py index c36180e..e5633b1 100644 --- a/uvm/config.py +++ b/uvm/config.py @@ -40,6 +40,7 @@ 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 @@ -66,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( @@ -148,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(), ) @@ -161,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: diff --git a/uvm/dashboard/static/css/dashboard.css b/uvm/dashboard/static/css/dashboard.css index a8986f0..50712eb 100644 --- a/uvm/dashboard/static/css/dashboard.css +++ b/uvm/dashboard/static/css/dashboard.css @@ -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; } diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html index 7520d77..c018018 100644 --- a/uvm/dashboard/static/index.html +++ b/uvm/dashboard/static/index.html @@ -171,6 +171,16 @@
A closer look

The essentials

A place in the network

+
+

Room to grow

Adjust CPU, memory, and private storage.
+

Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.

+ +

A direct connection

Private port policy

Just the connections this machine needs.

A window into the runtime

Recent Firecracker log entries.

@@ -182,6 +192,7 @@
Every idea starts somewhere

Make something new.

Back to machines

01A name and a little space

Give your new machine a shape.
Start with a size
+

02A way to connect

Private networking and guest access.

Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.

03Open the right doors

Choose which private TCP services are allowed.
diff --git a/uvm/dashboard/static/js/dashboard.js b/uvm/dashboard/static/js/dashboard.js index 915cc87..eb51115 100644 --- a/uvm/dashboard/static/js/dashboard.js +++ b/uvm/dashboard/static/js/dashboard.js @@ -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 `
${icon('server')}
${esc(machineName(vm))}${esc(vm.guest_ip || vm.id)}
- ${badge(runtimeStatus(vm))}${vcpu(vm)} vCPU${esc(formatRam(vm.ram_mib))} + ${badge(runtimeStatus(vm))}${vcpu(vm)} vCPU${esc(formatRam(vm.ram_mib))} RAM${esc(formatStorage(vm.storage_mib))} storage
${portsMarkup(vm.ports, 2)}
${badge(vm.policy_status || 'unknown')}
`; @@ -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(); }); diff --git a/uvm/firecracker/__pycache__/__init__.cpython-312.pyc b/uvm/firecracker/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 31026deff8d36b53f3377f275e9ea0b55783097c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 219 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 diff --git a/uvm/firecracker/__pycache__/config.cpython-312.pyc b/uvm/firecracker/__pycache__/config.cpython-312.pyc deleted file mode 100644 index bbc87be5c2e53924a7f531e536fe9881530ee8d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3937 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 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) diff --git a/uvm/known_hosts.py b/uvm/known_hosts.py new file mode 100644 index 0000000..a44e8b2 --- /dev/null +++ b/uvm/known_hosts.py @@ -0,0 +1,167 @@ +"""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[^ \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) or 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}") + fcntl.flock(source.fileno(), fcntl.LOCK_EX) + metadata = os.fstat(source.fileno()) + 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) diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index d86f272..b1231a4 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -19,6 +19,7 @@ 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 @@ -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,6 +65,7 @@ class LifecycleService: self._images = images self._network = network self._process = process + self._known_hosts = known_hosts self._client_factory = client_factory self._clock = clock @@ -251,7 +254,7 @@ class LifecycleService: 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, (Path(vm.disk).stat().st_size + 1024 * 1024 - 1) // (1024 * 1024)) + 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, @@ -284,7 +287,11 @@ class LifecycleService: return if self._process.observe(vm) != "gone": raise ConflictError("cannot resize a disk without confirmed process exit") - if target.storage_mib != vm.storage_mib: + # 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 @@ -301,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: