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 3ce4397..0000000 Binary files a/uvm/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/app.cpython-312.pyc b/uvm/__pycache__/app.cpython-312.pyc deleted file mode 100644 index fb8baf7..0000000 Binary files a/uvm/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/cli.cpython-312.pyc b/uvm/__pycache__/cli.cpython-312.pyc deleted file mode 100644 index 81b9511..0000000 Binary files a/uvm/__pycache__/cli.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/config.cpython-312.pyc b/uvm/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 43272c1..0000000 Binary files a/uvm/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/domain.cpython-312.pyc b/uvm/__pycache__/domain.cpython-312.pyc deleted file mode 100644 index 778ab72..0000000 Binary files a/uvm/__pycache__/domain.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/errors.cpython-312.pyc b/uvm/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index a0033ff..0000000 Binary files a/uvm/__pycache__/errors.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/firewall.cpython-312.pyc b/uvm/__pycache__/firewall.cpython-312.pyc deleted file mode 100644 index 117d3e6..0000000 Binary files a/uvm/__pycache__/firewall.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/images.cpython-312.pyc b/uvm/__pycache__/images.cpython-312.pyc deleted file mode 100644 index 9c0f052..0000000 Binary files a/uvm/__pycache__/images.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/install.cpython-312.pyc b/uvm/__pycache__/install.cpython-312.pyc deleted file mode 100644 index 236c44c..0000000 Binary files a/uvm/__pycache__/install.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/integrity.cpython-312.pyc b/uvm/__pycache__/integrity.cpython-312.pyc deleted file mode 100644 index ed2fe42..0000000 Binary files a/uvm/__pycache__/integrity.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/lifecycle.cpython-312.pyc b/uvm/__pycache__/lifecycle.cpython-312.pyc deleted file mode 100644 index f21e6fa..0000000 Binary files a/uvm/__pycache__/lifecycle.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/network.cpython-312.pyc b/uvm/__pycache__/network.cpython-312.pyc deleted file mode 100644 index 0c1d9c9..0000000 Binary files a/uvm/__pycache__/network.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/state.cpython-312.pyc b/uvm/__pycache__/state.cpython-312.pyc deleted file mode 100644 index 4c452f9..0000000 Binary files a/uvm/__pycache__/state.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/system.cpython-312.pyc b/uvm/__pycache__/system.cpython-312.pyc deleted file mode 100644 index be6d5a2..0000000 Binary files a/uvm/__pycache__/system.cpython-312.pyc and /dev/null differ diff --git a/uvm/__pycache__/validation.cpython-312.pyc b/uvm/__pycache__/validation.cpython-312.pyc deleted file mode 100644 index 1685c23..0000000 Binary files a/uvm/__pycache__/validation.cpython-312.pyc and /dev/null differ 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 @@
Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.
+ +