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