From d8a25209b916ccc885153613fa852dd4a0260786 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 9 Sep 2026 17:27:50 +0530 Subject: [PATCH 1/5] Refactor VM lifecycle management and enhance validation - Introduced a new method `installed_kernel` in `ImageStore` to handle kernel verification and checksum validation. - Updated `Installer` to include additional packages: `nftables` and `conntrack`. - Refined `LifecycleService` to improve error handling and state management during VM creation, starting, stopping, and deletion. - Enhanced port management with new methods for setting and syncing ports, including validation for port rules. - Improved error handling in `StateStore` to raise specific `NotFoundError` and `ConflictError` exceptions. - Updated network management to ensure proper TAP device handling and firewall integration. - Enhanced API responses in `vms.py` to include policy status for better client-side handling. - Added validation for VM names and ports in `validation.py` to enforce stricter rules and prevent conflicts. --- DEVELOPER.md | 680 +++++++----- FIREWALL.md | 735 +++++++++++++ README.md | 259 +++-- USAGE.md | 828 +++++++++------ tests/integration/test_private_ports.py | 421 ++++++++ tests/test_cli.py | 11 + tests/test_cli_commands.py | 886 ++++++++++++++++ tests/test_firewall.py | 926 ++++++++++++++++ tests/test_lifecycle.py | 635 +++++++++-- tests/test_lifecycle_commands.py | 493 +++++++++ tests/test_network.py | 388 ++++++- tests/test_process.py | 1291 ++++++++++++++++++++++- tests/test_server.py | 464 ++++++-- tests/test_state.py | 245 ++++- tests/test_system.py | 85 +- tests/test_validation.py | 106 +- uvm/api_models.py | 46 +- uvm/cli.py | 531 +++++++++- uvm/domain.py | 75 +- uvm/errors.py | 8 + uvm/firecracker/process.py | 464 ++++++-- uvm/firewall.py | 496 +++++++++ uvm/images.py | 24 +- uvm/install.py | 2 + uvm/lifecycle.py | 629 +++++++---- uvm/network.py | 67 +- uvm/routers/vms.py | 35 +- uvm/server.py | 13 +- uvm/state.py | 27 +- uvm/system.py | 3 +- uvm/validation.py | 34 + 31 files changed, 9576 insertions(+), 1331 deletions(-) create mode 100644 FIREWALL.md create mode 100644 tests/integration/test_private_ports.py create mode 100644 tests/test_cli_commands.py create mode 100644 tests/test_firewall.py create mode 100644 tests/test_lifecycle_commands.py create mode 100644 uvm/firewall.py diff --git a/DEVELOPER.md b/DEVELOPER.md index 0f7c769..c4bead8 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -10,60 +10,53 @@ general-purpose Python library or a multi-host control plane. ## 1. Goals and Boundaries -UVM currently provides a small host-local workflow: +UVM installs host/guest assets, provisions private disks, and manages named +VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk +start/stop/restart, deletion, fixed-target bulk operations, inspection, SSH, +logs, doctor, and explicit port edits/sync. The optional API exposes only the +existing health/install/create/list/inspect/stop/delete operations. -1. Install Firecracker, a guest kernel, and a rootfs template. -2. Allocate an ID, private IPv4 address, MAC address, TAP device, and VM - runtime directory. -3. Copy the rootfs template to a private writable VM disk and provision the - requested existing guest account password. -4. Configure and launch one Firecracker process. -5. List, stop, SSH to, and destroy the VM. -6. Optionally expose those operations through an authenticated FastAPI API. - -The design deliberately does not provide these capabilities yet: - -- Multi-host scheduling or remote state coordination. -- User tenancy, quotas, or a public-facing control plane. -- A `start` or `restart` command for stopped VMs. -- Guest snapshots, extra disks, port forwarding, or DHCP. -- Cgroup CPU enforcement for fractional CPU requests. -- Jailer-based Firecracker execution, namespaces, or seccomp hardening. -- Production-grade image building or guest SSH-key injection. +There is no autostart or background reconciler, multi-host control plane, +tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port +filtering is not public-port publishing or a hardened multi-tenant boundary; +see Current Production Gaps for the remaining scope limits. Treat UVM as a privileged local systems tool. It manipulates `/dev/kvm`, TAP -interfaces, bridge addresses, iptables rules, host filesystems, and processes. +interfaces, bridge addresses, nftables/conntrack and existing iptables egress +rules, host filesystems, and processes. For the complete operator command and +flag reference, see [USAGE.md](USAGE.md). ## 2. First Run All UVM-specific files live in this directory. Start here: ```sh -cd uvm python3 -m pip install -e '.[test]' -PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v +python3 -B -m unittest discover -s tests -v ./uvm.py --help ``` The `uvm` console script is registered by `pyproject.toml`. During source -development, `./uvm.py` and `python3 -m uvm` are equivalent entry points. +development, `./uvm.py` and `python3 -B -m uvm` are equivalent entry points. Requirements for real host operations: - Python 3.12+ - Linux with KVM enabled and `/dev/kvm` accessible +- Linux >= 5.3 with native bridge conntrack and safe pidfd support - Root privileges for installation and lifecycle mutations -- `iproute2`, `iptables`, `e2fsprogs`, `util-linux`, `openssl`, and OpenSSH - client tools +- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, + `openssl`, and OpenSSH client tools - An x86_64 host for the default kernel/rootfs URLs Do not run real integration operations on a developer workstation unless the -bridge, subnet, iptables changes, and guest workload are acceptable there. +bridge, subnet, firewall changes, and guest workload are acceptable there. +Packet integration tests must remain inside disposable isolated namespaces. ## 3. Project Layout ```text -uvm/ +./ ├── DEVELOPER.md # This document ├── README.md # Operator-facing usage guide ├── pyproject.toml # Package metadata and dependencies @@ -73,16 +66,17 @@ uvm/ │ ├── cli.py # argparse, CLI rendering, serve dispatch │ ├── app.py # Composition root │ ├── config.py # Settings and environment loading -│ ├── domain.py # VmSpec, VmRecord, State +│ ├── domain.py # PortRule, VmSpec, VmRecord, State │ ├── errors.py # Expected error hierarchy -│ ├── validation.py # CPU and RAM parsing +│ ├── validation.py # Resources, names, credentials, ports, timeouts │ ├── system.py # CommandRunner, root/KVM/directory checks │ ├── state.py # JSON state, atomic writes, file locks │ ├── integrity.py # SHA-256 and integrity manifest logic │ ├── install.py # Apt, Firecracker, kernel, rootfs installation │ ├── images.py # Template lookup, private disks, and guest credentials │ ├── network.py # IP/MAC/TAP/bridge/NAT/forward rules -│ ├── lifecycle.py # Create/list/stop/destroy orchestration +│ ├── firewall.py # Owned nft bridge tables and offline conntrack cleanup +│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent │ ├── api_models.py # Pydantic HTTP request/response models │ ├── server.py # FastAPI factory and Uvicorn launcher │ ├── routers/ @@ -94,7 +88,7 @@ uvm/ │ ├── config.py # Firecracker JSON and boot arguments │ ├── api.py # Unix-socket HTTP client │ └── process.py # Process launch, liveness, termination -└── tests/ # Unit tests and conditional API tests +└── tests/ # Unit/API tests; integration/ has opt-in namespace packets ``` Keep feature code in the most specific existing module. Do not add a new @@ -112,9 +106,11 @@ flowchart LR APP --> STATE[StateStore] LIFE --> IMAGES[ImageStore] LIFE --> NET[NetworkManager] + NET --> FW[FirewallManager] LIFE --> FC[Firecracker adapters] INSTALL --> RUNNER[CommandRunner] NET --> RUNNER + FW --> RUNNER FC --> HOST[Linux host primitives] RUNNER --> HOST STATE --> DISK[(state.json)] @@ -126,29 +122,6 @@ The public entry points are thin adapters. The lifecycle layer is the only place that coordinates multiple host resources for a VM. Lower-level modules must not import `cli.py`, `server.py`, or `lifecycle.py`. -### Dependency Direction - -```text -cli.py / server.py / routers/ - | - v - app.py - | - +-------+--------+ - | | - install.py lifecycle.py - | - +------------+------------+ - | | | - state.py network.py images.py - | - firecracker/* - | | | - +------ system.py --------+ - | - Linux host -``` - Leaf modules are `config.py`, `domain.py`, `errors.py`, and `validation.py`. They must remain free of subprocess, network, CLI, and FastAPI side effects. @@ -156,45 +129,37 @@ They must remain free of subprocess, network, CLI, and FastAPI side effects. ### CLI Path -```mermaid -sequenceDiagram - participant U as User - participant C as cli.py - participant A as app.py - participant L as LifecycleService - participant H as Linux host +`cli.py` builds the application, validates arguments, and calls lifecycle +services. It owns confirmation, terminal/JSON output, exit handling, SSH +`execvp`, local log streaming from a validated path, and `--serve` dispatch. +It must not coordinate state files, interfaces, or Firecracker sockets itself. - U->>C: uvm create --cpu 1 --ram 512 - C->>A: build_application() - A->>L: composed service graph - C->>L: create(VmSpec) - L->>H: allocate files, TAP, process, Firecracker API - L-->>C: VmRecord - C-->>U: formatted VM details -``` +The local surface is install/create/list/inspect/start/stop/restart/delete +(`destroy` alias)/ssh/logs/doctor and ports list/open/close/set/sync. Identifiers +resolve by exact ID, name, or guest IP, never an ID prefix. Generated-ID-shaped +inputs resolve only by ID; names cannot impersonate generated IDs. Ambiguous +non-ID aliases raise `ConflictError`. -`cli.py` owns parsing, terminal output, exit handling, SSH `execvp`, and the -`--serve` switch. It must not manipulate state files, interfaces, or -Firecracker sockets directly. +Create alone adds `22:TCP` unless `--no-default-ports` is supplied. Port syntax +is `PORT[:TCP|HTTP|HTTPS]`, comma-separated or repeated via `--ports`; all labels +use TCP. `--password-stdin` reads one line instead of using a password argument. +`_vm_output()` is an explicit output whitelist, not `VmRecord.to_dict()`. +JSON stays on stdout, while warnings, prompts, and host traces use stderr. + +Bulk lifecycle actions and ports sync resolve/deduplicate IDs before prompting. +Delete and mutating `--all` selections require a TTY confirmation or `--yes`/`-y`. +Port set/close-all and removal of TCP/22 also prompt, except empty managed +no-ops. `--dry-run` previews without lifecycle/policy mutations or confirmation; +it is not a full preflight. Stop/restart/delete accept `--timeout`; start does +not. Logs follow does not hold the operation lock. Doctor runs readiness +checks, not per-VM repair or guest application probes. ### HTTP Path -```mermaid -sequenceDiagram - participant Client - participant Uvicorn - participant API as server.py / routers - participant L as LifecycleService - participant H as Linux host - - Client->>Uvicorn: HTTP(S) request + X-UVM-Token - Uvicorn->>API: route dispatch - API->>API: token dependency and Pydantic validation - API->>L: lifecycle operation - L->>H: host mutation - L-->>API: VmRecord or UvmError - API-->>Client: JSON response or JSON error -``` +Routers validate the token and Pydantic request, invoke application services, +and render whitelisted response models. The CLI expansion does not imply HTTP +endpoint parity: start/restart, port edits/sync, bulk operations, logs, doctor, +and interactive SSH have no HTTP endpoints. Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The `create_api()` factory requires an explicit host specifically to prevent it @@ -212,13 +177,15 @@ process: | StateStore | `StateStore` | Inventory, locks, JSON persistence | | ImageStore | `ImageStore` | Template verification, VM disks, and offline credentials | | NetworkManager | `NetworkManager` | Guest allocation and Linux networking | +| FirewallManager | Constructed by `NetworkManager` | Owned native bridge policy and scoped offline conntrack cleanup | | FirecrackerProcessManager | `FirecrackerProcessManager` | VMM process control | | LifecycleService | `LifecycleService` | Ordered VM resource changes and rollback | | Installer | `Installer` | Host packages and artifacts | -The FastAPI factory calls `build_application(emit=None)` so API-driven host -commands do not print shell-style command traces to the server output. The CLI -uses the default emitter and prints commands as `+ ...` for transparency. +Composition can disable command tracing with `emit=None`; `create_api()` does +so when it constructs its own application. The CLI supplies an emitter that +prints `+ ...` traces to stderr, including when passing its application to +the server launcher. ## 7. Persistent Data and Models @@ -253,42 +220,38 @@ state file. ### State Schema -[`domain.py`](uvm/domain.py) is the schema authority. The current state -version is `1`. +[`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: ```json { - "schema_version": 1, - "next_mac_index": 2, - "vms": { - "vm-": { - "id": "vm-", - "cpu": 1.0, - "ram_mib": 512, - "guest_ip": "10.42.0.2", - "gateway": "10.42.0.1", - "tap": "uvm-...", - "mac": "02:fc:00:00:00:01", - "socket": "/var/lib/uvm/vms/.../firecracker.sock", - "config": "/var/lib/uvm/vms/.../config.json", - "log": "/var/lib/uvm/vms/.../firecracker.log", - "disk": "/var/lib/uvm/vms/.../rootfs.ext4", - "username": "root", - "password": "replace-me", - "status": "running", - "pid": 1234, - "process_start_time": "...", - "created_at": 0, - "updated_at": 0, - "last_error": null - } - } + "schema_version": 2, + "next_mac_index": 1, + "vms": {} } ``` `VmSpec` is the validated create request. `VmRecord` is the persisted local record. Do not make API models or CLI namespaces the persistence format. +| Record Fields | Purpose | +|---|---| +| `id`, `name`, `cpu`, `ram_mib` | Stable identity, optional unique name, resource request | +| `guest_ip`, `gateway`, `network`, `bridge`, `tap`, `mac` | Retained network identity/configuration | +| `socket`, `config`, `log`, `disk` | Canonical per-VM runtime paths | +| `username`, `password` | Guest credentials; password stays out of public output | +| `ports` | Null for legacy/unmanaged, or a list of `{ "port": 80, "protocol": "HTTP" }` objects | +| `disk_ready`, `runtime_owned`, `tap_owned` | Completed provisioning and resource ownership evidence | +| `status`, `operation`, `spawn_pending`, `last_error` | Durable state, pending intent, ambiguous-spawn guard, failure detail | +| `pid`, `process_start_time`, `boot_id` | VMM identity, including host reboot detection | +| `created_at`, `updated_at` | Registry timestamps | + +`PortRule` accepts TCP/HTTP/HTTPS labels and integer ports 1-65535. A port +number is unique regardless of label. `VmSpec.ports` defaults to TCP/22, +whereas `VmRecord.ports=None` deliberately preserves the legacy distinction. +An explicit empty tuple/list is a managed empty TCP allowlist, not unmanaged +mode. There is no persisted boolean asserting that a policy is enforced. + ### State Guarantees - `state.json` writes use a sibling temporary file, `fsync`, atomic replace, @@ -296,11 +259,18 @@ record. Do not make API models or CLI namespaces the persistence format. - `state.json` is mode `0600`; it must be treated as a credential registry and excluded from logs and bug reports. - `state.lock` serializes short JSON read-modify-write transactions. -- `operations.lock` serializes long lifecycle 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 complete old file or a complete new file. -- Legacy state without a schema version is accepted as version `0`; missing - `disk` and `process_start_time` fields have safe defaults. +- Legacy versions 0 (including unversioned state) and 1 are loaded into schema + 2 in memory and written as schema 2 on the next transaction. Newer schemas + are rejected. Reads do not reconcile VMs or adopt process identities. +- Missing `name`, `ports`, and `boot_id` remain null. No policy or default SSH + rule is silently imposed on a legacy VM. Use stop, explicit `ports set`, + then start; first policy adoption requires a stopped, proven-gone VM. +- Missing `disk` and process start time remain absent. Legacy `disk_ready` is + inferred only for running/stopped records with a recorded private disk; + start still validates the path, file, and provisioning state. - Records created before credential provisioning default to username `root` and password `null`; UVM does not apply a password retroactively. - `next_mac_index` is reconstructed from older locally administered MACs when @@ -324,62 +294,108 @@ The persisted status is a string, not an enum, to keep legacy JSON readable. | `stopped` | Process/TAP removed; private disk remains | | `terminating` | Destruction is in progress | | `failed` | A lifecycle step or cleanup failed; `last_error` explains why | -| `dead` | Observed-only list status when a supposedly active process is gone | +| `dead` | Observed-only status when a stored running process is proven gone | +| `unknown` | Observed process identity cannot be established safely | -`dead` is not persisted. `destroy` removes the record rather than retaining a tombstone. +Transition statuses can survive interruption. `dead`/observed `unknown` do +not rewrite inventory. Delete removes the record rather than keeping a +tombstone. Pending deletion remains authoritative even if a subsequent stop +finishes runtime cleanup; start and policy edits must not resurrect it. + +`ListedVm` separates the record, observed process status, and observed policy +status. Policy can be `enforced`, `missing`, `drift`, `quarantined`, `unknown`, +legacy `unmanaged`, or `inactive` for a cleanly stopped managed VM. Readback is +a ruleset/attachment snapshot, not application health or a reachability test. ### Create Order [`LifecycleService.create`](uvm/lifecycle.py) follows this sequence: -```mermaid -flowchart TD - A[Validate root, KVM, binary, integrity] --> B[Acquire operations.lock] - B --> C[Reserve ID, IP, MAC, and starting state] - C --> D[Create 0700 runtime directory] - D --> E[Copy template to private rootfs.ext4] - E --> F[Provision guest password and SSH policy] - F --> G[Ensure bridge, NAT, and forwarding rules] - G --> H[Create TAP] - H --> I[Write Firecracker config atomically] - I --> J[Spawn Firecracker and wait for API socket] - J --> K[Persist PID and process start token] - K --> L[Configure Firecracker through Unix HTTP] - L --> M[Persist running state] -``` +1. Validate the request/root access and acquire `operations.lock`. +2. Check KVM, verified Firecracker, firewall capability, and installed templates. +3. Reserve ID/name/IP/MAC/ports with `status=starting`, `operation=preparing`. +4. Create the owned mode-0700 runtime directory, copy a mode-0600 private disk, + provision credentials/host keys, and persist `disk_ready=True`. +5. Ensure bridge/egress networking and prepare an owned, attached TAP **down**. +6. Apply/read back the nftables bridge policy, then activate the TAP. +7. Write Firecracker config; persist `operation=spawning`, `spawn_pending=True`, + and the host boot ID before `Popen`. +8. Publish PID/start time/boot ID through `on_spawn` before socket readiness + or API configuration; clear the pending-spawn guard. +9. Configure Firecracker and `InstanceStart`, verify policy enforcement again, + then save `running` with no pending operation. -The record is persisted as `starting` before host side effects. The PID and -Linux process-start token are persisted before Firecracker API configuration. -This makes cleanup and later diagnosis possible if API configuration fails. +This records both intent and acquired ownership across crash windows. Never +make a managed TAP usable before enforcement or mark a partial disk ready. ### Rollback Rules Any `Exception`, `KeyboardInterrupt`, or `SystemExit` during create triggers compensation: -1. Terminate a started Firecracker process when its identity is known. -2. Remove the TAP when it was created. -3. Remove the VM runtime directory and private disk. -4. Remove the reserved state record if cleanup succeeds. -5. Preserve a `failed` record with `last_error` if cleanup itself fails. +1. Attempt quarantine/TAP deactivation and confirm safe process exit. +2. Remove the owned TAP, clean up scoped conntrack entries, and remove its policy. +3. Remove the owned runtime directory/private disk only after confirmed exit. +4. Remove the reservation only if cleanup succeeds. +5. Otherwise retain a `failed` record with identity/intent and `last_error`; + do not release its disk or IP for reuse. Do not reorder these operations without considering leaked TAPs, orphaned VMM processes, and allocation reuse. -### Stop and Destroy +### Start and Restart + +Start requires managed ports, a proven-gone process (or an already identified +running VM), a complete safe private disk, and compatible retained gateway, +network, and bridge settings. It verifies the VMM and installed **kernel**; +the original rootfs template is not needed. It never recopies the rootfs, +reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC, +ports, and guest disk changes survive stop/start; guest RAM does not. + +Start on a healthy running VM synchronizes policy without spawning another +VMM. An ambiguous process or interrupted live boot/stop is not adopted as a +successful start. Failed resume retains the private disk for retry. Restart +performs stop and start under one operation lock, not two independent public +calls with a race between them. + +### Stop and Delete ```text stop: - running/failed/stopped -> stopping -> terminate process -> delete TAP -> stopped - private disk and config remain + persist stop intent -> quarantine/deactivate TAP -> confirm VMM exit + -> delete owned TAP -> scoped conntrack cleanup -> remove owned nft table + -> stopped (retain disk, credentials, name/IP and desired ports) -destroy: - any non-transition state -> terminating -> terminate process -> delete TAP - -> remove VM runtime directory -> remove state record +delete (destroy alias): + persist delete intent -> same runtime cleanup -> remove owned runtime directory + -> remove state record -> release name/IP ``` -`stop` is intentionally disk-preserving. There is no public `start` command -yet, so do not add start behavior by modifying stop semantics. +Stop signals the **VMM**, not a guest shutdown agent or ACPI-coordinated +shutdown. SIGTERM is followed by SIGKILL when needed. `--timeout` is the +per-VM TERM wait, default 5 seconds, finite and non-negative; it is not a total +deadline. Even zero still requires confirmed kernel exit before cleanup. +Application consistency requires separately quiescing the guest. + +Runtime cleanup retains reservations when process exit, TAP ownership, or +connection cleanup is uncertain. A pending spawn cannot be declared gone +merely because its PID was never persisted or its socket has not appeared. + +### Policy and Bulk Operations + +`set_ports()` persists the new allowlist with `operation=policy` before +applying it. Live updates apply atomically and retain conntrack; cleanly +stopped edits are metadata-only. On apply/readback failure, preserve the new +intent, attempt link-down/quarantine, and leave a failed pending record. Never +restore an older, more permissive allowlist after a failed close. An explicit +`ports sync` retries the desired policy and can reactivate a contained live VM. + +`run_many()` holds one outer operation lock while processing the fixed ID +snapshot produced before CLI confirmation. It reloads by **exact ID**, not a +reusable name/IP. Results are `succeeded`, `failed`, `skipped` (already absent), +or `interrupted`; ordinary failures do not undo other successes or stop later +targets. Interruptions skip the remaining targets. Read-only views and API +startup never reconcile; there is no autostart or background repair loop. ## 9. Firecracker Integration @@ -406,14 +422,30 @@ comes from `Settings.network`, not a hard-coded `/24` value. ### Process Safety -Firecracker starts detached in a new session, with stdout/stderr redirected to -the per-VM log. The manager records `/proc//stat` start time and only -signals a process when its persisted token still matches. This prevents a stale -state record from killing an unrelated process after PID reuse. +Firecracker starts detached in a new session, appending stdout/stderr to the +per-VM log. Identity consists of PID, `/proc//stat` start time, and the +host boot ID. `observe()` distinguishes `alive`, `gone`, and `unknown`; +unreadable evidence is not proof of exit. A changed boot ID or start token can +prove the old process gone without signaling a reused PID. -Legacy records with a PID but no start token are only cleaned up automatically -when that PID is already gone. If it remains alive, UVM fails closed and asks -the operator to inspect it. +Start probes safe pidfd support before spawning. Termination opens a pidfd, +rechecks identity, signals through that handle (never a bare PID), and confirms +whole-process exit before unlinking the socket. Python bindings or libc +bindings may supply pidfd support; there is no unsafe PID-signal fallback. +A zombie leader alone does not prove its worker threads have exited. + +Legacy adoption is deliberately narrow. During a locked mutation, +`adopt_legacy_identity()` may fill a missing boot ID only when all evidence +matches: a saved numeric start token, a live pidfd-pinned process, the configured +Firecracker executable path **and inode**, the exact `--api-sock` command line, +and root-owned/protected binary, socket, and ancestor directories. It checks +that the process has not exited before returning, and the lifecycle layer +persists the adopted boot ID. Listing/inspection never adopts it. + +A live legacy PID without a saved start token, or with mismatched/inaccessible +binary/socket evidence, remains unknown and fails closed. UVM does not guess +an identity from the current occupant of a PID or delete an in-use disk to +make recovery convenient. Proven-gone legacy processes can be cleaned up. ## 10. Guest Images and Integrity @@ -451,6 +483,10 @@ writes a manifest marked `verified: false`; a later strict run will not treat that manifest as publisher trust. Use `install --force` with trusted digests to refresh an existing artifact without manually deleting managed files. +Retain the explicit opt-out on create/start/restart (or the API server) when +using an unverified development installation. It is not appropriate for +untrusted workloads. A bare strict install requires the trusted digests. + The optional `UVM_FIRECRACKER_BINARY_SHA256` validates a local Firecracker binary when no verified manifest is available. @@ -474,7 +510,8 @@ host default uplink internet ``` -[`network.py`](uvm/network.py) owns all networking details: +[`network.py`](uvm/network.py) owns address allocation and host attachments; +[`firewall.py`](uvm/firewall.py) owns native bridge filtering: - IP allocation from `Settings.network`, excluding network, broadcast, and gateway addresses. @@ -484,16 +521,89 @@ internet Linux bridge. - Gateway address setup and IPv4 forwarding. - One egress NAT rule and forward/return rules for the default uplink. -- TAP creation, cleanup, and error reporting. +- TAP ownership aliases (`uvm:`), preparation while down, activation, + deactivation, cleanup, and error reporting. Network actions are idempotent where practical. Do not adopt an existing TAP: -it may belong to another VM. A name collision is an error. +it may belong to another VM. A create-time name collision is an error. Cleanup +checks the TAP kind and ownership alias, with a narrow unaliased-TAP allowance +for legacy records; it does not treat an arbitrary interface name as ownership. + +### Native Bridge Policy + +The supported path is a **software Linux bridge** with STP disabled. Each +managed TAP's bridge-slave `group_fwd_mask` must include `0x4000` (the network +layer sets 16384). This sends LLDP through the early filter instead of a +pre-hook delivery/learning path. Firewall apply/status checks the bridge and +port settings when the TAP exists. Do not use hardware/flow offloads or other +paths that bypass these hooks. This is not an offload-isolation guarantee. + +Native bridge conntrack requires Linux >= 5.3. Capability checks run +`conntrack --version`, inspect the kernel release, and ask `nft --json --check` +to validate a full policy without committing it. The nft CLI must support +the rendered JSON. This layer needs no `br_netfilter` sysctl changes. + +Each VM owns one `bridge` table named `uvm_fw_`, derived from the +resolved `UVM_BASE` and exact VM ID, with a full ownership comment. Apply uses +an atomic nft JSON transaction, delete-by-handle for the old owned table, and +exclusive create for its replacement. Foreign ownership is an error, not an +adoption opportunity. Readback normalizes presentation differences but compares +all effective objects, ordered rules, hooks, identity, and the full port set; +rule counts or an owner comment alone are not proof of enforcement. + +The policy has prerouting/input/forward/output hooks. Early prerouting identity +checks run at priority -310, before conntrack and bridge MAC learning; input +paths recheck identity as well. Managed traffic is bound to the recorded MAC +and private IPv4 address. Spoofed identity, IPv6, VLAN encapsulation, and other +unsupported EtherTypes are dropped. Host-to-guest and peer-to-guest paths are +covered, not just routed IP forwarding. + +The current TCP allowlist is checked against conntrack's **original service +destination address/port in both directions**, including established service +flows. There is no blanket established/related accept before it. Consequently +close blocks subsequent packets on existing service connections in both +directions after commit, without promising a TCP RST or immediate application +disconnect. Live close does not flush conntrack, which would risk +reclassifying midstream service traffic as guest egress. + +Guest-initiated IPv4 TCP/UDP and replies remain allowed, including outbound DNS. +ARP, ICMP echo, and necessary related ICMP errors are supported. Other inbound +UDP services are not; HTTP/HTTPS are only TCP labels. Empty managed ports +means no inbound TCP service allowance, not quarantine or loss of all egress. +Quarantine drops every scoped TAP path, including ARP. + +Offline cleanup requires the TAP to be absent. It deletes and verifies absence +of conntrack entries scoped to the guest IP in all four original/reply source +and destination fields, including NAT reply tuples. Only then may teardown +release the address. Rule removal also requires an absent TAP and verified +table ownership; it never flushes global firewall or conntrack state. + +### Coexistence and Recovery + +Existing `iptables` egress MASQUERADE/forwarding remains unchanged. The new +policy adds no DNAT, public listener, or host-port mapping and does not replace +the host firewall manager. Public connectivity, DNS, guest reverse proxies/TLS, +and integration with UFW/firewalld/Docker/libvirt remain out of scope. An +administrator's external routing/NAT can still affect reachability; do not +infer source-CIDR restrictions or complete public-exposure protection from the +use of private addresses. Operators must choose a non-conflicting topology. + +If an external actor loses or changes UVM rules, recovery is explicit +`ports sync`, not a promised daemon repair. An `enforced` snapshot cannot +prevent later root-level changes or prove that other rules allow traffic. +[FIREWALL.md](FIREWALL.md) retains the original proposal/backend evaluation; +do not import its unimplemented topology checks into current documentation. ## 12. Installer and Host Commands `Installer` is the only owner of apt installation, Firecracker artifact handling, downloads, and integrity-manifest publication. +The host package list includes `nftables` and `conntrack` as well as the +existing iproute2/iptables, ext4, OpenSSL, OpenSSH, download, and util-linux +tools. UVM adds no reconciliation daemon or tested external firewall-manager +integration profile. + `system.py` centralizes host command execution: - `CommandRunner.run()` converts subprocess failures into `CommandError`. @@ -513,27 +623,12 @@ Python's safe tar extraction filter. ### Server Startup -```sh -cd uvm -python3 -m pip install . - -export UVM_API_TOKEN='long-random-visible-ascii-token' -sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \ - ./uvm.py --serve --host 127.0.0.1 --port 8000 -``` - -For non-loopback binds, both TLS variables are required and loaded into an -`ssl.SSLContext` before Uvicorn starts: - -```sh -export UVM_API_TLS_CERT='/etc/uvm/api-cert.pem' -export UVM_API_TLS_KEY='/etc/uvm/api-key.pem' -sudo env \ - UVM_API_TOKEN="$UVM_API_TOKEN" \ - UVM_API_TLS_CERT="$UVM_API_TLS_CERT" \ - UVM_API_TLS_KEY="$UVM_API_TLS_KEY" \ - ./uvm.py --serve --host 0.0.0.0 --port 8443 -``` +See [USAGE.md](USAGE.md) for dependency installation, loopback deployment, +TLS deployment, and authenticated curl examples. Launch through `uvm --serve` +using the Python environment with the installed API dependencies. Every server +requires `UVM_API_TOKEN`, even at `127.0.0.1`. Non-loopback binds additionally +require `UVM_API_TLS_CERT` and `UVM_API_TLS_KEY`; paired certificate/key files +are loaded into an `ssl.SSLContext` before Uvicorn starts. The token must contain visible ASCII characters without whitespace. It is never accepted as a UVM command-line argument, so it does not appear in UVM @@ -548,10 +643,10 @@ or secret manager when that matters. | `GET` | `/health` | `health.py` | Unauthenticated liveness response | | `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | -| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, optional `guest_ip`, `username`, and `password` | -| `GET` | `/vms/{id-or-ip}` | `vms.py` | Lookup by ID or guest IP | -| `POST` | `/vms/{id}/stop` | `vms.py` | Stop while retaining private disk | -| `DELETE` | `/vms/{id}` | `vms.py` | Remove runtime files and state | +| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP | +| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop while retaining private disk | +| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files and state | All management routes use `get_authorized_application()` and require: @@ -561,29 +656,31 @@ X-UVM-Token: Request schemas reject unexpected fields. `UvmError` instances are rendered as JSON errors by `server.py`; Pydantic request validation uses FastAPI's normal -422 response format. VM responses include the username but never the password. +422 response format. Create accepts `name: str | null`, `ports: list | null`, +and strict boolean `no_default_ports` (default false). Port objects contain a +strict integer `port` in 1-65535 and uppercase `protocol` TCP/HTTP/HTTPS (TCP +default). The router normalizes/deduplicates ports and adds TCP/22 unless +default ports are disabled, even for null/empty input. It never creates an +unmanaged VM from an omitted policy. Names share CLI validation. -The API intentionally does not expose interactive SSH, raw Firecracker calls, -arbitrary command execution, or host path access. +VM responses include `name`, `ports`, `policy_status`, and `operation` alongside +resource/status fields and username. The schema excludes password and +disk/socket/config/log path fields; error messages may still contain paths. +`ports: null` in a legacy response still means unmanaged. The existing +stop/delete routes use service defaults; no timeout/dry-run/bulk HTTP schemas +were added. Typed not-found/conflict errors map to 404/409. + +There are **no new start/restart, ports, or bulk HTTP endpoints**. The API also +does not expose logs, doctor, interactive SSH, raw Firecracker calls, arbitrary +command execution, or host path access. Public `/docs`, `/redoc`, and +`/openapi.json` are informational, not privileged management routes. ## 14. Configuration Reference -| Variable | Default | Used by | -|---|---|---| -| `UVM_BASE` | `/var/lib/uvm` | Storage layout | -| `UVM_NETWORK` | `10.42.0.0/24` | IP allocation and bridge subnet | -| `UVM_GATEWAY` | `10.42.0.1` | Bridge and guest boot args | -| `UVM_BRIDGE` | `uvm0` | Linux bridge name | -| `UVM_KERNEL_URL` | Firecracker quickstart URL | Guest kernel download | -| `UVM_ROOTFS_URL` | Firecracker quickstart URL | Guest rootfs download | -| `UVM_FIRECRACKER_SHA256` | unset | Trusted release archive checksum | -| `UVM_KERNEL_SHA256` | unset | Trusted kernel checksum | -| `UVM_ROOTFS_SHA256` | unset | Trusted rootfs checksum | -| `UVM_FIRECRACKER_BINARY_SHA256` | unset | Optional direct binary checksum | -| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | `false` | Development-only integrity bypass | -| `UVM_API_TOKEN` | unset | Required API bearer token | -| `UVM_API_TLS_CERT` | unset | TLS certificate for remote API bind | -| `UVM_API_TLS_KEY` | unset | TLS key for remote API bind | +The environment-variable table is in [README.md](README.md#configuration); +[`config.py`](uvm/config.py) is the implementation authority. Keep the same +base/network/gateway/bridge settings across CLI and server operations. The +network identity is retained per VM and checked before resume. `Settings` validates the gateway, bridge-name length, API token format, and environment boolean values at process startup. Add configuration fields there, @@ -598,11 +695,17 @@ Expected failures inherit from `UvmError`: | `ConfigurationError` | Invalid local settings or environment | | `ValidationError` | Invalid CLI/API resource input | | `StateError` | Unsafe or malformed persisted state | +| `NotFoundError` | Missing registry entry | +| `ConflictError` | Ambiguous identifier, allocation conflict, or unsafe/pending operation | | `CommandError` | Required host command failure | | `FirecrackerError` | VMM process or Unix API failure | | `TapCreationError` | TAP setup failed after interface creation | CLI code catches `UvmError` and prints `uvm: error: ...` without a traceback. +Invalid CLI input returns 2, ordinary operational failure 1, and interruption +130. Batch JSON retains per-target outcomes rather than pretending a partial +success was atomic. + The FastAPI server maps the same class to an error envelope. New expected operational failures should use this hierarchy rather than leaking raw `OSError`, `CalledProcessError`, or implementation-specific exceptions. @@ -611,22 +714,26 @@ operational failures should use this hierarchy rather than leaking raw These invariants are critical: -1. Only `LifecycleService` may coordinate VM state, disks, TAPs, and - Firecracker as one operation. +1. Only `LifecycleService` may coordinate VM state, disks, TAPs, firewall, + and Firecracker as one operation. 2. Only `StateStore` may read/write `state.json`. 3. All long host mutations acquire `operations.lock`. 4. All short inventory mutations acquire `state.lock`. 5. The installer shares `operations.lock` with lifecycle actions, preventing a Firecracker binary replacement between verification and launch. 6. A VM rootfs is private before it becomes writable. -7. A PID is not signaled unless its persisted start token still matches - `/proc//stat`. -8. A new VM never adopts a pre-existing TAP. +7. Process signaling requires a safe pidfd and matching persisted start/boot + identity; unknown is not gone. Legacy adoption must prove binary/socket identity. +8. A new VM never adopts a pre-existing TAP or activates it before verified policy. 9. A server must have an API token; a remote server must also have usable TLS. 10. Routers call application services; they do not run shell commands or touch state files directly. 11. Plaintext guest passwords remain in the mode-`0600` registry and never - enter Firecracker config, helper command arguments/traces, or API responses. + enter Firecracker config, helper command arguments/traces, or CLI/API VM output. +12. Retained-disk start never overwrites guest data, credentials, or SSH host keys. +13. No disk/IP release before confirmed exit and scoped network cleanup; no + old-policy rollback after a failed close, and no resurrection of pending deletion. +14. Policy state is observed from the actual owned rules, not a cached applied flag. When a change would violate one of these rules, redesign the change rather than adding a special-case bypass. @@ -636,80 +743,75 @@ than adding a special-case bypass. ### Standard Unit Suite ```sh -cd uvm python3 -m pip install -e '.[test]' -PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v +python3 -B -m unittest discover -s tests -v ``` -The suite covers: - -- CPU/RAM parsing and validation. -- State serialization, legacy migration, locking, and permissions. -- Checksum verification and integrity-manifest provenance. -- Private rootfs behavior. -- Guest credential persistence, validation, and offline rootfs provisioning. -- IP, MAC, bridge, TAP, NAT, and forwarding command construction. -- Firecracker configuration and API request ordering. -- PID safety and startup interrupt cleanup. -- Lifecycle success, rollback, stop, and destroy behavior. -- CLI SSH safety and `--serve` parsing. -- Server token/TLS validation and conditional API route tests. +Ordinary tests require no root, KVM, Firecracker, or host network mutations. +They cover validation, schema migration/locking/permissions, artifact trust, +private disks/credentials, network/firewall rendering and readback, conntrack +semantics, pidfd/legacy identity, lifecycle recovery/resume/bulk targeting, +CLI parsing/output, and server security. Mock/fixture policy checks do not +prove the kernel's actual packet behavior. FastAPI route tests are conditionally enabled when both `fastapi` and `httpx` are installed. They should be enabled in CI using the `test` extra. -### Real Integration Tests +### Isolated Packet Tests -No current test automatically boots a real microVM. Before a release, run an -isolated host test that covers: +The opt-in packet harness must run **only in disposable isolated user/network +namespaces**. It verifies namespace separation before creating links or +changing rules, and never falls back to the parent host namespace. It may +require privileges (or enabled unprivileged user namespaces) plus `unshare`, +`ip`, `bridge`, `nft`, `conntrack`, and `sysctl`: + +```sh +UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v +``` + +The harness uses bridge-connected veths in place of guest TAPs and routed +egress without NAT. It exercises host/peer service traffic, both-direction +close of existing streams while preserving conntrack/egress, IPv6/spoof +blocking, quarantine, and offline cleanup. It does not boot a microVM or +validate real TAP/NAT deployment. + +The packet test skipped in this workspace because namespace creation was +denied (`Operation not permitted`). Document that permission limitation, not +an assertion that real packets or a real host were validated. Unit tests and +nft JSON parser checks are not substitutes for kernel packet-path execution. +### Isolated Host Validation + +No current test automatically boots a real microVM. Before a release, validate +on an isolated Linux host: 1. Strict checked installation. 2. `create` and successful guest boot. 3. Guest IP reachability and egress. 4. SSH with unique guest host keys. -5. Stop and retained disk inspection. -6. Destroy and IP/TAP release. -7. API launch with token and TLS on a non-loopback test address. -8. Firecracker crash and interrupted create recovery. +5. Stop/start/restart with retained guest data, SSH keys, and credentials. +6. Live private-port close in both directions, empty policy, and explicit sync + after deliberately lost rules inside the isolated setup. +7. Delete and IP/TAP/policy/conntrack cleanup. +8. API launch with token and TLS on a non-loopback test address. +9. Firecracker crash, interrupted create/start/stop/delete, and legacy recovery. ## 18. Common Development Tasks -### Add a CLI Command - -1. Put business logic in an existing service or a cohesive new internal - module, not `cli.py`. -2. Add parser arguments in `build_parser()`. -3. Add display/dispatch behavior in `_run_command()`. -4. Use `UvmError` subclasses for expected failures. -5. Add unit tests for parsing and service behavior. -6. Update `README.md` and this document if the architecture changes. - -### Add an HTTP Endpoint - -1. Add or extend a Pydantic request/response model in `api_models.py`. -2. Put the endpoint in the appropriate `routers/` module. -3. Use `get_authorized_application()` for every privileged endpoint. -4. Keep `/health` as the only intentionally unauthenticated management route. - FastAPI's default documentation endpoints are informational and public. -5. Add the router to `create_api()`. -6. Add `httpx`/FastAPI tests and update the API table in both docs. - -### Add a Host Primitive - -1. Put subprocess construction in `system.py`, `network.py`, `install.py`, or - a specific Firecracker adapter. -2. Use `CommandRunner` when invoking external commands. -3. Make repeated application safe where possible. -4. Define compensation behavior before calling it from lifecycle code. -5. Add fake-runner tests; do not require root in ordinary unit tests. - -### Change State - -1. Increment `STATE_SCHEMA_VERSION` only when the serialized interpretation - changes incompatibly. -2. Keep older state readable or provide an explicit migration path. -3. Update `VmRecord`, `State`, state tests, and runtime cleanup behavior. -4. Consider lock ownership and crash/retry behavior before changing fields. +- CLI: keep business logic in services; wire arguments in `build_parser()` and + dispatch/output in `_run_command()`. Test validation, prompts, dry runs, + JSON, and service behavior; use `UvmError` for expected failures. +- HTTP: define strict models in `api_models.py`, use the appropriate router and + `get_authorized_application()`, and register new routers in `create_api()`. + Add API tests and update all route tables. New CLI commands do not implicitly + authorize new HTTP endpoints; `/health` remains the only unauthenticated + operational route. +- Host primitives: use the specific network/firewall/install/process module + and `CommandRunner` for external commands. Define ownership, idempotence, + and compensation before invoking a primitive from lifecycle code. Use fake + runners rather than requiring privileges in unit tests. +- State: update `VmRecord`/`State`, schema tests, cleanup, and documentation. + Version incompatible interpretations and preserve older data or provide an + explicit migration. Consider lock order and crash/retry behavior first. ## 19. Debugging Guide @@ -718,27 +820,19 @@ isolated host test that covers: | `guest assets missing` | `/var/lib/uvm/images`, integrity variables, `integrity.json` | | Firecracker exits early | VM `firecracker.log`, VM config, `/dev/kvm` access | | VM is `dead` in `list` | PID/start token in state, `/proc/`, VM log | -| Cannot stop legacy VM | Missing process start token; inspect PID manually before cleanup | +| Cannot stop legacy VM | Missing start/boot identity or failed protected binary/socket match; never guess a PID | | Guest has no egress | `ip route show default`, bridge address, TAP state, iptables rules | +| Policy missing/drift/unknown | Actual nft JSON and ownership, STP/group mask, permissions; explicit sync after fixing cause | +| Retained-disk start fails | `disk_ready`, safe private path, kernel/VMM integrity, process identity, retained network, pending operation | | API server refuses to start | `UVM_API_TOKEN`, requested host, TLS cert/key files | | API returns 401 | `X-UVM-Token` header mismatch | | API returns 422 | Request schema or UVM input validation failure | -Useful host commands during isolated debugging: - -```sh -ip link show uvm0 -ip addr show dev uvm0 -ip route show default -iptables -t nat -S POSTROUTING -iptables -S FORWARD -ls -la /var/lib/uvm/vms -sudo cat /var/lib/uvm/state.json # Contains plaintext guest passwords; do not share. -``` - -Do not use these commands to mutate UVM resources manually while a lifecycle -operation is active. Prefer fixing the service behavior and retrying through -the CLI or API. +Use `inspect VM --json`, `logs VM`, `ports list --all`, and `doctor` before +examining host state. [USAGE.md](USAGE.md) lists read-only bridge/nft/iptables +debugging commands. Never share the credential registry or unredacted guest +logs. Do not manually mutate UVM resources during a lifecycle operation; +fix the service/host failure and retry through supported commands. ## 20. Current Production Gaps @@ -749,9 +843,13 @@ The code is intentionally honest about current limits: - No cgroup CPU, memory, I/O, or process limits are applied. - Fractional CPU is represented by rounding vCPUs up and printing an advisory. - Guest health is inferred from VMM process state, not application readiness. -- There is no DHCP, port mapping, ingress firewall policy, or network tenancy. -- `iptables` rules are incrementally managed rather than rendered from a full - declarative firewall policy. +- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy. +- No guest snapshots, extra disks, production image builder, or SSH-key injection. +- Per-VM private TCP ingress policy exists, but public connectivity, DNS, + guest reverse proxy/TLS, and external firewall integration remain out of scope. +- Existing egress `iptables` rules are incremental and unchanged; only the + private bridge policy is atomically rendered per VM. +- No autostart, background reconciliation, or guest-coordinated shutdown. - The JSON inventory is suitable for one host, not distributed coordination. - Guest passwords are stored in plaintext in the root-only local registry. - API authentication is a single bearer token, not a user/tenant model. diff --git a/FIREWALL.md b/FIREWALL.md new file mode 100644 index 0000000..7eaadbb --- /dev/null +++ b/FIREWALL.md @@ -0,0 +1,735 @@ +# UVM Local Firewall Design + +> Status: proposed design. This document describes a host-local firewall layer +> to be implemented after the VM lifecycle and registry work. It does not +> describe functionality currently available in `uvm`. + +## 1. Decision + +UVM should use **native nftables**, managed locally through an internal Python +adapter that invokes the `nft` command through the existing `CommandRunner`. + +The initial firewall layer will enforce per-VM **private IPv4 TCP allowlists** +at the UVM bridge/TAP boundary. It will not require a cloud firewall, remote +agent, external controller, or long-running UVM firewall daemon. + +This choice fits UVM because nftables provides: + +- Native Linux bridge filtering, which sees VM-to-VM traffic on `uvm0`. +- Stateful connection tracking in the bridge family on supported kernels. +- Atomic rule transactions through `nft --file -`. +- Named sets and a dedicated table, allowing UVM to update only resources it + owns. +- A local CLI and JSON inspection interface that can be used safely through + UVM's existing subprocess boundary. + +The recommended implementation is a focused adapter, not a general firewall +framework or a public Python SDK. + +## 2. Scope + +### In scope + +- Enforce a per-VM TCP allowlist for traffic to the VM's private IPv4 address. +- Cover host-to-VM and VM-to-VM traffic on the existing UVM bridge. +- Preserve ordinary guest-initiated egress and replies to that egress. +- Make `close` block subsequent packets on existing service connections as + well as new connections. +- Install the policy before a VM TAP becomes usable. +- Let explicit lifecycle or policy-sync operations reconcile UVM-owned + firewall state from the local registry after interruption or firewall-rule + loss. +- Work entirely on the Linux host that runs UVM. + +### Explicitly out of scope + +- Public IP allocation, port forwarding, DNAT, reverse proxies, DNS, TLS + termination, load balancing, or cloud security groups. +- A remote firewall API, external controller, or multi-host coordination. +- Automatic integration with Docker, UFW, firewalld, Kubernetes, libvirt, or + a site-managed firewall. Those need separately tested integration profiles. +- IPv6 service policies, UDP service policies, source-CIDR ACLs, rate limits, + or egress restrictions in the first version. +- A claim that the current Firecracker launch model is a hardened, + multi-tenant isolation boundary. + +Opening TCP/80 in this design means that TCP/80 is reachable at the guest's +private UVM address by permitted local paths. It does not create a public +listener, start a guest service, configure TLS, or bypass a guest firewall. + +### Supported topology profile + +The first version supports a UVM-dedicated private bridge only. Preflight must +verify all of these conditions before activating a managed TAP: + +- `UVM_NETWORK` is wholly contained in one RFC1918 range and does not overlap + another local address or non-default route, apart from the expected UVM + bridge address and connected bridge route. The host's generic default route + is expected and is not a conflict. +- `UVM_BRIDGE` has only UVM-owned TAP member ports. It has no physical uplink, + foreign veth, libvirt, container, or otherwise unknown bridge port. +- New inbound service connections originate from the UVM bridge gateway or a + valid address in `UVM_NETWORK`. This is a fixed private-path boundary, not a + user-configurable source-CIDR feature. +- UVM itself installs no DNAT, port-forward, or public-address rule. + +The source restriction permits host-to-guest traffic, whose source is normally +the bridge gateway address, and VM-to-VM traffic. It does not affect replies +to guest-initiated external connections, which are identified as reply traffic +through connection tracking. + +A private RFC1918 address is not itself a complete public-exposure guarantee. +An administrator can still add host routing, source NAT, DNAT, or another +firewall rule that changes reachability. UVM does not discover or override +such external policy; it only guarantees the scoped bridge policy after it has +successfully applied and verified it. + +## 3. Existing UVM Topology + +UVM currently creates one TAP per Firecracker VM and attaches every TAP to the +same Linux bridge. The host bridge address is the guest default gateway. + +```text +guest eth0 + | +Firecracker virtio-net + | +per-VM TAP: uvm- + | +Linux bridge: uvm0 (10.42.0.1/24 by default) + | +host IPv4 routing and existing egress NAT + | +host uplink +``` + +Relevant current code: + +- [`uvm/network.py`](uvm/network.py) allocates guest addresses and MACs, + creates the bridge/TAPs, enables IPv4 forwarding, and installs the current + egress `iptables` rules. +- [`uvm/lifecycle.py`](uvm/lifecycle.py) coordinates VM creation, rollback, + stop, and destruction under the shared operation lock. +- [`uvm/state.py`](uvm/state.py) owns the atomic registry and the operation + lock. +- [`uvm/system.py`](uvm/system.py) provides the testable `CommandRunner` + boundary for local host commands. + +An IP-family `FORWARD` rule alone is insufficient for the future port policy. +Traffic between two VM TAPs is normally switched by the Linux bridge at layer +2 and does not traverse an `inet`/`ip` forwarding hook. Host-to-guest traffic +also follows bridge output rather than the ordinary routed-forward path. + +Native nftables bridge filtering is designed for these paths and does not +require enabling the legacy `br_netfilter` module. The Linux bridge +documentation specifically discourages `br_netfilter` for new filtering +designs in favor of nftables. + +## 4. Candidate Evaluation + +All options below are open source and can run entirely on the local Linux +host. The comparison is about fit for UVM's intended lifecycle-managed policy, +not whether the tool is generally useful. + +| Option | Local operation | Advantages | Limitations for UVM | Decision | +|---|---|---|---|---| +| Native nftables | Kernel Netfilter plus local `nft` CLI; no daemon required | Bridge-family hooks, conntrack, named sets, atomic batches, precise ownership | Requires a supported kernel and careful coexistence rules | Adopt | +| `iptables-nft` compatibility frontend | Local CLI backed by nftables | Already used by existing UVM egress code; familiar syntax | Does not give a clean bridge-policy model and makes native ownership/order harder to reason about | Do not extend for the new layer | +| `iptables-legacy` plus ebtables/ipset | Local CLI tools | Available on older systems | Separate legacy data path, incremental updates, more components, and bridge IP filtering commonly depends on legacy `br_netfilter` behavior | Do not use | +| firewalld | Local daemon with D-Bus API | Zones, policies, IP sets, local programmatic control | Daemon ownership, runtime/permanent configuration, and policy interactions add operational complexity; it is not optimized for UVM's per-TAP hard-close contract | Optional future adapter only | +| UFW | Local Ubuntu-oriented CLI | Simple operator workflow and routed rules | CLI/rule-number model is not a suitable authoritative dynamic policy API; reload/default-policy changes have broad effects | Do not use as UVM's backend | +| eBPF/XDP or tc/eBPF | Local kernel programs and maps | High performance and dynamic maps | Requires a custom data plane, attachment/pinning lifecycle, verifier-compatible programs, and does not naturally provide UVM's conntrack semantics | Future performance option, not v1 | + +### Why not reuse the current iptables implementation? + +The existing `NetworkManager._ensure_masquerade()` method incrementally adds +egress NAT and forwarding rules. It does not manage a UVM-owned firewall +table, does not filter bridge-forwarded VM-to-VM frames, and does not provide +atomic whole-policy replacement. + +On systems where `iptables` points to the nftables compatibility backend, +native nftables and `iptables-nft` share the kernel nftables data plane but +still create separately managed rule structures. Adding the new policy through +native nftables while leaving current egress rules unchanged is the smallest +safe migration. A later, separately tested change may migrate UVM's existing +egress rules into a dedicated nftables table. + +UVM must never change a system-wide `iptables` alternative, flush a global +ruleset, or adopt unrelated rules just because they resemble UVM's topology. + +### Why not use firewalld or UFW as the primary backend? + +firewalld is a valid local-only solution on hosts where it is already the +authoritative firewall manager. Its D-Bus API and policy model make a future +adapter possible. It adds a daemon and a separate permanent/runtime +configuration model, however, and a firewalld reload intentionally preserves +established connections. That does not by itself meet UVM's close-port +contract. + +UFW is well suited to static host policy and can express routed rules. Its own +manual states that its command interface is not intended to expose complete +firewall functionality. Dynamic per-VM reconciliation would depend on parsing +or manipulating a higher-level CLI-managed ruleset, and it still would not +solve bridge-path filtering cleanly. + +If a host already uses firewalld or UFW, UVM must not write that manager's +generated tables directly. The operator must choose and test a dedicated +integration profile before using UVM's firewall layer on that host. + +## 5. Recommended Security Contract + +The v1 policy is deliberately narrow: + +| Item | Contract | +|---|---| +| Protocol | IPv4 TCP only | +| Inbound access | Default deny for managed VMs; only explicitly allowed guest TCP ports pass | +| Default new-VM policy | Persist TCP/22 as an ordinary initial allowlist entry unless creation explicitly disables default ports | +| Egress | Guest-initiated TCP, UDP, DNS, and required control traffic remain allowed | +| Existing service connections after close | Subsequent packets are blocked in both directions after the policy update commits | +| IPv6 | Drop IPv6 traffic on managed TAPs until an explicit IPv6 policy exists | +| Guest identity | Bind each managed TAP to its recorded MAC and IPv4 address; reject spoofed source identity | +| New inbound source | Only the UVM bridge gateway or an address in `UVM_NETWORK` may initiate a service connection | +| Legacy records | A missing policy is `unmanaged`, not an implicit allow-all or deny-all policy | + +The policy must distinguish an explicit empty list from a legacy record: + +```text +open_tcp_ports = None -> legacy/unmanaged; do not claim enforcement +open_tcp_ports = [] -> managed deny-all inbound policy +open_tcp_ports = [22] -> managed policy allowing SSH only +``` + +HTTP and HTTPS are user-facing labels for TCP/80 and TCP/443. They do not +change packet treatment. + +An active unmanaged VM may not share `uvm0` with managed VMs. Before managed +enforcement is enabled, all active legacy records must be stopped and migrated +to an explicit policy, or the operation must fail. This avoids treating an +unfiltered legacy TAP as a trustworthy peer for MAC/IP/ARP identity checks. + +### What close guarantees + +When a managed VM closes TCP/443, the resulting policy must match both: + +- Original-direction packets traveling to that VM's TCP/443 service. +- Reply-direction packets leaving that service on an already established flow. + +The implementation must not place a broad `ct state established,related +accept` rule before this service-policy check. Instead, it checks the +connection's original destination address and destination port for every +service-flow packet. + +This gives the following guarantee: + +> Once the atomic firewall update commits, later packets traversing the VM's +> service path are blocked while the port remains closed. + +It cannot retract data already delivered, gracefully close a guest process, or +guarantee an immediately visible close for a completely idle TCP socket. The +design may use `reject with tcp reset` after packet-path tests establish that +the behavior is appropriate; a drop rule is the conservative baseline. + +Connection tracking must remain available while enforcing a live close. A +targeted `conntrack` cleanup is useful before a deleted VM IP is reused, but +it is not the primary live-close mechanism. Deleting a conntrack entry too +early can discard the original tuple needed to distinguish a service reply +from a new guest-originated connection. + +## 6. nftables Architecture + +UVM will own one dedicated bridge-family table, conceptually: + +```text +table bridge uvm +``` + +The table contains only UVM-generated rules, sets, and chains. It must never +use `flush ruleset`, modify a built-in host policy, or clear tables owned by a +different tool. + +```mermaid +flowchart LR + LIFE[LifecycleService] --> FIREWALL[NftFirewall] + LIFE --> NETWORK[NetworkManager] + FIREWALL --> RUNNER[CommandRunner] + RUNNER --> NFT[nftables bridge table] + NETWORK --> BRIDGE[uvm0 bridge and TAPs] + NFT --> BRIDGE + BRIDGE --> VM1[Firecracker VM A] + BRIDGE --> VM2[Firecracker VM B] +``` + +### Hook Coverage + +| Bridge hook | UVM use | Required policy action | +|---|---|---| +| `prerouting` | Frame arrives from a managed TAP | Validate expected source MAC/IP before bridge learning; allow only necessary ARP; reject unsupported IPv6 | +| `input` | VM frame enters the host stack, including the gateway path | Apply the VM's source-side policy and allow guest-originated traffic/relevant replies | +| `output` | Host stack sends a frame toward a VM | Apply the destination VM's inbound policy or allow a reply to guest-originated traffic | +| `forward` | Frame moves directly between bridge ports | Apply both source-VM and destination-VM policy; never let one VM's acceptance skip the other VM's check | + +The base chains have an `accept` policy so that UVM does not impose a global +deny policy on non-UVM bridge ports. The UVM dispatch rules select only known, +registry-backed TAP names. A denial occurs inside a selected VM's policy path. + +Once a frame is selected as to or from a managed TAP, an unmatched frame is a +terminal denial. The v1 policy permits only untagged IPv4 and the narrowly +required ARP traffic on that attachment. It drops IPv6, VLAN-tagged frames, +and other unsupported EtherTypes in both directions. This is separate from +the base-chain policy for non-UVM bridge ports. + +For VM-to-VM traffic, dispatch into source and destination policy chains must +use `jump` and `return` semantics. A source-side success returns to the +dispatcher so the destination VM's inbound policy still runs. It must not use +an early terminal `accept` that bypasses the destination check. + +### Per-VM Rule Model + +Each managed VM contributes a typed policy record: + +```text +VM ID +TAP name +guest IPv4 address +guest MAC address +open TCP ports +runtime mode: active or quarantined +``` + +The generated rules maintain two logical checks: + +| Chain role | Original-direction traffic | Reply-direction traffic | +|---|---|---| +| Toward VM | Allow a new TCP flow only from the private-path sources and when its destination port is in the VM's current allowlist | Allow a reply only when it belongs to a guest-originated connection | +| From VM | Allow guest-originated traffic | Allow a service reply only when the original destination VM/port is still in the allowlist | + +The implementation uses connection tracking's original tuple, rather than the +packet's current source/destination port alone, for service replies. For +example, a web server reply is sent *from* TCP/443 to a client ephemeral port; +the original tuple still identifies TCP/443 as the guest service. + +The actual nft syntax is an implementation detail and must be generated from +typed data. It will use `ct direction`, `ct original ip daddr`, and `ct +original proto-dst` only after an isolated integration test verifies the +target kernel's bridge conntrack behavior. + +### Identity and Layer-2 Controls + +Filtering only TCP ports is not enough if a guest can spoof another guest's +source identity. For every managed TAP, the policy must: + +- Accept a frame from the TAP only when its Ethernet source equals the + persisted guest MAC. +- Accept an IPv4 frame from the TAP only when its source equals the persisted + guest IPv4 address. +- Bind ARP sender hardware and protocol addresses from the TAP to that same + MAC/IP pair; allow only the required target/address-probe forms after packet + testing. +- Drop IPv6, tagged Ethernet frames, and unsupported EtherTypes both to and + from the managed TAP while they have no corresponding policy. + +The identity check belongs before or at the earliest bridge ingress point. It +must be validated against the actual bridge forwarding database behavior on +the target kernel. This is meaningful network-policy hardening, but it does +not turn UVM into a complete hostile-tenant platform. Stronger tenant +isolation may eventually require per-VM L2/L3 segments, bridge-port +isolation, VLANs, or network namespaces. + +### Ownership and Rule Rendering + +UVM owns only its dedicated table and resources carrying a fixed versioned +UVM marker. The adapter must: + +1. Refuse an unexpected pre-existing UVM table or an ownership-marker + mismatch rather than adopting it. +2. Render the complete desired contents of its own table from current state. +3. Apply the generated table in one nftables transaction. +4. Inspect the owned table after a mutation when a stronger confirmation is + required. +5. Remove a per-VM dispatch entry only after its TAP is confirmed absent or + safely quarantined. + +The exact table replacement batch must be tested against the supported nft +version. It may rebuild only `table bridge uvm`; it must never rebuild the +host's whole nftables ruleset. + +## 7. Persistence and Reconciliation + +The JSON registry remains the source of desired policy. Kernel firewall state +is observed state and is never treated as durable truth. + +The future `VmRecord` model needs to persist at least: + +```text +name (if VM naming is added) +open_tcp_ports: list[int] | None +policy_intent / pending lifecycle phase +policy generation +``` + +`None` preserves the legacy/unmanaged distinction. New VMs always have an +explicit policy, including a deliberately empty policy. + +The registry also needs a topology fingerprint at the state level containing +the configured network CIDR, gateway, and bridge name. Lifecycle and firewall +preflight compare the current `Settings` against that persisted fingerprint +before touching interfaces or rules. A legacy registry without this fingerprint +requires an explicit migration; it must not infer that a changed `/24` and +`/16`, for example, describe the same UVM topology. + +UVM should not persist an authoritative `policy_enforced: true` boolean. It +would become stale after a reboot, manual firewall change, partial failure, or +host firewall reload. Instead, `NftFirewall.reconcile()` renders the desired +state from the registry and inspects the UVM-owned table when needed. + +Reconciliation has no continuous trigger in this design. It runs during +create, start, port mutations, and an explicit future policy-sync operation. +Read-only list/inspect commands may report an owned table as missing or +unverified, but must not silently mutate the host. If an active VM loses its +UVM firewall table between operations, its policy is unknown until a successful +explicit reconciliation; UVM must not claim it remains enforced. + +### Mutation Ordering + +All firewall mutations use the existing `operations.lock`. State and kernel +updates cannot be one cross-resource transaction, so durable intent and +observed enforcement must remain distinct: + +| Action | Required order | +|---|---| +| Create | Persist explicit desired policy, create the TAP down, install/verify policy, activate TAP, then launch Firecracker | +| Start | Reconcile the saved policy before activating the retained TAP or launching a new VMM | +| Open port | Persist desired policy first, atomically apply it, then clear the pending state | +| Close port | Persist the more restrictive desired policy first, atomically apply it, then clear the pending state | +| Stop | Persist stop intent, quarantine the TAP, confirm VMM exit, remove/deactivate TAP, retain saved policy | +| Delete | Quarantine, confirm exit, remove TAP, remove UVM policy for the VM, clear stale flow state before IP reuse, then remove registry data | + +If an open operation cannot be applied, the desired state may say "open" while +the host remains more restrictive. UVM reports failure and retries through +reconciliation; it must not claim the port is open. If a close operation +cannot be confirmed, UVM retains its restrictive intent and attempts to +quarantine the attachment rather than restoring the old policy. + +There is an unavoidable crash window after restrictive intent is persisted and +before the nftables transaction commits. During that window, an already-running +VM can still have its old permissive kernel policy. An interrupted close stays +pending and is not reported as enforced until an explicit reconciliation or +quarantine successfully commits. The operation lock cannot close this window +after the UVM process exits. + +### Quarantine + +An empty inbound allowlist is not a quarantine because it still permits a VM +to initiate outbound traffic. A quarantine mode drops traffic both to and from +the owned TAP. It is required while a stop, delete, or failed boot leaves +process/TAP ownership unresolved. + +Do not remove this guard merely because a cleanup operation was attempted. It +remains until the TAP is gone or a successful start replaces it with the +saved active policy. + +## 8. Python Integration + +### Internal Adapter Boundary + +Add a focused internal module such as `uvm/firewall.py`. It should be +constructed in [`uvm/app.py`](uvm/app.py), similarly to the current +`NetworkManager` and Firecracker process manager. + +```python +firewall = NftFirewall(settings, runner) +lifecycle = LifecycleService(settings, state_store, images, network, process, firewall) +``` + +The exact constructor arrangement can vary, but these boundaries must remain: + +| Component | Responsibility | +|---|---| +| `LifecycleService` | Owns ordering, operation locking, persistence intent, rollback, and VM status transitions | +| `NetworkManager` | Owns bridge validation plus TAP preparation, activation, and deletion | +| `NftFirewall` | Owns only UVM's generated nftables policy and its local inspection/reconciliation | +| `CommandRunner` | Runs local commands without a shell and makes command construction testable | +| CLI/API routers | Validate user input and call services; never send raw firewall syntax or commands | + +The adapter should expose typed operations, for example: + +```python +@dataclass(frozen=True, slots=True) +class VmFirewallPolicy: + vm_id: str + tap: str + guest_ip: IPv4Address + guest_mac: str + open_tcp_ports: tuple[int, ...] + quarantined: bool = False + + +class NftFirewall: + def preflight(self) -> None: ... + + def reconcile(self, policies: Iterable[VmFirewallPolicy]) -> FirewallObservation: ... + + def inspect(self) -> FirewallObservation: ... + + def purge_vm_flows(self, vm: VmFirewallPolicy) -> None: ... +``` + +The public surface should accept domain objects, not interface names or raw +rules supplied by a CLI/API caller. There should be no `apply_raw`, `flush`, +or generic arbitrary-command method. + +### Command Transport + +The first implementation should call the local `nft` executable through +`CommandRunner`, rather than add a Python package dependency: + +```python +runner.run( + ("nft", "--check", "--file", "-"), + input_text=ruleset, + capture=True, + timeout=settings.firewall_timeout_s, +) +runner.run( + ("nft", "--file", "-"), + input_text=ruleset, + capture=True, + timeout=settings.firewall_timeout_s, +) +``` + +This follows the same engineering model as Firecracker's Unix-socket adapter: +a narrow typed UVM boundary hides an external host interface. For nftables, +the host interface is a local command/Netfilter transaction rather than HTTP. + +`nft --check` and `nft --file -` are separate operations. The checked batch is +only a validation aid; UVM may claim a policy change only after the apply +transaction succeeds and the adapter records its observation. + +Benefits of the CLI approach: + +- No shell interpolation or raw shell scripts. +- Atomic batch application through `nft --file -`. +- Syntax/preflight checking through `nft --check`. +- Easy fake-runner unit tests and visible CLI traces. +- No additional Python runtime dependency. + +Inspection should use JSON rather than human-formatted output: + +```python +runner.run( + ("nft", "--json", "list", "table", "bridge", "uvm"), + capture=True, + timeout=settings.firewall_timeout_s, +) +``` + +`python3-nftables`/libnftables is a viable future transport if avoiding a +subprocess becomes necessary. It must not be introduced casually: package API +and kernel compatibility need a version-pinned integration suite. Direct +Netlink programming is not justified for the first implementation. + +### Errors + +Introduce a `FirewallError(UvmError)` for semantic firewall failures such as +unsupported local capabilities, unexpected ownership, or an unsafe observed +table. Preserve `CommandError` for an `nft` or `conntrack` command failure. + +Errors must include the failed operation and safe diagnostics, but never +include raw untrusted rule input because the adapter has none. The CLI and API +can retain their existing `UvmError` rendering conventions. + +## 9. Installation and Host Requirements + +The installer remains the only component that invokes `apt-get`. The future +package list should add: + +```text +nftables +conntrack +``` + +`conntrack` is used for targeted stale-flow cleanup before a deleted VM IP is +reallocated. It is not a substitute for the firewall's live close policy. + +On Ubuntu-compatible hosts, the installation step is conceptually: + +```sh +apt-get install --yes nftables conntrack +``` + +Do not automatically enable `nftables.service`, write `/etc/nftables.conf`, +or install a system-wide persistence service. UVM dynamically owns only its +dedicated table and recreates/reconciles it during lifecycle operations. + +### Required Host Capabilities + +| Requirement | Reason | +|---|---| +| Linux with native nftables support | UVM uses local Netfilter tables | +| Kernel with bridge-family connection tracking support | Required for original-tuple service checks; upstream support starts in Linux 5.3 | +| `nft`, `conntrack`, and `ip` commands | Local policy, selected flow cleanup, and TAP/bridge management | +| `CONFIG_BRIDGE` and `CONFIG_TUN` | Existing UVM bridge/TAP topology | +| Netfilter nftables, conntrack, bridge, reject, and relevant protocol modules | Required to load/apply the generated table | +| Root or `CAP_NET_ADMIN` in the bridge network namespace | Interface and firewall administration | +| No flowtable/hardware offload that bypasses this filter path | Required for reliable close enforcement | + +Preflight should verify the actual generated ruleset with `nft --check` on the +target host. Kernel-version checks alone are not enough; distributions can +build or package Netfilter capabilities differently. + +UVM must not enable `br_netfilter` or change `net.bridge.bridge-nf-call-*` +sysctls. Native bridge nftables works without that legacy compatibility layer. + +## 10. Configuration Approach + +The first version should avoid a generic `UVM_FIREWALL_RULES` setting or any +configuration value containing raw nftables syntax. + +The generated policy derives from existing UVM configuration and state: + +| Source | Firewall use | +|---|---| +| `UVM_NETWORK` | Validates guest IPv4 addresses and UVM subnet membership | +| `UVM_GATEWAY` | Establishes the bridge/guest topology for ARP and gateway traffic | +| `UVM_BRIDGE` | Selects the UVM bridge and its managed TAP attachments | +| `VmRecord.tap`, `VmRecord.mac`, `VmRecord.guest_ip` | Binds policy to one owned VM attachment | +| Persisted `open_tcp_ports` | Creates the per-VM TCP allowlist | + +One bounded command timeout, such as `UVM_FIREWALL_TIMEOUT_S`, is reasonable +as a `Settings` value. It should default to a small value and apply only to +UVM's local command invocations. + +Changing `UVM_NETWORK`, `UVM_GATEWAY`, or `UVM_BRIDGE` after VMs exist is not +a firewall migration mechanism. UVM must fail rather than infer ownership of +old interfaces or rules from changed environment values. + +## 11. Lifecycle Integration + +The existing `NetworkManager.create_tap()` activates a TAP immediately. The +firewall layer requires that operation to be split into preparation and +activation: + +```text +create TAP down -> attach to bridge -> install policy -> bring TAP up +``` + +### Create and Start + +```mermaid +sequenceDiagram + participant L as LifecycleService + participant S as StateStore + participant N as NetworkManager + participant F as NftFirewall + participant P as Firecracker + + L->>S: persist VM and explicit policy intent + L->>N: ensure bridge; prepare TAP down + L->>F: reconcile active policy including new VM + F-->>L: policy confirmed + L->>N: activate TAP + L->>P: spawn, configure, and start VMM + L->>S: persist running state +``` + +A policy failure prevents TAP activation and Firecracker launch. A startup +failure after policy installation leaves the policy in place until lifecycle +cleanup confirms that the TAP is gone. This is safer than briefly booting an +unfiltered guest. + +### Stop, Delete, and Failure Cleanup + +```text +stop/delete intent + -> quarantine owned TAP + -> terminate verified Firecracker process and confirm exit + -> deactivate/delete TAP + -> stop: retain desired port policy for a later start + -> delete: remove VM policy, purge stale flows, remove disk/state +``` + +If process identity or TAP ownership is unresolved, keep the quarantine guard, +disk, and registry entry. Do not delete a possibly live VM disk or remove its +network guard simply because a cleanup command raised an error. + +Shared table infrastructure may remain after the last VM is deleted. It is +not a resource leak: it is a UVM-owned baseline that can be reconciled by the +next lifecycle operation. It must contain no stale per-VM dispatch/policy +entries after successful deletion. + +## 12. Coexistence and Security Boundaries + +The initial supported profile is a host where UVM may own `table bridge uvm` +and no other component modifies that table, using the dedicated private bridge +profile described above. + +This does not mean UVM owns the host firewall. In particular: + +- A terminal `drop` in UVM's bridge table can protect a UVM TAP. +- An `accept` in one nftables base chain does not promise that later hooks or + another firewall manager will accept the packet. +- UVM must not change global default policies, Docker chains, firewalld zones, + UFW framework files, or a host's `nftables.service` configuration. +- If Docker, Podman, UFW, firewalld, libvirt, or site firewall tooling is + active, allowed traffic may still be blocked by that tooling. UVM should + report its own enforcement state honestly rather than claim reachability. + +The present design controls the local private network path only. It does not +protect against a privileged host process, a guest root exploit, raw host +network administration, or future external DNAT rules. Source-IP allowlists +and public exposure, if added, require a separate `inet`-family and ingress +design. + +## 13. Testing and Release Gates + +### Unit Tests + +Add a focused `tests/test_firewall.py` with a stateful fake runner. Test: + +- Generated table ownership, no `flush ruleset`, and no built-in chain policy + changes. +- Exact TAP-scoped dispatch and VM policy rendering. +- One atomic batch per policy update. +- Validation failures before host mutation. +- Idempotent reconciliation and recovery from missing UVM-owned rules. +- Refusal to adopt an unexpected table or marker mismatch. +- No broad `established,related` acceptance before service policy checks. +- Close-policy rendering that blocks both request and reply service packets. +- Quarantine and policy retention across stop/start/delete. +- Lifecycle ordering: policy before TAP activation and Firecracker spawn. +- Failure injection at state, nft, process, TAP, and cleanup boundaries. + +### Isolated Network-Namespace Proof + +Before integrating with real Firecracker, build a disposable network-namespace +test using a bridge and veth peers standing in for TAPs. It must prove: + +1. Host-to-VM traffic reaches an allowed TCP port and fails at a closed port. +2. VM-to-VM traffic is governed by the destination VM's policy. +3. Guest-initiated TCP/UDP egress and replies still work. +4. Closing a port during one-way server streaming blocks subsequent reply + packets as well as new requests. +5. A guest outbound connection using a source port equal to a closed service + port is not falsely blocked as inbound service traffic. +6. ARP/gateway behavior remains functional. +7. IPv6 cannot bypass the IPv4-only policy. +8. MAC/IP spoofing from a managed TAP is rejected. +9. Tagged IPv4/IPv6 and unsupported EtherTypes are denied at managed TAPs. +10. An active unmanaged TAP prevents managed-policy activation. +11. Unrelated bridge ports and host firewall tables are unaffected. + +Only after that proof should an isolated Firecracker test verify real TAP/MAC +behavior, default SSH access, an allowed guest listener, and live close. + +## 14. References + +- [Linux bridge Netfilter documentation](https://docs.kernel.org/networking/bridge.html#netfilter) +- [nftables bridge filtering](https://wiki.nftables.org/wiki-nftables/index.php/Bridge_filtering) +- [nftables atomic rule replacement](https://wiki.nftables.org/wiki-nftables/index.php/Atomic_rule_replacement) +- [nftables connection-tracking metadata](https://wiki.nftables.org/wiki-nftables/index.php/Matching_connection_tracking_stateful_metainformation) +- [firewalld concepts and policies](https://firewalld.org/documentation/concepts.html) +- [UFW manual](https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html) +- [iptables manual](https://man7.org/linux/man-pages/man8/iptables.8.html) +- [Linux `bpf(2)` manual](https://man7.org/linux/man-pages/man2/bpf.2.html) diff --git a/README.md b/README.md index e9c680d..56d2143 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ `uvm` is a small, local Firecracker microVM command-line manager for Ubuntu. It creates one Firecracker process per VM, attaches it to a host TAP device, -and persists enough state to list, stop, and destroy the VM later. +and keeps a private disk and local registry for named VMs. The CLI supports +create, start, stop, restart, delete, inspection, SSH, logs, diagnostics, and +enforced private-IPv4 TCP port allowlists. This directory is a self-contained project. All UVM code, tests, packaging metadata, launcher scripts, and documentation live here. @@ -13,14 +15,17 @@ workflows, see [DEVELOPER.md](DEVELOPER.md). For end-to-end operator scenarios, CLI examples, and HTTP API calls, see [USAGE.md](USAGE.md). +For the original firewall proposal and backend evaluation, see +[FIREWALL.md](FIREWALL.md). That proposal is design background, not the current +feature contract; these guides describe the implementation in the workspace. + ## Quick Start -From the parent repository directory, run commands from this project root: +Run commands from this project root: ```sh -cd uvm ./uvm.py --help -python3 -m uvm --help +python3 -B -m uvm --help ``` The `pyproject.toml` file also defines an installable `uvm` console command. @@ -32,12 +37,16 @@ The project is an application, not a reusable Python SDK. - Python 3.12 or newer - Root access for UVM commands that read or mutate the protected VM registry - KVM enabled and accessible at `/dev/kvm` +- Linux 5.3 or newer with native bridge conntrack and safe pidfd support - An x86_64 host for the default guest assets -- Working host networking, `iproute2`, `iptables`, `e2fsprogs`, `openssl`, and - `openssh-client` +- Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`, + `e2fsprogs`, `util-linux`, `openssl`, and `openssh-client` +- An STP-disabled software bridge; no offload path that bypasses bridge hooks `uvm install` installs the required host packages. It validates `/dev/kvm` -before downloading or launching a microVM. +before downloading VM artifacts. Boot operations additionally check that the +native nftables bridge policy is supported; installing packages alone is not +a connectivity or isolation test. ## Trusted Installation @@ -45,8 +54,6 @@ Installation requires trusted SHA-256 digests by default. Supply digests from an independently trusted source before running the privileged installer: ```sh -cd uvm - sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ @@ -72,59 +79,134 @@ available through an explicit opt-out: sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install ``` -An unverified install is recorded as such and cannot satisfy 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. -To create VMs from an unverified local-development install, retain -`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create` commands and on the API -server process. +To boot VMs from an unverified local-development install, retain +`UVM_ALLOW_UNVERIFIED_DOWNLOADS=1` on later `create`, `start`, and `restart` +commands and on the API server process. ## Commands +These are separate operation examples, not a script to run from top to bottom. + ```sh # Install after supplying the trusted checksum variables shown above. # A bare `install` command fails under the default strict integrity policy. -# Create and boot a VM with the defaults: 1 CPU, 512 MiB RAM, and root/root. -sudo ./uvm.py create +# Create a named VM: 1 CPU, 512 MiB RAM, root/root, and private TCP/22. +# Use a unique non-default password for anything beyond a throwaway VM. +sudo ./uvm.py create --name web -# Request CPU and RAM explicitly. RAM defaults to MiB; B/K/M/G suffixes work. -sudo ./uvm.py create --cpu 1 --ram 1G +# Choose resources, a private guest IP, and additional service ports. +sudo ./uvm.py create --name app --cpu 1 --ram 1G \ + --guest-ip 10.42.0.10 --ports 80:HTTP,443:HTTPS -# Set credentials for an account that already exists in the guest image. -sudo ./uvm.py create --username root --password '' +# Read a password from one line of stdin, rather than a --password argument. +# USAGE.md includes a non-echoing Bash prompt example. +sudo ./uvm.py create --name worker --password-stdin -# Select the guest IP. Despite the legacy flag name, this is the guest IP. -sudo ./uvm.py create --host-ip 10.42.0.10 +# Explicitly allow no inbound service ports, including SSH. +sudo ./uvm.py create --name closed --no-default-ports -# Show persisted VM state and observed process status. +# Inspect persisted state and observed process/policy status. sudo ./uvm.py list +sudo ./uvm.py list --status running --json +sudo ./uvm.py list -q +sudo ./uvm.py inspect web --json +sudo ./uvm.py logs web --tail 50 --follow +sudo ./uvm.py doctor --json -# Connect over SSH by VM ID or guest IP. -sudo ./uvm.py ssh vm- +# Resolve VMs by exact ID, name, or guest IP. +sudo ./uvm.py ssh web +sudo ./uvm.py ssh app --port 22 -- uname -a ssh root@10.42.0.10 -# Stop or remove a VM. -sudo ./uvm.py stop vm- -sudo ./uvm.py destroy vm- +# Stop and boot the same private disk, or restart selected VMs. +sudo ./uvm.py stop web --timeout 5 +sudo ./uvm.py start web +sudo ./uvm.py restart web app --timeout 10 + +# Preview bulk operations before confirming them. +sudo ./uvm.py stop --all --dry-run --json +sudo ./uvm.py stop --all -y +sudo ./uvm.py start --all -y +sudo ./uvm.py restart --all -y +sudo ./uvm.py delete --all --dry-run +sudo ./uvm.py delete web -y +# destroy is an alias for delete, with the same flags and destructive effect. ``` +`start`, `stop`, `restart`, and `delete` accept one or more VM identifiers or +`--all`, plus `--json`, `--dry-run`, and `--yes`/`-y`. Deletion and mutating +`--all` selections require confirmation; non-interactive callers must use +`--yes`. `--timeout` is available on stop/restart/delete (and `destroy`), not +start. It is a finite non-negative SIGTERM wait in seconds, default `5`, not +a total command deadline or a guest shutdown timeout. + +Stop sends SIGTERM and, if needed, SIGKILL to the Firecracker VMM. It is **not +a guest-coordinated shutdown**. Start boots the retained disk without resetting +guest data, SSH host keys, or credentials; it does not restore guest memory. +Delete permanently removes the disk and releases the VM's name and IP after +cleanup. See [USAGE.md](USAGE.md) for the complete flag and recovery reference. + +Names are unique local identifiers, not DNS names. `--host-ip` remains an +alias for `--guest-ip`; both select the guest's private IPv4 address. JSON +output omits passwords, with warnings and host-command traces on stderr. + SSH uses `StrictHostKeyChecking=accept-new` and a VM-ID-specific host-key -alias by default. `--insecure-host-key` restores the old no-verification -behavior for a single connection. +alias by default. `--user`, `--key`, and `--port` customize the SSH client; +append a remote command after a literal `--`. `--insecure-host-key` disables +verification for one connection. SSH does not automatically supply the stored +password or open a closed port. + +### Private Ports + +```sh +sudo ./uvm.py ports list web --json +sudo ./uvm.py ports list --all +sudo ./uvm.py ports open web --ports 80:HTTP --ports 443:HTTPS,8080:TCP +sudo ./uvm.py ports close web --ports 80 +sudo ./uvm.py ports set web --ports 22,443:HTTPS --dry-run --json +sudo ./uvm.py ports set web --ports 22,443:HTTPS -y +sudo ./uvm.py ports close web --all -y +sudo ./uvm.py ports set web --none -y +sudo ./uvm.py ports sync web +sudo ./uvm.py ports sync --all --dry-run +sudo ./uvm.py ports sync --all -y +``` + +Rules use `PORT[:TYPE]`, for example `22`, `80:HTTP`, `443:HTTPS`, or +`8080:TCP`, repeated or comma-separated. HTTP and HTTPS are **TCP labels**, +not proxies or TLS configuration. Create adds `22:TCP` even when other ports +are supplied, unless `--no-default-ports` is set. Later port edits do not +automatically add SSH back. `ports close VM --all` closes ports on that one +VM; only `ports list --all` and `ports sync --all` select all VMs. + +Port mutations support `--dry-run` and `--json`; close/set/sync also support +`--yes`/`-y`. Set, close-all, and removal of TCP/22 require confirmation +(an already empty managed policy can be a no-op). Closing a service blocks +subsequent packets on existing service connections in **both directions**, as +well as new connections. This is packet dropping, not a guaranteed TCP RST or +immediate application disconnect. + +These rules govern access to the **guest private IP**. They do not start a +guest service or provide public connectivity, host-port mappings, DNS, +reverse proxies, or guest-service TLS. UDP and IPv6 service policies are not +yet supported. Guest-initiated IPv4 TCP/UDP egress, its replies, and required +ARP/ICMP traffic remain allowed; managed TAPs block IPv6. ## Management API Install the project dependencies before serving the API: ```sh -cd uvm python3 -m pip install . ``` -Launch the FastAPI management server with the requested CLI form: +Launch the FastAPI management server using the environment where the project +dependencies were installed (use the full console-script path if needed): ```sh -cd uvm export UVM_API_TOKEN='replace-with-a-long-random-secret' sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \ uvm --serve --host 127.0.0.1 --port 8000 @@ -144,13 +226,19 @@ The server exposes these routes: | `GET` | `/health` | Unauthenticated liveness response | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `GET` | `/vms` | List VM records and observed status | -| `POST` | `/vms` | Create a VM; body supports `cpu`, `ram`, `guest_ip`, `username`, and `password` | -| `GET` | `/vms/{id-or-ip}` | Retrieve one VM by ID or guest IP | -| `POST` | `/vms/{id}/stop` | Stop a VM while retaining its private disk | -| `DELETE` | `/vms/{id}` | Destroy a VM and release its resources | +| `POST` | `/vms` | Create; supports `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | +| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk | +| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources | -The API intentionally does not proxy interactive SSH. Use the returned guest -IP with `uvm ssh` or a normal SSH client. +API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`; +`no_default_ports` is a boolean, default `false`. Responses include `name`, +`ports`, `policy_status`, and `operation`, but never passwords. See +[USAGE.md](USAGE.md) for request examples and strict schema details. + +The endpoint set is unchanged: there are **no start/restart, port-management, +or bulk HTTP endpoints**. SSH, logs, and doctor are also CLI-only. Use the +local CLI for those operations; the API is not a background reconciler. ### API Security @@ -219,6 +307,24 @@ it, enables IPv4 forwarding, adds an egress masquerade rule, and adds matching forward rules for return traffic. Guest IPs, gateway, bridge name, and guest boot netmask derive from the configured CIDR. +The existing `iptables` egress NAT/forwarding behavior is unchanged. Private +port enforcement uses separate per-VM native nftables `bridge` tables, not +DNAT or host listeners. Policy is installed and read back before an owned TAP +is activated and before the guest starts. The software bridge must have STP +disabled; each TAP's bridge-slave `group_fwd_mask` is set to `0x4000` so LLDP +cannot bypass the early identity filter. Native bridge conntrack requires +Linux >= 5.3; no `br_netfilter` sysctl setup is required by this policy layer. + +Host-to-VM and VM-to-VM traffic crossing these hooks is filtered. An allowed +port is not a reachability guarantee, and a private IP alone is not protection +against administrator-supplied external routing or NAT. Integration with UFW, +firewalld, Docker, or other external firewall managers remains deferred. +Do not offload traffic around the software bridge hooks. + +There is no autostart service or daemon reconciliation. After externally lost +or changed UVM rules, inspect policy status and explicitly run `ports sync`. +Read-only commands do not repair rules; sync does not boot stopped VMs. + ## Persistent State The default data directory is `/var/lib/uvm`: @@ -232,9 +338,9 @@ The default data directory is `/var/lib/uvm`: └── state.json # Root-only VM inventory and guest credentials ``` -State writes are locked and atomic. Lifecycle operations share an additional -operation lock so concurrent `create`, `stop`, `destroy`, and `install` -commands cannot overwrite each other's state or binaries. +The default registry is `/var/lib/uvm/state.json`, schema version `2`. Writes +are locked and atomic. Lifecycle, port-policy, and installation mutations +share an operation lock; bulk operations use a fixed snapshot of VM IDs. The registry stores guest usernames and passwords in plaintext, so `state.json` is mode `0600`. Treat it as a secret, do not include it in bug reports, and @@ -243,14 +349,28 @@ replace the default `root` password immediately. Credential provisioning applies only to VMs created by this version. Existing VM disks are not modified automatically. +Older registries are read and migrated on the next write. A missing/null +`ports` policy remains `None`/`unmanaged`, not an empty allowlist or an +implicitly enforced default. Migrate a legacy VM explicitly with `stop`, +`ports set VM --ports 22,80:HTTP -y` (choose the actual required ports), then +`start`. Resume requires a complete, safe private disk, a verified kernel and +VMM, and compatible network settings. Removing the original rootfs template +does not block start of a retained private disk. + +Process identity includes PID, Linux process start time, and host boot ID. +A running legacy PID missing the boot ID is adopted only when its saved start +time, running binary, exact API-socket command line, and protected paths all +match. Missing or ambiguous evidence fails closed: UVM will not guess which +process to signal or release a disk while process exit is unconfirmed. + ## Configuration -All optional configuration is supplied through environment variables: +Host and API settings can be overridden through environment variables: | Variable | Purpose | |---|---| | `UVM_BASE` | Data directory; defaults to `/var/lib/uvm` | -| `UVM_NETWORK` | Guest 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_BRIDGE` | Linux bridge name; defaults to `uvm0` | | `UVM_KERNEL_URL` | Guest kernel download URL | @@ -267,52 +387,57 @@ All optional configuration is supplied through environment variables: ## Project Layout ```text -uvm/ -├── README.md +./ +├── README.md, USAGE.md, DEVELOPER.md ├── pyproject.toml ├── uvm.py # Executable compatibility launcher ├── uvm/ # Private application implementation │ ├── cli.py # argparse, terminal output, SSH execution -│ ├── app.py # Application composition root -│ ├── api_models.py # FastAPI request and response models -│ ├── config.py # Settings and filesystem layout -│ ├── domain.py # Typed VM and state models -│ ├── errors.py # Expected application failures -│ ├── integrity.py # Checksums and manifest provenance -│ ├── install.py # Host/artifact installation -│ ├── images.py # Guest templates and per-VM disks -│ ├── lifecycle.py # Create/list/stop/destroy orchestration +│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent │ ├── network.py # IP/MAC/TAP/bridge/NAT management -│ ├── routers/ # Health, installation, and VM HTTP routers -│ ├── server.py # FastAPI application factory and Uvicorn launcher -│ ├── state.py # Locked atomic JSON persistence -│ ├── system.py # Host command and KVM adapters +│ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup │ └── firecracker/ # Firecracker config, API, and process adapters -└── tests/ # Unit tests with mocked host boundaries +└── tests/ # Unit tests and opt-in isolated namespace packet tests ``` +See [DEVELOPER.md](DEVELOPER.md) for the complete module map and ownership rules. + ## Testing -From the parent repository directory, run the unit suite from this project root: +Run the unit suite from this project root: ```sh -cd uvm python3 -m pip install '.[test]' -PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest discover -s tests -v +python3 -B -m unittest discover -s tests -v ``` The unit tests do not need root, KVM, Firecracker, or host network changes. They cover validation, state migration and locking, integrity manifests, -network command rendering, Firecracker request ordering, process safety, and -lifecycle rollback. API route tests run automatically when the FastAPI -and `httpx` test dependencies are installed. +network/firewall rendering, Firecracker request ordering, process safety, +retained-disk resume, bulk operations, and lifecycle rollback. API route tests +run when the FastAPI and `httpx` test dependencies are installed. + +Opt-in packet checks run **only in disposable isolated user/network +namespaces**, which may require privileges and the installed network tools: + +```sh +UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v +``` + +The packet test was skipped in this workspace because namespace creation was +denied (`Operation not permitted`). A skip is not packet-path or real-host +validation. The harness uses veths in place of TAPs and does not boot a real VM; +validate KVM, TAP, NAT, SSH, and guest connectivity on an isolated Linux host. ## Current Boundaries - Fractional CPU requests still round Firecracker vCPUs up and are advisory. -- `stop` retains the private VM disk; the current command surface does not yet - include a separate `start` command. +- No quotas or cgroup CPU, memory, I/O, or process limits are applied. - Firecracker is launched directly by this local CLI. Jailer/cgroup hardening is not implemented by the current command set. +- No VM autostart, background reconciliation, guest readiness probing, + snapshots, DHCP, public-port publishing, or multi-host scheduling. +- The API uses one shared token, not users, roles, or tenants. UVM is not a + hardened multi-tenant platform. - Run real KVM, TAP, and guest-connectivity checks on an isolated Linux host before relying on UVM for workloads. diff --git a/USAGE.md b/USAGE.md index d9b9792..87f3b42 100644 --- a/USAGE.md +++ b/USAGE.md @@ -12,11 +12,13 @@ For the project architecture and contributor guidance, see UVM creates small Linux microVMs through Firecracker and KVM on one Linux host. Each VM receives: -- A unique VM ID. +- A unique VM ID and, optionally, a unique local name. - A private writable root disk copied from a shared template. - A private IP address and MAC address. - A TAP interface connected to a host bridge. - A dedicated Firecracker process, API socket, log, and config file. +- A managed private-IPv4 TCP allowlist, initially allowing port 22 unless + default ports are explicitly disabled. By default, guest networking uses: @@ -27,7 +29,9 @@ Bridge: uvm0 Guest pool: 10.42.0.2 through 10.42.0.254 ``` -The default locations on the host are under `/var/lib/uvm`. +The default locations on the host are under `/var/lib/uvm`. Stop/start and +restart reuse the private disk; delete removes it. UVM is a local CLI, not an +autostart service or a daemon that continuously repairs VM or firewall state. ## 2. Before You Start @@ -38,9 +42,13 @@ You need: - Linux, preferably Ubuntu or a compatible distribution. - Python 3.12 or newer. - KVM enabled and readable/writable at `/dev/kvm`. +- Linux >= 5.3 with native bridge conntrack and safe pidfd support. - Root access for installation and VM lifecycle operations. - An x86_64 host for the default guest kernel/rootfs downloads. - Internet access if downloading Firecracker and guest assets. +- `iproute2`, `iptables`, `nftables`, `conntrack`, `e2fsprogs`, `util-linux`, + `openssl`, and `openssh-client`; `install` installs the host packages. +- An STP-disabled software bridge without traffic offload around its hooks. Check virtualization before installing: @@ -51,35 +59,34 @@ lscpu | grep -i virtualization ### Source Checkout Setup -From the parent directory of this project: +From this project root: ```sh -cd uvm ./uvm.py --help -python3 -m uvm --help +python3 -B -m uvm --help ``` To install the optional HTTP API dependencies and the console command: ```sh -cd uvm python3 -m pip install . uvm --help ``` The rest of this guide uses `./uvm.py` so it works from a source checkout. If -you installed the package, replace `./uvm.py` with `uvm`. +you installed the package, replace `./uvm.py` with `uvm`. For the API, use the +Python environment containing the installed dependencies; under `sudo`, use +the full path to that environment's console script when necessary. ### Privilege Rules | Operation | Root required? | |---|---| -| `install` | Yes | -| `create` | Yes | -| `stop` | Yes | -| `destroy` | Yes | -| `list` | Yes with the default root-owned registry | -| `ssh` | Yes when resolving a VM through the registry | +| `install`, `create`, `start`, `stop`, `restart`, `delete`/`destroy` | Yes | +| `ports open/close/set/sync` | Yes | +| `list`, `inspect`, `ports list`, dry-run target lookup | Access to the default root-owned registry requires root | +| `ssh`, `logs` | Access to the protected registry/runtime files requires root | +| `doctor` | Run as root for meaningful privileged checks | | API server that manages VMs | Yes | The registry contains plaintext guest credentials and is mode `0600`. Direct @@ -87,22 +94,43 @@ SSH to a known guest IP does not require root. ## 3. Command Overview -```sh -./uvm.py --help +The following is syntax notation, not a shell script. `VM` means an exact VM +ID, local name, or guest IPv4 address. Flags are command-specific; in +particular, create has no `--dry-run`, and start has no `--timeout`. -./uvm.py install [--force] -./uvm.py create [--cpu CPU] [--ram RAM] [--host-ip GUEST_IP] \ - [--username USER] [--password PASSWORD] -sudo ./uvm.py list -sudo ./uvm.py ssh VM_OR_IP [--user USER] [--key PATH] [--insecure-host-key] -./uvm.py stop VM_ID -./uvm.py destroy VM_ID - -./uvm.py --serve [--host HOST] [--port PORT] +```text +uvm install [--force] +uvm create [--name NAME] [--cpu CPU] [--ram RAM] [--guest-ip GUEST_IP] + [--username USER] [--password PASSWORD | --password-stdin] + [--ports PORTS]... [--no-default-ports] [--json] +uvm list [--status STATUS]... [--json | -q | --quiet] +uvm inspect VM [--json] +uvm start (VM [VM ...] | --all) [--yes | -y] [--dry-run] [--json] +uvm stop (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] +uvm restart (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] +uvm delete (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] +uvm destroy (VM [VM ...] | --all) [--timeout SECONDS] [--yes | -y] [--dry-run] [--json] +uvm ssh VM [--user USER] [--key PATH] [--port PORT] [--insecure-host-key] [-- COMMAND ...] +uvm logs VM [--tail N] [--follow | -f] +uvm doctor [--json] +uvm ports list (VM | --all) [--json] +uvm ports open VM --ports PORTS [--ports PORTS ...] [--dry-run] [--json] +uvm ports close VM (--ports PORTS [--ports PORTS ...] | --all) [--yes | -y] [--dry-run] [--json] +uvm ports set VM (--ports PORTS [--ports PORTS ...] | --none) [--yes | -y] [--dry-run] [--json] +uvm ports sync (VM [VM ...] | --all) [--yes | -y] [--dry-run] [--json] +uvm --serve [--host HOST] [--port PORT] ``` -`--host-ip` is a legacy CLI name. It means the **guest IP address**, not the -host's public or uplink address. +`--host-ip` is an alias for `--guest-ip`; neither refers to the host's public +or uplink address. `--ports` and `list --status` are repeatable flags, not +space-separated positional lists. `destroy` is an alias for `delete`. + +JSON is written to stdout; warnings, errors, prompts, and host-command traces +go to stderr. VM JSON omits passwords. Lifecycle and sync results are arrays of +`id`, `action`, `outcome`, and `error`; dry runs return an object with +`dry_run`, `action`, and `targets` (port edits also include before/after +`changes`). `ports list --json` returns an array even for one VM. JSON does +not disable confirmations, and errors may still be plain text on stderr. ## 4. Install UVM Assets @@ -115,8 +143,6 @@ artifacts that will be used by a privileged process. Obtain trusted checksums independently, then run: ```sh -cd uvm - sudo env \ UVM_FIRECRACKER_SHA256='' \ UVM_KERNEL_SHA256='' \ @@ -124,19 +150,9 @@ sudo env \ ./uvm.py install ``` -On success, UVM prints paths similar to: - -```text -Firecracker: /var/lib/uvm/bin/firecracker -Kernel: /var/lib/uvm/images/vmlinux -Rootfs: /var/lib/uvm/images/ubuntu.ext4 -``` - -The checksums and whether the installation was verified are recorded in: - -```text -/var/lib/uvm/integrity.json -``` +Installation prints the Firecracker, kernel, and rootfs paths under +`/var/lib/uvm`. Checksums and verification provenance are recorded in +`/var/lib/uvm/integrity.json`. ### Scenario: Refresh Firecracker or Guest Assets @@ -155,27 +171,22 @@ after checksum verification succeeds. ### Scenario: Local Experimentation Without Checksums -This mode is unsafe for production. It is useful only when you deliberately -accept the source artifacts without checksum verification: +This mode is unsafe for production; use it only when deliberately accepting +unverified development artifacts: ```sh sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py install ``` -An unverified install is marked as unverified. It cannot later become a strict -trusted installation merely by setting checksum variables. Reinstall with the -strict command above when you are ready to trust the host assets. - -For later local-development VM creation, keep the opt-out on the command too: +An unverified manifest is not publisher trust. Reinstall with trusted digests +to establish a verified installation. Until then, retain the explicit opt-out +on later create/start/restart commands and on an API server that creates VMs: ```sh sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py create +sudo env UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 ./uvm.py start VM ``` -The same rule applies to an API server that creates VMs from an unverified -installation: start that server with `UVM_ALLOW_UNVERIFIED_DOWNLOADS=1`, or -perform a strict reinstall first. - ### Installation Failures | Message | Meaning and action | @@ -190,42 +201,73 @@ perform a strict reinstall first. ### Scenario: Create a Default VM -The default is one CPU, 512 MiB RAM, username `root`, and password `root`: +The default is one CPU, 512 MiB RAM, username `root`, password `root`, and an +allowlist containing private TCP/22: ```sh sudo ./uvm.py create ``` -Typical output includes the VM ID, guest IP, TAP name, and SSH target: +Output includes the ID, optional name, guest IP, resources, TAP, username, and +private port rules. The default password produces a warning on stderr. Use +`--json` for a password-free VM object instead of the human-readable summary. -```text -VM created: vm-... - IP: 10.42.0.2 - RAM: 512 MiB - CPU: 1.0 - TAP: uvm-... - Username: root - SSH: ssh root@10.42.0.2 - -WARNING: the guest is using the default password 'root'. Change it promptly. -``` - -Set a different password during creation: +### Names and Credentials ```sh -sudo ./uvm.py create --username root --password '' +sudo ./uvm.py create --name web --username root --password '' ``` -`--username` must name an account already present in the guest image. The -password is visible in the process arguments and may be recorded in shell -history, so avoid reusing a sensitive host or service password. +Names are optional, unique within the registry, and case-sensitive. They must +be 1-63 lowercase letters, digits, or hyphens, starting with a letter. Names +that impersonate generated IDs (`vm-` plus 32 hexadecimal characters) are +rejected. Names and IPs stay reserved while a VM is stopped. Names do not +create DNS records, and identifiers are not matched by ID prefix. + +`--username` must name an account already present in the guest image. A +`--password` value is visible in process arguments and may enter shell +history. Prefer `--password-stdin`, which reads one line, strips the trailing +line ending, and rejects empty passwords, control characters, or passwords +longer than 128 characters. It cannot be combined with `--password`. + +This Bash example prompts without echoing the password and avoids putting a +literal secret in the command line or history. Do not enable shell tracing: + +```bash +sudo -v +read -r -s -p 'Guest password: ' guest_password +printf '\n' +printf '%s\n' "$guest_password" | sudo ./uvm.py create --name app \ + --cpu 1 --ram 1G --guest-ip 10.42.0.10 \ + --ports 80:HTTP,443:HTTPS --password-stdin +unset guest_password +``` + +UVM still stores the password in plaintext in the root-only registry. Never +reuse a host/service password; replace `root` / `root` outside throwaway tests. + +### Initial Ports + +```sh +# Adds 80 and 443 to the default private TCP/22 rule. +sudo ./uvm.py create --name frontend --ports 80:HTTP --ports 443:HTTPS + +# Exactly TCP/443, with no SSH allowance. +sudo ./uvm.py create --name tls-app --ports 443:HTTPS --no-default-ports + +# Managed empty service allowlist, not an unmanaged VM. +sudo ./uvm.py create --name worker --no-default-ports +``` + +`--ports` uses colon syntax `PORT[:TYPE]`, not slash notation or host:guest +port mappings. See Private Port Policies below for live edits and network +scope. UVM does not install the services named by these labels. ### Scenario: Choose CPU and RAM ```sh sudo ./uvm.py create --cpu 2 --ram 2G -sudo ./uvm.py create --cpu 1 --ram 1024M -sudo ./uvm.py create --cpu 1 --ram 768 +sudo ./uvm.py create --cpu 0.5 --ram 512 ``` RAM is in MiB by default. Supported suffixes include `B`, `K`, `M`, `G`, @@ -235,70 +277,97 @@ UVM enforces a minimum of 128 MiB. Firecracker uses whole vCPUs. A fractional request such as `--cpu 0.5` is accepted but rounds the VM up to one Firecracker vCPU; it does not currently apply a host CPU quota. -```sh -sudo ./uvm.py create --cpu 0.5 --ram 512 -``` - ### Scenario: Request a Specific Guest IP ```sh -sudo ./uvm.py create --host-ip 10.42.0.10 +sudo ./uvm.py create --guest-ip 10.42.0.20 +# The legacy spelling --host-ip has the same meaning. ``` -The address must be inside the configured guest network, must not be the -network address, broadcast address, or gateway, and must not already belong to -another persisted VM. +The address must be a private RFC1918 IPv4 address inside the configured guest +network. It cannot be the network address, broadcast address, or gateway, or +already belong to another persisted VM (including a stopped one). ### What Happens During Create UVM performs these operations in order: -1. Validates root access, KVM, assets, and integrity state. -2. Reserves a VM ID, IP, MAC, and state record. -3. Creates `/var/lib/uvm/vms//`. -4. Copies the rootfs template to a private `rootfs.ext4` disk. -5. Sets the requested account password and enables SSH password login in the copy. -6. Ensures the bridge/NAT/forwarding rules exist. -7. Creates a TAP device. -8. Starts and configures Firecracker. -9. Marks the VM as `running`. +1. Checks root access, KVM, artifact integrity, and nftables/conntrack support. +2. Reserves identity, name/IP, and initial port policy in the registry. +3. Copies the template into a private disk and provisions credentials and SSH host keys. +4. Ensures bridge/egress networking and creates an owned TAP attached but down. +5. Installs and verifies the nftables bridge policy, then activates the TAP. +6. Starts Firecracker, persists process identity, and configures/boots the guest. +7. Confirms policy enforcement and records `running`. -If a step fails, UVM attempts to terminate the new VMM, remove the TAP, remove -the private runtime directory, and release the reservation. +On failure, UVM attempts containment and cleanup. It does not release the +disk or IP reservation if safe process exit or cleanup cannot be confirmed; +inspect the retained `failed` record and `last_error` before retrying. ## 6. Inspect VMs ```sh sudo ./uvm.py list +sudo ./uvm.py list --status running --status dead --json +sudo ./uvm.py list --status stopped -q +sudo ./uvm.py inspect web +sudo ./uvm.py inspect 10.42.0.10 --json ``` -Example: - -```text -ID IP USER CPU RAM STATUS PID -vm-0123456789abcdef... 10.42.0.2 root 1.0 512 running 12345 -``` +List includes names and policy status. `--status` filters by **observed** +status and may be repeated; `-q`/`--quiet` prints IDs only and is mutually +exclusive with `--json`. Inspect includes retained disk/log/config paths, +`disk_ready`, pending `operation`, and `last_error`, without the password. Status meanings: | Status | Meaning | |---|---| -| `starting` | VM creation is in progress. | -| `running` | Firecracker started successfully. | -| `stopping` | Stop is in progress. | +| `starting` | Create/start is in progress or was interrupted. | +| `running` | Firecracker started; this is not an SSH/application readiness probe. | +| `stopping` | Stop is in progress or was interrupted. | | `stopped` | VMM and TAP are gone; disk remains. | -| `terminating` | Destruction is in progress. | +| `terminating` | Deletion is pending. | | `failed` | A lifecycle operation or cleanup failed. | -| `dead` | Stored state claims the VM is active but its VMM process is gone. | +| `dead` | Observed-only status: stored state says running but the VMM is proven gone. | +| `unknown` | Process identity cannot be established safely; do not guess a PID to kill. | -If a VM is `failed` or `dead`, inspect its log before deciding whether to stop -or destroy it: +Stored `status` and `observed_status` can differ. Inspection does not restart, +adopt a legacy process, or reconcile a VM. Policy status is a separate snapshot: + +| Policy Status | Meaning | +|---|---| +| `enforced` | The actual owned rules and required bridge attachment settings match. | +| `inactive` | Managed VM is cleanly stopped; saved rules apply on next start. | +| `unmanaged` | Legacy record has no managed policy. | +| `missing` | Expected managed nftables table is absent. | +| `drift` | Actual rules, ownership, or bridge attachment settings differ. | +| `quarantined` | The inspected quarantine policy blocks the VM's TAP traffic. | +| `unknown` | Inspection failed or metadata could not establish policy status. | + +`enforced` is not a service-connectivity test or a lasting guarantee against +external rule changes. Mutation JSON may report policy `unknown` without a +fresh observation; use inspect or ports list for a current readback. + +### Logs and Doctor ```sh -sudo ls -la /var/lib/uvm/vms/ -sudo cat /var/lib/uvm/vms//firecracker.log +sudo ./uvm.py logs web +sudo ./uvm.py logs web --tail 50 +sudo ./uvm.py logs web --tail 0 -f +sudo ./uvm.py doctor +sudo ./uvm.py doctor --json ``` +Logs reads the local Firecracker stdout/stderr log, defaulting to the last +100 lines. `--tail` takes a non-negative integer; `--follow`/`-f` watches for +new output until interrupted. It is not an in-guest log API. + +Doctor checks root/KVM access, guest templates and integrity, the non-committing +nftables capability probe, and required command availability. It returns a +nonzero status if any check fails. It does not repair VMs or rules, boot a +guest, or prove SSH/egress connectivity. Use inspect for per-VM policy health. + ## 7. Connect with SSH UVM provisions a password for an existing account. The default credentials are @@ -310,84 +379,233 @@ UVM provisions a password for an existing account. The default credentials are UVM replaces conventional RSA, ECDSA, and Ed25519 SSH host keys in the private disk during creation and removes Firecracker's publicly known demo login key. -This is applied only while creating a new VM. Existing VM disks and legacy -registry entries are not changed retroactively. - -### Scenario: SSH as Root +This is applied only while creating a new VM. Start/restart preserves the +existing disk, credentials, and host keys; legacy disks are not changed +retroactively. ```sh -sudo ./uvm.py ssh vm- +sudo ./uvm.py ssh web ssh root@10.42.0.2 -``` - -### Scenario: Use a Different User and Private Key - -```sh -sudo ./uvm.py ssh vm- --user ubuntu --key ~/.ssh/id_ed25519 +sudo ./uvm.py ssh web --user ubuntu --key ~/.ssh/id_ed25519 --port 2222 +sudo ./uvm.py ssh web -- uname -a ``` By default, UVM uses SSH `StrictHostKeyChecking=accept-new` and a stable host-key alias derived from the VM ID. This lets a guest IP be reused later without confusing it with the prior VM's known-hosts entry. -### Scenario: Temporarily Bypass Host-Key Verification +The key must already be authorized by the guest; UVM does not inject it. +`--port` selects the guest SSH port (default 22), not a host mapping. The +guest's sshd and private allowlist must allow that port. SSH does not feed the +stored password to the client. A remote command must follow a literal `--`; +UVM SSH options can appear before or after the VM identifier. -Use only for troubleshooting a guest you trust: +Only for troubleshooting a guest you trust, disable host-key verification +for one connection: ```sh -sudo ./uvm.py ssh vm- --insecure-host-key +sudo ./uvm.py ssh web --insecure-host-key ``` -This disables normal host-key checking for that connection. - -## 8. Stop and Destroy VMs +## 8. Start, Stop, Restart, and Delete ### Scenario: Stop a VM and Keep Its Disk ```sh -sudo ./uvm.py stop vm- +sudo ./uvm.py stop web --timeout 5 ``` -Stopping terminates Firecracker and deletes the TAP device. The private root -disk, config, and log remain under the VM runtime directory. +Stop quarantines/deactivates the TAP, terminates Firecracker, removes the TAP, +cleans up VM-scoped connection tracking, and removes the owned policy table. +The private disk, config, log, credentials, name, IP, and saved ports remain. -UVM does not currently offer a `start` command, so a stopped disk cannot be -rebooted through the public CLI yet. Keep it only if you need to inspect it or -expect a future start feature. +**Stop is not a guest-coordinated shutdown.** UVM sends SIGTERM to the VMM, +waits, and sends SIGKILL if needed. `--timeout` accepts finite non-negative +seconds, including fractional values, and defaults to `5`. It controls the +SIGTERM wait, not the whole command: confirmed exit after SIGKILL and resource +cleanup can take additional time. `--timeout 0` skips the TERM wait, not exit +confirmation. Flush/quiesce important guest workloads separately before stop, +restart, or delete; retaining a disk does not guarantee application consistency. + +### Boot or Restart the Retained Disk + +```sh +sudo ./uvm.py start web +sudo ./uvm.py restart web --timeout 10 +``` + +Start boots the same complete private disk, without recopying the template, +resetting passwords, or regenerating SSH host keys. Guest changes survive; +guest RAM/process state does not. The verified kernel and VMM, KVM, firewall +support, and compatible network settings are still required. Removing the +original rootfs template does not prevent retained-disk start, although doctor +checks templates and can report it missing. + +Start on an already running, identified VM synchronizes its managed port +policy without rebooting. Restart is stop followed by start under the same +operation lock. Unknown process identity, an incomplete/unsafe disk, or pending +deletion blocks a new boot; inspect the error rather than editing registry +flags. An interrupted boot with a live VMM must be stopped before start. ### Scenario: Permanently Remove a VM ```sh -sudo ./uvm.py destroy vm- +sudo ./uvm.py delete web --dry-run +sudo ./uvm.py delete web -y +# Equivalent destructive command: sudo ./uvm.py destroy web -y ``` -Destroying a VM: +Delete performs the stop cleanup, then removes the runtime directory/private +disk and registry record, releasing the name and IP. `destroy` is the same +command with the same flags. Back up data first. A failed deletion remains +pending; retry delete rather than trying to resurrect it with start. -1. Terminates the VMM if it is alive. -2. Deletes the TAP device. -3. Deletes the VM runtime directory and private root disk. -4. Removes the VM record from `state.json`. -5. Releases its IP address for later allocation. - -This is destructive. Back up guest data before running it. - -## 9. Use the FastAPI Server - -The API is an alternative management surface for the same local lifecycle -services. It does not make UVM multi-host or safe to expose directly to the -public internet. - -### Scenario: Start a Local API Server - -Install dependencies first: +### Multiple VMs and Confirmation ```sh -cd uvm -python3 -m pip install . +sudo ./uvm.py stop web app --timeout 10 --json +sudo ./uvm.py start web app +sudo ./uvm.py restart --all --dry-run --json +sudo ./uvm.py restart --all -y +sudo ./uvm.py stop --all -y +sudo ./uvm.py start --all -y +sudo ./uvm.py delete --all --dry-run +sudo ./uvm.py delete --all --timeout 5 -y --json ``` -Then start the server as root so lifecycle endpoints can manipulate KVM, -networking, and host files: +Start/stop/restart/delete accept explicit identifiers **or** `--all`, never both. +Targets are resolved and deduplicated to a fixed ID snapshot before any prompt. +Later-created VMs are not added, and a reused name/IP is not retargeted. If +initial lookup fails, nothing is attempted. Once running, the batch reports +each result and continues after ordinary per-VM failures; it is not all-or-nothing. +An interrupt stops the batch and marks remaining targets skipped. + +Every delete and every mutating `--all` selection requires confirmation when +there are targets. Use `--yes`/`-y` for scripts; piping `yes` is not sufficient +because the prompt requires a TTY. `--dry-run` previews without prompting or +performing lifecycle mutations, but is not a boot/firewall preflight. Empty +`--all` selections succeed without prompting. Exit codes are `0` for success +or cancellation, `1` for operational/batch failure, `2` for invalid CLI input, +and `130` for interruption; inspect per-target JSON outcomes as well. + +## 9. Private Port Policies + +### Syntax and Scope + +Use `PORT[:TYPE]`, such as `22`, `80:HTTP`, `443:HTTPS`, and `8080:TCP`. +Ports must be integers from 1 to 65535. CLI labels are case-insensitive and +normalized to `TCP`, `HTTP`, or `HTTPS`; all three use TCP transport. Repeat +`--ports` or separate entries with commas. Identical entries are deduplicated; +conflicting labels for the same port in one request are rejected. Slash +syntax, ranges, UDP labels, and host:guest mappings are not accepted. + +Opening a port permits service traffic to the **guest private IPv4 address** +through the UVM bridge/TAP policy, including host-to-guest and VM-to-VM paths. +It does not start a listener, bypass the guest firewall, or configure HTTP or +TLS. Public connectivity, host-port mappings/DNAT, DNS publication, reverse +proxies, and guest-service TLS management are out of scope. External routing +or NAT can change reachability; a private IP is not by itself an exposure +guarantee, and UVM does not integrate with external firewall managers yet. + +UDP and IPv6 service policies are not yet supported. Unsolicited inbound UDP +is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4 TCP/UDP (including +DNS queries), replies to that egress, ARP, ICMP echo, and required related ICMP +errors remain allowed. An empty TCP allowlist is not a complete network +disconnect. Other VMs' policies still apply to guest-to-guest egress. + +### List, Open, Close, and Set + +```sh +sudo ./uvm.py ports list web --json +sudo ./uvm.py ports list --all +sudo ./uvm.py ports open web --ports 80:HTTP --ports 443:HTTPS,8080:TCP +sudo ./uvm.py ports close web --ports 80 +sudo ./uvm.py ports close web --ports 22 --dry-run --json +sudo ./uvm.py ports close web --ports 22 -y +sudo ./uvm.py ports set web --ports 22,443:HTTPS -y +sudo ./uvm.py ports close web --all -y +sudo ./uvm.py ports set web --none -y +``` + +- `list` inspects one VM or all VMs; it does not repair rules. +- `open` adds missing ports, preserving other ports and existing labels. +- `close` removes by port number, regardless of label; closing an absent port + is harmless. `close VM --all` means all ports of **one VM**, not all VMs. +- `set` replaces the entire policy, including labels. `--none` is an explicit + empty managed allowlist. It is not legacy/unmanaged mode. + +Only create adds TCP/22 by default. Set/open/close never add SSH implicitly; +include `22` yourself when replacing a policy if you want to keep SSH. +Open/close/set support `--json` and `--dry-run`. Close/set accept `--yes`/`-y`: +set, close-all, and removing TCP/22 prompt unless approved (an already empty +managed policy can be a no-op). Ordinary open has no confirmation flag. + +For a running VM, edits persist the desired policy and apply it atomically. +For a cleanly stopped VM, they update saved metadata for the next start. +After a successful close, subsequent packets on already established service +connections are dropped in **both directions**, as are new connections. +There is no guaranteed TCP RST, immediate socket close, or application-level +disconnect. Live edits retain conntrack state and preserve unrelated egress. + +### Explicit Sync and Recovery + +```sh +sudo ./uvm.py ports sync web app --dry-run --json +sudo ./uvm.py ports sync web +sudo ./uvm.py ports sync --all -y --json +``` + +Sync reapplies the saved managed policy to running VMs; it does not start +stopped VMs. It accepts multiple identifiers or `--all`, plus `--dry-run`, +`--json`, and `--yes`/`-y`. `--all` uses the same confirmation and fixed-target +batch behavior as lifecycle commands. + +UVM has no background firewall reconciler. If another administrator/tool +removes or changes UVM rules, inspect status and explicitly run `ports sync` +after resolving the external conflict. A failed policy update retains +the new intent with a pending `operation` and attempts TAP deactivation and +quarantine; it does not restore older, more permissive rules. Inspect +`last_error`, fix the underlying failure, then retry sync. Read-only status +commands and starting the API server do not repair lost rules. + +### Legacy VM Migration + +The default registry is `/var/lib/uvm/state.json`, schema version `2`. Older +registries are accepted and migrated on the next write. Missing/null `ports` +stays `None`/`unmanaged`, not an empty policy and not default SSH enforcement. +Open/close/sync/start do not silently adopt a policy for such a VM: + +```sh +sudo ./uvm.py inspect VM --json +sudo ./uvm.py stop VM +sudo ./uvm.py ports set VM --ports 22,80:HTTP -y +sudo ./uvm.py start VM +``` + +Choose the actual required ports, or use `ports set VM --none -y`. Policy must +first be set while the legacy VM is stopped. Start also requires a safe, +completely provisioned private disk; very old records without one cannot be +resumed by copying a new template over their data. + +A live legacy PID missing only the host boot ID can be adopted during a +locked mutation only if its saved start time, running Firecracker executable, +exact API-socket command line, and protected binary/socket paths match. A +missing start token, inaccessible evidence, or any ambiguity fails closed; +UVM will not blindly signal that PID or delete a possibly in-use disk. Listing +does not perform adoption. Inspect such failures rather than manually +inventing identity fields in `state.json`. + +## 10. Use the FastAPI Server + +The API uses the same local lifecycle services, but exposes only the existing +health/install/create/list/inspect/stop/delete endpoints. There are no new +start/restart, port-management, or bulk HTTP endpoints; SSH, logs, and doctor +also remain CLI-only. The server does not autostart VMs or reconcile rules, +and is not safe to expose directly to the public internet. + +### Start a Local Server + +After installing the project dependencies, start the server as root: ```sh export UVM_API_TOKEN='replace-with-a-long-random-visible-ascii-token' @@ -397,19 +615,17 @@ sudo env \ ./uvm.py --serve --host 127.0.0.1 --port 8000 ``` -Open these local URLs after it starts: - -```text -Health: http://127.0.0.1:8000/health -OpenAPI: http://127.0.0.1:8000/docs -``` +Liveness is at `http://127.0.0.1:8000/health`; OpenAPI docs are at +`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`. The API token is required even for a loopback server. All VM and installation routes require it. FastAPI's informational `/docs`, `/redoc`, and `/openapi.json` endpoints are also available without a token; they do not -perform host mutations. +perform host mutations. Tokens must be visible ASCII with no whitespace. +Use a protected environment file or secret manager rather than recording a +real token in shell history; do not share tokens or guest passwords in reports. -### Scenario: Start a Remote TLS API Server +### Start a TLS Server Non-loopback binds require a valid TLS certificate and key. The files must be readable by the server process: @@ -432,18 +648,14 @@ certificate for an untrusted network. ### API Authentication -Set one reusable shell variable for API examples: +Set variables for the request examples: ```sh export UVM_API_TOKEN='replace-with-the-server-token' export UVM_API_URL='http://127.0.0.1:8000' ``` -Every management request needs: - -```http -X-UVM-Token: -``` +Every management request needs the `X-UVM-Token` header, as below. For a TLS server, set `UVM_API_URL` to an `https://` URL and use your CA: @@ -454,124 +666,90 @@ curl --cacert /path/to/ca.pem \ "$UVM_API_URL/vms" ``` -## 10. API Endpoint Examples +## 11. API Endpoint Examples -### Health Check +| Method | Path | Operation | +|---|---|---| +| `GET` | `/health` | Unauthenticated liveness | +| `POST` | `/install` | Install/refresh with a `force` boolean | +| `GET` | `/vms` | List VM responses | +| `POST` | `/vms` | Create and boot, HTTP 201 on success | +| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | +| `POST` | `/vms/{identifier}/stop` | Stop and retain disk, using the default TERM wait | +| `DELETE` | `/vms/{identifier}` | Delete VM and private disk | -No token is required: +These stop/delete endpoints do not expose the CLI timeout, dry-run, or bulk +flags and do not prompt for confirmation. + +### Health, List, and Inspect ```sh +# Health is unauthenticated and returns {"status":"ok"}. curl "$UVM_API_URL/health" +curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/vms" +curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/vms/api-web" +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. ``` -Response: - -```json -{ - "status": "ok" -} -``` - -### List VMs - -```sh -curl \ - -H "X-UVM-Token: $UVM_API_TOKEN" \ - "$UVM_API_URL/vms" -``` - -Example response: - -```json -[ - { - "id": "vm-0123456789abcdef", - "cpu": 1.0, - "ram_mib": 512, - "guest_ip": "10.42.0.2", - "gateway": "10.42.0.1", - "mac": "02:fc:00:00:00:01", - "username": "root", - "status": "running", - "observed_status": "running", - "pid": 12345, - "created_at": 1700000000, - "updated_at": 1700000000, - "last_error": null - } -] -``` +Each response contains `id`, `name`, `cpu`, `ram_mib`, `guest_ip`, `gateway`, +`mac`, `username`, `status`, `observed_status`, `ports`, `policy_status`, +`operation`, `pid`, timestamps, and `last_error`. The response schema excludes +passwords and disk/socket/config/log path fields. Operational error messages +can still contain host paths; review them before sharing. ### Create a VM -Create a default-sized VM: +Create a named VM with a chosen guest address and private service ports: ```sh curl -X POST \ -H "Content-Type: application/json" \ -H "X-UVM-Token: $UVM_API_TOKEN" \ - -d '{"cpu": 1, "ram": "512", "username": "root", "password": "root"}' \ + -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}' \ "$UVM_API_URL/vms" ``` -Create a VM with a chosen guest address: +Create an explicit empty service policy, with no default SSH allowance: ```sh curl -X POST \ -H "Content-Type: application/json" \ -H "X-UVM-Token: $UVM_API_TOKEN" \ - -d '{"cpu": 2, "ram": "2G", "guest_ip": "10.42.0.10", "username": "root", "password": "replace-me"}' \ + -d '{"name":"api-worker","password":"replace-me","ports":[],"no_default_ports":true}' \ "$UVM_API_URL/vms" ``` -The response is the VM record with its username, but never its password. -Omitting credentials uses `root` / `root`. Request fields are strict: -misspelled or extra fields return HTTP `422` instead of being silently ignored. +Request fields are `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, +`ports`, and `no_default_ports`. The name has the same validation as the CLI. +`ports` is an array of objects (or null/omitted), not a CLI colon string. +Each object has a strict integer `port` from 1 to 65535 and an optional, +case-sensitive `protocol` of `TCP` (default), `HTTP`, or `HTTPS`. +`no_default_ports` is a strict boolean, default `false`; port 22 is added +unless that flag is true, even if `ports` is null, empty, or contains other +ports. Null create input does not create a legacy/unmanaged VM. -### Look Up One VM +The response never includes the password. Omitting credentials still uses +`root` / `root`. Extra request fields, invalid ports/names, and conflicting +labels return HTTP `422`. Example passwords above are placeholders; real +secrets in curl arguments can enter shell history and process listings. -By ID: - -```sh -curl \ - -H "X-UVM-Token: $UVM_API_TOKEN" \ - "$UVM_API_URL/vms/vm-0123456789abcdef" -``` - -By guest IP: - -```sh -curl \ - -H "X-UVM-Token: $UVM_API_TOKEN" \ - "$UVM_API_URL/vms/10.42.0.2" -``` - -### Stop a VM +### Stop and Delete ```sh curl -X POST \ -H "X-UVM-Token: $UVM_API_TOKEN" \ - "$UVM_API_URL/vms/vm-0123456789abcdef/stop" -``` + "$UVM_API_URL/vms/api-web/stop" -The response is the VM record with `status: "stopped"`. - -### Destroy a VM - -```sh +# Destructive: no interactive confirmation over HTTP. curl -X DELETE \ -H "X-UVM-Token: $UVM_API_TOKEN" \ - "$UVM_API_URL/vms/vm-0123456789abcdef" + "$UVM_API_URL/vms/api-web" ``` -Response: - -```json -{ - "id": "vm-0123456789abcdef", - "status": "terminated" -} -``` +Stop returns the VM response with `status: "stopped"`; delete returns its +exact `id` and `status: "terminated"`. Start the retained disk through the +local CLI, not a new HTTP route. ### Install or Refresh Through the API @@ -591,67 +769,50 @@ curl -X POST \ "$UVM_API_URL/install" ``` -Refresh all downloaded artifacts: +Use `{"force": true}` to refresh all downloaded artifacts. -```sh -curl -X POST \ - -H "Content-Type: application/json" \ - -H "X-UVM-Token: $UVM_API_TOKEN" \ - -d '{"force": true}' \ - "$UVM_API_URL/install" -``` - -## 11. API Errors +## 12. API Errors | Status | Typical meaning | |---|---| | `401` | Missing or incorrect `X-UVM-Token`. | | `403` | UVM process lacks root privileges for a host mutation. | | `404` | Requested VM was not found. | -| `409` | A lifecycle operation is already in progress. | +| `409` | Name/identifier conflict, incompatible retained state, or an unsafe/pending operation. | | `422` | Invalid JSON body, extra request fields, or invalid UVM input. | | `500` | Host command, Firecracker, state, or unexpected operational failure. | UVM operation errors use this envelope: ```json -{ - "error": { - "message": "human-readable explanation" - } -} +{"error": {"message": "human-readable explanation"}} ``` FastAPI request-schema errors use FastAPI's normal `detail` response format. -## 12. Configuration Scenarios +## 13. Configuration Scenarios -### Scenario: Use a Different Storage Directory +### Custom Storage and Network + +For example, install under `/srv/uvm` with a different private subnet. All +binaries, images, VM runtime directories, locks, and state use the base path: ```sh sudo env \ UVM_BASE='/srv/uvm' \ - UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 \ - ./uvm.py install -``` - -All UVM binaries, images, VM runtime directories, locks, and state use the -configured base path. - -### Scenario: Use a Different Guest Network - -```sh -sudo env \ UVM_NETWORK='10.50.0.0/24' \ UVM_GATEWAY='10.50.0.1' \ UVM_BRIDGE='uvm50' \ - UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 \ + UVM_FIRECRACKER_SHA256='' \ + UVM_KERNEL_SHA256='' \ + UVM_ROOTFS_SHA256='' \ ./uvm.py install ``` -Use the same environment values for later `create`, `list`, `stop`, `destroy`, -and server commands. Changing the network variables after VMs exist can make -their persisted state inconsistent with host networking. +Use the same base/network/gateway/bridge values for all later lifecycle, +inspection, port, and server commands. Start rejects conflicting retained +network settings. Use a non-overlapping private subnet; do not assume UVM +automatically detects every conflict with host routes or other managers. ### Environment Variables @@ -672,7 +833,7 @@ their persisted state inconsistent with host networking. | `UVM_API_TLS_CERT` | TLS certificate for remote server binds | | `UVM_API_TLS_KEY` | TLS key for remote server binds | -## 13. Files, Logs, and State +## 14. Files, Logs, and State The default host layout is: @@ -685,36 +846,43 @@ The default host layout is: └── state.json # Mode-0600 VM inventory and plaintext guest credentials ``` -Useful inspection commands: - -```sh -sudo cat /var/lib/uvm/state.json -sudo ls -la /var/lib/uvm/vms -sudo cat /var/lib/uvm/vms//firecracker.log -sudo cat /var/lib/uvm/vms//config.json -``` - -Avoid editing `state.json`, VM config files, runtime directories, TAPs, or -iptables rules by hand while UVM is running. Use UVM commands or the API so -locks and cleanup rules remain correct. +Registry writes are atomic, with a short `state.lock`; lifecycle, policy, and +installation mutations share `operations.lock`. Avoid editing `state.json`, +runtime files, TAPs, or UVM-owned nftables/iptables rules by hand. Use supported +commands so ownership, locking, and cleanup remain correct. Back up important +disks while stopped and keep the associated registry backup protected. `state.json` contains plaintext guest passwords. Keep it root-only, never copy it into logs or bug reports, and use unique non-default passwords outside local -throwaway environments. +throwaway environments. Prefer inspect/list to dumping the registry. Raw +Firecracker logs may include guest/application secrets; redact them before sharing. -## 14. Networking Troubleshooting +## 15. Networking Troubleshooting + +The existing `iptables` egress MASQUERADE and forwarding rules are unchanged. +Private port filtering is a separate native nftables `bridge` policy, not +DNAT. It must be installed and read back before the TAP is active. The bridge +must have STP disabled and each TAP must have bridge-slave `group_fwd_mask` +`0x4000` (16384), so LLDP takes the early identity-filter path rather than +bypassing it before MAC learning. These are software-bridge requirements; +do not offload traffic around the hooks. Native bridge conntrack needs Linux +>= 5.3; this layer does not configure `br_netfilter` sysctls. Check the bridge and TAP state: ```sh -ip link show uvm0 +ip -d link show uvm0 ip addr show dev uvm0 ip route show default ``` -Check NAT and forwarding rules: +Check port policy and the separate egress NAT/forwarding rules: ```sh +sudo ./uvm.py doctor +sudo ./uvm.py inspect web --json +sudo ./uvm.py ports list --all +sudo nft --json list tables bridge sudo iptables -t nat -S POSTROUTING sudo iptables -S FORWARD ``` @@ -726,24 +894,32 @@ Common issues: | Guest cannot reach the internet | Bridge is up, TAP is up, host has default route, NAT and FORWARD rules exist. | | Requested guest IP is rejected | Confirm it is inside the configured subnet and not used, gateway, network, or broadcast. | | Create says TAP already exists | Inspect the interface; do not delete it unless you know it is stale UVM state. | -| Existing bridge is rejected | `UVM_BRIDGE` points to an interface that is not a Linux bridge. | -| SSH cannot connect | Confirm guest booted, the requested user exists, the image runs OpenSSH, and the configured password is being used. | +| Existing bridge is rejected | Verify it is a software Linux bridge with STP disabled. | +| Firewall unavailable/unknown | Check privileges, `nft`, `conntrack`, kernel support, and bridge/TAP settings. | +| Policy missing/drift after external reload | Resolve the other manager's conflict, then explicitly run `ports sync VM`; there is no automatic repair. | +| SSH cannot connect | Confirm boot/sshd, guest user and password/key, chosen SSH port, allowlist, and guest firewall. | +| Closed SSH session hangs rather than disconnects | Close drops subsequent traffic in both directions; a TCP RST is not guaranteed. | -## 15. Important Limitations +External firewall integration (including UFW, firewalld, Docker, and libvirt) +is deferred. A UVM policy readback does not prove that external rules permit +traffic, or that they will not later remove UVM's rules. + +## 16. Important Limitations Keep these constraints in mind when deciding whether UVM fits a scenario: - Firecracker is launched directly; the downloaded Jailer is not yet used. -- No cgroup CPU, memory, I/O, or process limits are applied. +- No quotas or cgroup CPU, memory, I/O, or process limits are applied. - Fractional CPU requests are advisory only. -- There is no public `start` or `restart` command after a VM is stopped. -- There is no DHCP, inbound port mapping, load balancing, multi-network - isolation, or multi-host scheduling. +- No autostart, background lifecycle/firewall reconciliation, or guest-coordinated stop. +- No UDP/IPv6 service policies, public connectivity setup, host-port mapping, + DNS publication, guest reverse proxy/TLS setup, DHCP, load balancing, + snapshots, multi-network tenancy, or multi-host scheduling. - The API uses one shared bearer token, not individual users, roles, or tenants. - There is no guest readiness probe beyond Firecracker process state. - UVM is not a hardened multi-tenant platform. -## 16. Safe Operating Checklist +## 17. Safe Operating Checklist Before relying on a VM for useful work: @@ -751,26 +927,52 @@ Before relying on a VM for useful work: 2. Confirm `/dev/kvm` and nested virtualization are available. 3. Confirm UVM generated unique guest SSH host keys and set a non-default password. 4. Test guest egress and SSH on the actual host network. -5. Back up guest data before destroy operations. +5. Quiesce guest workloads before stopping/restarting and back up data before deletion. 6. Keep the API loopback-only unless you have a strong remote-access need. 7. For remote API access, use a long random token, valid TLS, and firewall restrictions. 8. Review `/var/lib/uvm/vms//firecracker.log` after failed starts. -## 17. Get Help +## 18. Verification + +From the project root, normal unit tests need no root, KVM, or host network +changes. Install the test extra in your Python environment for API tests: + +```sh +python3 -m pip install '.[test]' +python3 -B -m unittest discover -s tests -v +``` + +The opt-in packet test runs **only in disposable isolated user/network +namespaces**, which may require privileges. It needs `unshare`, `ip`, +`bridge`, `nft`, `conntrack`, and `sysctl`: + +```sh +UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v +``` + +Namespace creation was denied in this workspace (`Operation not permitted`), +so the packet test skipped. That is a permission limitation, not real-packet +or real-host validation. The harness uses veths as TAP stand-ins and routed +egress without NAT; it does not boot Firecracker or validate the host's real +TAP/NAT setup. Run actual VM/SSH/egress checks on an isolated Linux host before +relying on the deployment. + +## 19. Get Help ```sh ./uvm.py --help -./uvm.py install --help -./uvm.py --serve --help +./uvm.py create --help +./uvm.py ports set --help +./uvm.py ssh --help ``` When reporting a problem, collect: -- The exact command or HTTP request. +- The command or HTTP request, with passwords and tokens redacted. - The full error message. -- Output from `sudo ./uvm.py list`. -- The VM Firecracker log, if a VM was created. +- Password-free output from `list --json`, `inspect VM --json`, and `doctor`. +- The VM Firecracker log, reviewed for guest/application secrets. - The relevant `UVM_*` variables, with secrets such as `UVM_API_TOKEN` removed. Never include `/var/lib/uvm/state.json` because it contains guest passwords. diff --git a/tests/integration/test_private_ports.py b/tests/integration/test_private_ports.py new file mode 100644 index 0000000..32c77a2 --- /dev/null +++ b/tests/integration/test_private_ports.py @@ -0,0 +1,421 @@ +"""Opt-in real packet tests, using only disposable user/network namespaces. + +Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v + +Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network +namespaces (root, or unprivileged user namespaces). No sudo, installation, +/run/netns mounts, host firewall changes or br_netfilter sysctls are used. +Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests +guest egress without NAT. Ordinary test runs skip without executing commands. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import re +import select +import shutil +import signal +import socket +import struct +import subprocess +import sys +import time +import unittest +from ipaddress import IPv4Address, IPv4Network +from pathlib import Path + + +class SocketEndpoint: + """Synchronous socket operations; the kernel completes queued handshakes.""" + + def __init__(self): + self.sockets = {} + + def close(self): + for sock in self.sockets.values(): + sock.close() + + def request(self, operation, **args): + if operation == "run": + result = subprocess.run(args["command"], check=True, text=True, + capture_output=True, timeout=5) + return result.stdout + if operation in {"listen", "connect", "udp_bind"}: + family = socket.AF_INET6 if ":" in args["ip"] else socket.AF_INET + kind = socket.SOCK_DGRAM if operation == "udp_bind" else socket.SOCK_STREAM + sock = socket.socket(family, kind) + sock.settimeout(1) + if kind == socket.SOCK_STREAM: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if family == socket.AF_INET6: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + try: + if operation == "connect": + sock.connect((args["ip"], args["port"])) + else: + sock.bind((args["ip"], args["port"])) + if operation == "listen": + sock.listen(16) + except TimeoutError: + sock.close() + return False + except BaseException: + sock.close() + raise + self.sockets[args["name"]] = sock + return True + if operation == "accept": + sock, _ = self.sockets[args["listener"]].accept() + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.settimeout(1) + self.sockets[args["name"]] = sock + return True + if operation in {"send", "udp_send"}: + sock = self.sockets[args["name"]] + data = args["data"].encode("ascii") + if operation == "send": + sock.sendall(data) + else: + sock.sendto(data, (args["ip"], args["port"])) + return True + if operation in {"recv", "udp_recv"}: + sock = self.sockets[args["name"]] + sock.settimeout(0.4 if args.get("blocked") else 2) + data = b"" + try: + if operation == "udp_recv": + data, address = sock.recvfrom(4096) + return [data.decode("ascii"), list(address)] + while len(data) < args["size"]: + chunk = sock.recv(args["size"] - len(data)) + if not chunk: + break + data += chunk + except TimeoutError: + return data.decode("ascii") if data else None + return data.decode("ascii") + if operation == "ping": + ident = os.getpid() & 0xFFFF + frame = struct.pack("!BBHHH", 8, 0, 0, ident, 1) + b"uvm-private-ports!" + padded = frame + b"\0" * (len(frame) % 2) + checksum = sum(struct.unpack(f"!{len(padded) // 2}H", padded)) + checksum = (checksum & 65535) + (checksum >> 16) + checksum = (~((checksum & 65535) + (checksum >> 16))) & 65535 + frame = frame[:2] + struct.pack("!H", checksum) + frame[4:] + with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) as sock: + sock.settimeout(1) + sock.sendto(frame, (args["ip"], 0)) + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + try: + data, address = sock.recvfrom(65535) + except TimeoutError: + return False + offset = (data[0] & 15) * 4 + if (address[0] == args["ip"] and data[offset] == 0 + and data[offset + 4:offset + 8] == frame[4:8]): + return True + return False + if operation == "raw": + with socket.socket(socket.AF_PACKET, socket.SOCK_RAW) as sock: + sock.bind(("eth0", 0)) + sock.send(bytes.fromhex(args["frame"])) + return True + raise AssertionError(f"unknown endpoint operation {operation}") + + +def _require_isolated(parent_fd): + parent, current = os.fstat(parent_fd), os.stat("/proc/self/ns/net") + if (parent.st_dev, parent.st_ino) == (current.st_dev, current.st_ino): + raise RuntimeError("refusing packet/firewall operations in the parent network namespace") + os.close(parent_fd) + + +class NamespaceEndpoint: + def __enter__(self): + with open("/proc/self/ns/net", "rb") as parent: + self.process = subprocess.Popen( + ("unshare", "--net", sys.executable, "-B", str(Path(__file__).resolve()), + "--agent", str(parent.fileno())), + pass_fds=(parent.fileno(),), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + ) + try: + assert self._read() == "ready" + except BaseException: + self.__exit__(None, None, None) + raise + return self + + def __exit__(self, *_): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=3) + for pipe in (self.process.stdin, self.process.stdout, self.process.stderr): + pipe.close() + + def _read(self): + if not select.select([self.process.stdout], [], [], 8)[0]: + raise AssertionError("namespace endpoint timed out") + line = self.process.stdout.readline() + if not line: + detail = self.process.stderr.read() if self.process.poll() is not None else "" + raise AssertionError(f"namespace endpoint exited: {detail}") + response = json.loads(line) + if "error" in response: + raise AssertionError(response["error"]) + return response["result"] + + def request(self, operation, **args): + self.process.stdin.write(json.dumps({"operation": operation, **args}) + "\n") + self.process.stdin.flush() + return self._read() + + +def _agent(parent_fd): + _require_isolated(parent_fd) + endpoint = SocketEndpoint() + try: + print(json.dumps({"result": "ready"}), flush=True) + for line in sys.stdin: + request = json.loads(line) + try: + result = endpoint.request(**request) + response = {"result": result} + except Exception as error: + response = {"error": f"{type(error).__name__}: {error}"} + print(json.dumps(response), flush=True) + finally: + endpoint.close() + + +def _sandbox(parent_fd): + _require_isolated(parent_fd) + if not __debug__: + raise RuntimeError("packet verification requires assertions; disable PYTHONOPTIMIZE/-O") + # Direct script execution must import the workspace, not an installed uvm. + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from uvm.config import Settings + from uvm.domain import PortRule, VmRecord + from uvm.errors import UvmError + from uvm.firewall import FirewallManager + from uvm.system import CommandRunner + + runner = CommandRunner(emit=None) + + def run(*args): + return runner.run(args, capture=True, timeout=5).stdout + + assert [link["ifname"] for link in json.loads(run("ip", "-j", "link", "show"))] == ["lo"] + assert not any("table" in item for item in json.loads(run("nft", "--json", "list", "tables"))["nftables"]) + settings = Settings(base=Path("/tmp/uvm-firewall-namespace-test"), bridge="fw-br0", + network=IPv4Network("10.231.73.0/24"), gateway=IPv4Address("10.231.73.1")) + manager = FirewallManager(settings, runner) + manager.check_available() + run("ip", "link", "set", "lo", "up") + run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0") + run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge) + run("ip", "link", "set", settings.bridge, "up") + # This IPv4 sysctl belongs only to the freshly verified network namespace. + run("sysctl", "-w", "net.ipv4.ip_forward=1") + + metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway), + socket="", config="", log="", network=str(settings.network), bridge=settings.bridge) + vm = VmRecord(id="integration-vm", tap="fw-tap1", guest_ip="10.231.73.2", + mac="02:fc:00:00:00:01", ports=(PortRule(8080, "HTTP"),), **metadata) + peer_vm = VmRecord(id="integration-peer", tap="fw-tap2", guest_ip="10.231.73.3", + mac="02:fc:00:00:00:02", ports=(PortRule(9090),), **metadata) + + with contextlib.ExitStack() as stack: + guest = stack.enter_context(NamespaceEndpoint()) + peer = stack.enter_context(NamespaceEndpoint()) + outside = stack.enter_context(NamespaceEndpoint()) + host = SocketEndpoint() + stack.callback(host.close) + + for endpoint, tap, address, mac in ( + (guest, vm.tap, vm.guest_ip, vm.mac), + (peer, peer_vm.tap, peer_vm.guest_ip, peer_vm.mac), + (outside, "fw-up", "172.30.73.2", "02:fc:00:00:00:03"), + ): + run("ip", "link", "add", tap, "type", "veth", "peer", "name", "fw-end") + run("ip", "link", "set", "fw-end", "netns", str(endpoint.process.pid)) + endpoint.request("run", command=["ip", "link", "set", "fw-end", "name", "eth0"]) + endpoint.request("run", command=["ip", "link", "set", "lo", "up"]) + endpoint.request("run", command=["ip", "link", "set", "eth0", "address", mac]) + endpoint.request("run", command=["ip", "addr", "add", f"{address}/24", "dev", "eth0"]) + endpoint.request("run", command=["ip", "link", "set", "eth0", "up"]) + if endpoint is outside: + run("ip", "addr", "add", "172.30.73.1/24", "dev", tap) + endpoint.request("run", command=["ip", "route", "add", str(settings.network), "via", "172.30.73.1"]) + run("ip", "link", "set", tap, "up") + else: + record = vm if endpoint is guest else peer_vm + run("ip", "link", "set", "dev", tap, "alias", f"uvm:{record.id}") + run("ip", "link", "set", tap, "master", settings.bridge) + run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000") + endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)]) + + manager.apply(vm) + manager.apply(peer_vm) + for tap in (vm.tap, peer_vm.tap): + run("ip", "link", "set", tap, "up") + assert manager.status(vm) == manager.status(peer_vm) == "enforced" + + def stream(client, server, target, port, name): + assert client.request("connect", name=name, ip=target, port=port) + assert server.request("accept", listener="listener", name=name) + + def transfer(sender, receiver, name, text, *, blocked=False): + assert sender.request("send", name=name, data=text) + received = receiver.request("recv", name=name, size=len(text), blocked=blocked) + assert received == (None if blocked else text), (name, text, received, blocked) + + guest.request("listen", name="listener", ip=vm.guest_ip, port=8080) + peer.request("listen", name="listener", ip=peer_vm.guest_ip, port=9090) + host.request("listen", name="listener", ip=str(settings.gateway), port=9000) + outside.request("listen", name="listener", ip="172.30.73.2", port=9000) + stream(host, guest, vm.guest_ip, 8080, "host-service") + stream(peer, guest, vm.guest_ip, 8080, "peer-service") + stream(guest, host, str(settings.gateway), 9000, "host-egress") + stream(guest, outside, "172.30.73.2", 9000, "routed-egress") + stream(guest, peer, peer_vm.guest_ip, 9090, "peer-egress") + pairs = ((host, guest, "host-service"), (peer, guest, "peer-service"), + (guest, host, "host-egress"), (guest, outside, "routed-egress"), + (guest, peer, "peer-egress")) + for client, server, name in pairs: + transfer(client, server, name, "client-before") + transfer(server, client, name, "server-before") + print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True) + + def service_ids(): + listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp", + "--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id") + return set(re.findall(r"\bid=(\d+)", listing)) + + before = service_ids() + assert len(before) == 2, before + vm.ports = () + manager.apply(vm) + assert manager.status(vm) == "enforced" + assert service_ids() == before, "closing a port must preserve connection tracking" + for client, server, name in pairs[:2]: + transfer(client, server, name, "client-after-close", blocked=True) + transfer(server, client, name, "server-after-close", blocked=True) + for client, server, name in pairs[2:]: + transfer(client, server, name, "egress-after-close") + transfer(server, client, name, "reply-after-close") + assert not host.request("connect", name="closed-host", ip=vm.guest_ip, port=8080) + assert not peer.request("connect", name="closed-peer", ip=vm.guest_ip, port=8080) + print("PASS: existing service streams drop BOTH directions, CT IDs and guest egress survive", flush=True) + + peer_vm.ports = () + manager.apply(peer_vm) + transfer(guest, peer, "peer-egress", "peer-client-closed", blocked=True) + transfer(peer, guest, "peer-egress", "peer-server-closed", blocked=True) + transfer(guest, outside, "routed-egress", "other-table-survives") + transfer(outside, guest, "routed-egress", "other-table-reply") + + guest.request("udp_bind", name="dns", ip=vm.guest_ip, port=40053) + outside.request("udp_bind", name="dns", ip="172.30.73.2", port=53) + guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query") + query = outside.request("udp_recv", name="dns") + assert query[0] == "dns-query" + outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply") + assert guest.request("udp_recv", name="dns")[0] == "dns-reply" + guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054) + outside.request("udp_send", name="dns", ip=vm.guest_ip, port=40054, data="unsolicited") + assert guest.request("udp_recv", name="unsolicited", blocked=True) is None + for endpoint, target in ((host, vm.guest_ip), (peer, vm.guest_ip), + (guest, str(settings.gateway)), (guest, "172.30.73.2")): + assert endpoint.request("ping", ip=target), (endpoint, target) + print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True) + + # Spoofed source MACs must not reach FDB learning, including the local + # link-local path which can skip bridge prerouting entirely. + spoof = bytes.fromhex("02aabbccddee") + arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) + spoof + socket.inet_aton(vm.guest_ip) + arp += b"\0" * 6 + socket.inet_aton(str(settings.gateway)) + for destination in (b"\xff" * 6, bytes.fromhex("0180c200000e"), bytes.fromhex("0180c2000003")): + guest.request("raw", frame=(destination + spoof + b"\x08\x06" + arp).hex()) + time.sleep(0.1) + fdb = json.loads(run("bridge", "-j", "fdb", "show", "br", settings.bridge)) + assert not any(entry.get("mac") == "02:aa:bb:cc:dd:ee" for entry in fdb) + + run("ip", "-6", "addr", "add", "fd42:73::1/64", "dev", settings.bridge, "nodad") + guest.request("run", command=["ip", "-6", "addr", "add", "fd42:73::2/64", "dev", "eth0", "nodad"]) + host_mac = json.loads(run("ip", "-j", "link", "show", "dev", settings.bridge))[0]["address"] + run("ip", "-6", "neigh", "replace", "fd42:73::2", "lladdr", vm.mac, "nud", "permanent", "dev", settings.bridge) + guest.request("run", command=["ip", "-6", "neigh", "replace", "fd42:73::1", "lladdr", host_mac, + "nud", "permanent", "dev", "eth0"]) + guest.request("listen", name="v6-listener", ip="fd42:73::2", port=8080) + host.request("listen", name="v6-listener", ip="fd42:73::1", port=9000) + assert not host.request("connect", name="v6-inbound", ip="fd42:73::2", port=8080) + assert not guest.request("connect", name="v6-outbound", ip="fd42:73::1", port=9000) + print("PASS: pre-learning MAC identity and IPv6 blocking in both directions", flush=True) + + manager.apply(vm, quarantine=True) + assert manager.status(vm) == "quarantined" + transfer(guest, host, "host-egress", "quarantined-out", blocked=True) + transfer(host, guest, "host-egress", "quarantined-in", blocked=True) + try: + manager.remove(vm) + except UvmError: + pass + else: + raise AssertionError("removal must refuse a live port") + run("ip", "neigh", "flush", "dev", settings.bridge) + assert not host.request("ping", ip=vm.guest_ip), "quarantine must also stop ARP" + run("ip", "link", "del", vm.tap) + manager.cleanup_connections(vm) + manager.remove(vm) + assert manager.status(vm) == "missing" + assert manager.status(peer_vm) == "enforced" + print("PASS: quarantine, offline CT cleanup and sibling-safe removal", flush=True) + + +@unittest.skipUnless(os.environ.get("UVM_FIREWALL_INTEGRATION") == "1", + "opt in with UVM_FIREWALL_INTEGRATION=1; needs isolated network namespaces") +class PrivatePortNamespaceTests(unittest.TestCase): + def test_private_port_packet_paths_and_live_close(self): + if sys.platform != "linux" or shutil.which("unshare") is None: + self.skipTest("Linux and unshare are required") + prefix = ("unshare", "--net") if os.geteuid() == 0 else ( + "unshare", "--user", "--map-root-user", "--net", + ) + probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5) + if probe.returncode: + self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}") + missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None] + if missing: + self.skipTest(f"integration tools unavailable: {', '.join(missing)}") + with open("/proc/self/ns/net", "rb") as parent: + process = subprocess.Popen( + (*prefix, sys.executable, "-B", str(Path(__file__).resolve()), "--sandbox", str(parent.fileno())), + pass_fds=(parent.fileno(),), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, + ) + try: + stdout, stderr = process.communicate(timeout=120) + except BaseException: + os.killpg(process.pid, signal.SIGKILL) + process.communicate(timeout=5) + raise + self.assertEqual(process.returncode, 0, f"namespace scenario failed:\n{stdout}\n{stderr}") + print(stdout, end="") + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--sandbox": + _sandbox(int(sys.argv[2])) + elif len(sys.argv) == 3 and sys.argv[1] == "--agent": + _agent(int(sys.argv[2])) + else: + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index debffb7..749bc4d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,6 +44,8 @@ class CliSshTests(unittest.TestCase): key=None, user="root", insecure_host_key=False, + port=22, + remote_command=[], ) with patch("uvm.cli.os.execvp") as execvp: @@ -61,6 +63,8 @@ class CliSshTests(unittest.TestCase): key=None, user="root", insecure_host_key=True, + port=22, + remote_command=[], ) with patch("uvm.cli.os.execvp") as execvp: @@ -77,6 +81,8 @@ class CliSshTests(unittest.TestCase): key=None, user=None, insecure_host_key=False, + port=22, + remote_command=[], ) with patch("uvm.cli.os.execvp") as execvp: @@ -105,6 +111,11 @@ class CliCreateTests(unittest.TestCase): host_ip=None, username="admin", password="custom-secret", + name=None, + ports=[], + no_default_ports=False, + password_stdin=False, + json=False, ) output = io.StringIO() diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 0000000..17b61f0 --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,886 @@ +from __future__ import annotations + +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import replace +from ipaddress import IPv4Address +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, create_autospec, patch + +from tests.test_cli import make_vm +from uvm.cli import build_parser, main +from uvm.config import Settings +from uvm.domain import PortRule, VmRecord +from uvm.errors import UvmError +from uvm.lifecycle import LifecycleService, ListedVm, OperationResult +from uvm.system import CommandRunner + + +class TtyInput(io.StringIO): + def isatty(self) -> bool: + return True + + +class CliTestCase(unittest.TestCase): + def setUp(self) -> None: + self.vm = replace( + make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")), + status="running", disk_ready=True, + ) + self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True) + self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced") + self.lifecycle.list_vms.return_value = [self.listed] + self.lifecycle.inspect.return_value = self.listed + self.lifecycle.find_for_ssh.return_value = self.vm + self.lifecycle.resolve_targets.return_value = [self.vm] + self.lifecycle.set_ports.return_value = self.vm + self.lifecycle.run_many.side_effect = lambda action, ids, **_kwargs: [ + OperationResult(id=vm_id, action=action, outcome="succeeded", error=None) + for vm_id in ids + ] + self.lifecycle.create.side_effect = lambda spec: replace( + self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username, + password=spec.password, name=spec.name, ports=spec.ports, + ) + self.application = SimpleNamespace( + settings=Settings(), lifecycle=self.lifecycle, + state_store=Mock(spec_set=("operation_lock",)), + ) + self.application.state_store.operation_lock.side_effect = AssertionError( + "CLI must not take an operation lock" + ) + + def invoke(self, arguments, *, stdin=None): + stdout, stderr = io.StringIO(), io.StringIO() + with ( + patch("uvm.cli.build_application", return_value=self.application) as build, + patch("uvm.cli.sys.stdin", stdin if stdin is not None else io.StringIO()), + redirect_stdout(stdout), redirect_stderr(stderr), + ): + try: + result = main(arguments) + except SystemExit as error: + result = error.code + self.build_call = build.call_args + return result, stdout.getvalue(), stderr.getvalue() + + +class CliParsingTests(CliTestCase): + def test_guest_ip_alias_keeps_the_original_destination(self) -> None: + for option in ("--host-ip", "--guest-ip"): + with self.subTest(option=option): + args = build_parser(self.application).parse_args(["create", option, "10.42.0.8"]) + self.assertEqual(args.host_ip, "10.42.0.8") + self.assertEqual(args.password, "root") + self.assertFalse(args.password_stdin) + + def test_bulk_selectors_are_exclusive_and_required(self) -> None: + for command in (("start",), ("stop",), ("restart",), ("delete",), ("destroy",), ("ports", "sync")): + with self.subTest(command=command): + parser = build_parser(self.application) + named = parser.parse_args([*command, "web", "10.42.0.3", "--yes", "--json"]) + self.assertEqual(named.vms, ["web", "10.42.0.3"]) + self.assertFalse(named.all_vms) + all_vms = parser.parse_args([*command, "--all", "--dry-run"]) + self.assertTrue(all_vms.all_vms) + self.assertEqual(all_vms.vms, []) + for selector in ([], ["web", "--all"], ["--all", "web"]): + code, _, _ = self.invoke([*command, *selector]) + self.assertEqual(code, 2) + self.lifecycle.run_many.assert_not_called() + + def test_ports_subcommand_selector_semantics(self) -> None: + parser = build_parser(self.application) + close = parser.parse_args(["ports", "close", "web", "--all"]) + self.assertEqual(close.vm, "web") + self.assertTrue(close.all_ports) + listing = parser.parse_args(["ports", "list", "--all"]) + self.assertTrue(listing.all_vms) + self.assertIsNone(listing.vm) + for arguments in ( + ["ports"], ["ports", "list"], ["ports", "list", "web", "--all"], + ["ports", "open", "web"], ["ports", "open", "web", "--all"], + ["ports", "close", "--all"], ["ports", "close", "web"], + ["ports", "close", "web", "--ports", "80", "--all"], + ["ports", "set", "web"], ["ports", "set", "web", "--all"], + ["ports", "set", "web", "--ports", "80", "--none"], + ): + with self.subTest(arguments=arguments): + self.assertEqual(self.invoke(arguments)[0], 2) + self.lifecycle.set_ports.assert_not_called() + + def test_mutually_exclusive_output_and_password_options(self) -> None: + for arguments in ( + ["list", "--json", "-q"], + ["create", "--password", "root", "--password-stdin"], + ): + with self.subTest(arguments=arguments): + self.assertEqual(self.invoke(arguments)[0], 2) + self.lifecycle.create.assert_not_called() + + def test_no_bare_identifier_guessing_or_public_port_flags(self) -> None: + for arguments in ( + ["web"], ["vm-test"], ["10.42.0.2"], + ["create", "--public-port", "8080"], + ["create", "--ports", "127.0.0.1:8080:80"], + ["create", "--ports", "8080:80"], + ): + with self.subTest(arguments=arguments): + self.assertEqual(self.invoke(arguments)[0], 2) + self.lifecycle.create.assert_not_called() + + def test_explicit_invalid_ports_fail_before_any_lifecycle_calls(self) -> None: + self.vm.ports = () + for command in (["create"], ["ports", "open", "web"], + ["ports", "close", "web"], ["ports", "set", "web"]): + for invalid in ("", "80/http", "443/HTTPS", "22/TCP", "80:UDP", "80:", + "80:HTTP:TCP", "22,", "80:HTTP,65536:TCP"): + with self.subTest(command=command, invalid=invalid): + code, stdout, _ = self.invoke([ + *command, "--ports", "8080:TCP", "--ports", invalid, "--json", + ]) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertEqual(self.lifecycle.mock_calls, []) + + def test_port_help_documents_only_colon_syntax(self) -> None: + for command in (["create"], ["ports", "open"], ["ports", "close"], ["ports", "set"]): + with self.subTest(command=command): + code, stdout, stderr = self.invoke([*command, "--help"]) + self.assertEqual(code, 0, stderr) + self.assertIn("PORT[:TYPE]", stdout) + self.assertIn("80:HTTP", stdout) + self.assertNotIn("80/HTTP", stdout) + + def test_timeout_help_describes_process_signals_not_graceful_guest_shutdown(self) -> None: + for command in ("stop", "restart", "delete", "destroy"): + with self.subTest(command=command): + code, stdout, stderr = self.invoke([command, "--help"]) + self.assertEqual(code, 0, stderr) + self.assertIn("SIGTERM", stdout) + self.assertIn("SIGKILL", stdout) + self.assertIn("does not coordinate guest shutdown", " ".join(stdout.split())) + self.assertNotIn("graceful", stdout.lower()) + + def test_server_and_ssh_ports_have_independent_defaults(self) -> None: + parser = build_parser(self.application) + self.assertEqual(parser.parse_args(["--serve"]).port, 8000) + self.assertEqual(parser.parse_args(["ssh", "web"]).port, 22) + self.assertEqual(parser.parse_args(["--port", "8123", "ssh", "web"]).port, 22) + for value in ("0", "65536", "tcp", "1.5"): + for command in (["--serve", "--port", value], ["ssh", "web", "--port", value]): + with self.subTest(command=command): + self.assertEqual(self.invoke(command)[0], 2) + + def test_server_mode_is_preserved_and_cannot_run_a_command(self) -> None: + with patch("uvm.cli._run_server", return_value=0) as server: + code, _, _ = self.invoke(["--serve", "--host", "127.0.0.1", "--port", "8123"]) + self.assertEqual(code, 0) + self.assertEqual(server.call_args.args[1].port, 8123) + self.assertEqual(self.invoke(["--serve", "list"])[0], 2) + self.assertEqual(self.invoke([])[0], 2) + + +class CliCreateCommandTests(CliTestCase): + def test_create_combines_default_ports_and_stdin_credentials(self) -> None: + code, stdout, stderr = self.invoke([ + "create", "--name", "web", "--cpu", "0.5", "--ram", "1G", + "--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin", + "--ports", "80:http,443:https", "--ports", "8080:tcp", "--json", + ], stdin=io.StringIO(" stdin-secret \r\n")) + + self.assertEqual(code, 0, stderr) + spec = self.lifecycle.create.call_args.args[0] + self.assertEqual(spec.name, "web") + self.assertEqual(spec.cpu, 0.5) + self.assertEqual(spec.ram_mib, 1024) + self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8")) + self.assertEqual(spec.username, "admin") + self.assertEqual(spec.password, " stdin-secret ") + self.assertEqual(spec.ports, ( + PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"), PortRule(8080), + )) + self.assertEqual(json.loads(stdout)["ports"], [rule.to_dict() for rule in spec.ports]) + self.assertNotIn("stdin-secret", stdout + stderr) + self.assertNotIn("password", json.loads(stdout)) + self.assertIn("fractional", stderr) + self.lifecycle.run_many.assert_not_called() + + def test_create_defaults_to_ssh_but_can_create_an_explicit_empty_policy(self) -> None: + for options, expected in ( + ([], (PortRule(22),)), + (["--ports", "22,80"], (PortRule(22), PortRule(80))), + (["--no-default-ports"], ()), + (["--no-default-ports", "--ports", "443:https"], (PortRule(443, "HTTPS"),)), + ): + with self.subTest(options=options): + code, stdout, stderr = self.invoke(["create", *options, "--json"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected) + self.assertNotIn("WARNING", stdout) + self.assertIn("default password", stderr) + self.assertEqual(json.loads(stdout)["policy_status"], "enforced") + + def test_create_does_not_claim_enforcement_for_a_legacy_returned_record(self) -> None: + self.lifecycle.create.side_effect = None + self.lifecycle.create.return_value = replace(self.vm, ports=None) + code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(json.loads(stdout)["policy_status"], "unmanaged") + self.assertIsNone(json.loads(stdout)["ports"]) + self.assertNotIn("stored-secret", stdout + stderr) + + def test_invalid_create_inputs_do_not_call_the_service(self) -> None: + for options in ( + ["--name", "../bad"], ["--cpu", "nan"], ["--ram", "12"], + ["--guest-ip", "invalid"], ["--username", "Invalid"], + ["--ports", "80,70000"], ["--ports", "80:http", "--ports", "53:udp"], + ["--ports", ""], + ): + with self.subTest(options=options): + code, stdout, _ = self.invoke(["create", *options, "--json"]) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.lifecycle.create.assert_not_called() + + def test_invalid_stdin_password_is_not_echoed(self) -> None: + for password in ("", "\n", "secret\tcontrol\n", "long-secret-" * 20): + with self.subTest(password=password): + code, stdout, stderr = self.invoke( + ["create", "--password-stdin", "--json"], stdin=io.StringIO(password) + ) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertNotIn("secret", stderr) + self.lifecycle.create.assert_not_called() + + def test_json_uses_a_whitelist_not_persisted_vm_serialization(self) -> None: + with patch.object(VmRecord, "to_dict", side_effect=AssertionError("unsafe serialization")): + for arguments in ( + ["create", "--password", "stored-secret", "--json"], + ["list", "--json"], ["inspect", "web", "--json"], + ["ports", "list", "--all", "--json"], + ["ports", "open", "web", "--ports", "443", "--json"], + ["delete", "--all", "--dry-run", "--json"], + ["ports", "set", "web", "--none", "--dry-run", "--json"], + ): + with self.subTest(arguments=arguments): + code, stdout, stderr = self.invoke(arguments) + self.assertEqual(code, 0, stderr) + json.loads(stdout) + self.assertNotIn("password", stdout) + self.assertNotIn("stored-secret", stdout + stderr) + + def test_application_and_subprocess_progress_use_stderr_for_json(self) -> None: + self.invoke(["list", "--json"]) + runner = CommandRunner(emit=self.build_call.kwargs["emit"]) + create_vm = self.lifecycle.create.side_effect + + def create(spec): + runner.run(["cli-output-probe"]) + return create_vm(spec) + + def subprocess_run(_command, **kwargs): + self.assertIs(kwargs["stdout"], sys.stderr) + print("child process progress", file=kwargs["stdout"]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + self.lifecycle.create.side_effect = create + with patch("uvm.system.subprocess.run", side_effect=subprocess_run) as run: + code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(json.loads(stdout)["id"], self.vm.id) + self.assertIn("+ cli-output-probe", stderr) + self.assertIn("child process progress", stderr) + self.assertNotIn("stored-secret", stdout + stderr) + run.assert_called_once() + + +class CliListingTests(CliTestCase): + def test_status_filters_use_observed_status_and_quiet_prints_only_ids(self) -> None: + self.lifecycle.list_vms.return_value = [ + ListedVm(vm=self.vm, observed_status="dead", policy_status="missing"), + ListedVm( + vm=replace(self.vm, id="vm-two", status="stopped"), + observed_status="stopped", policy_status="inactive", + ), + ListedVm( + vm=replace(self.vm, id="vm-three"), observed_status="running", policy_status="enforced", + ), + ] + code, stdout, stderr = self.invoke(["list", "--status", "dead", "--status", "stopped", "-q"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(stdout, "vm-test\nvm-two\n") + code, stdout, _ = self.invoke(["list", "--status", "dead", "--json"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(stdout)[0]["observed_status"], "dead") + self.assertEqual(json.loads(stdout)[0]["status"], "running") + self.assertEqual(json.loads(stdout)[0]["policy_status"], "missing") + + def test_empty_list_formats(self) -> None: + self.lifecycle.list_vms.return_value = [] + for options, expected in (([], "No VMs.\n"), (["-q"], ""), (["--json"], "[]\n")): + with self.subTest(options=options): + code, stdout, _ = self.invoke(["list", *options]) + self.assertEqual(code, 0) + self.assertEqual(stdout, expected) + + def test_inspect_delegates_identifier_and_exposes_observed_state(self) -> None: + self.lifecycle.inspect.return_value = ListedVm( + vm=self.vm, observed_status="dead", policy_status="drift", + ) + for options in ([], ["--json"]): + code, stdout, stderr = self.invoke(["inspect", "10.42.0.2", *options]) + self.assertEqual(code, 0, stderr) + self.assertIn("dead", stdout) + self.assertIn("drift", stdout) + self.assertNotIn("stored-secret", stdout) + self.lifecycle.inspect.assert_called_with("10.42.0.2") + + +class CliBulkTests(CliTestCase): + def test_named_lifecycle_actions_use_resolved_ids_and_configured_timeouts(self) -> None: + self.application.settings = replace(Settings(), terminate_timeout_s=7.5) + self.lifecycle.resolve_targets.return_value = [self.vm, replace(self.vm, id="vm-two")] + for action in ("start", "stop", "restart", "delete", "destroy"): + with self.subTest(action=action): + code, _, stderr = self.invoke([action, "web", "10.42.0.3", "-y"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.resolve_targets.assert_called_with(["web", "10.42.0.3"], all_vms=False) + self.lifecycle.run_many.assert_called_with( + "delete" if action == "destroy" else action, + ("vm-test", "vm-two"), timeout=None if action == "start" else 7.5, + ) + + def test_explicit_timeouts_are_validated_before_target_resolution(self) -> None: + for action in ("stop", "restart", "delete", "destroy"): + for timeout in ("-1", "nan", "inf", "bad"): + with self.subTest(action=action, timeout=timeout): + code, _, _ = self.invoke([action, "web", "--timeout", timeout, "-y"]) + self.assertEqual(code, 2) + self.lifecycle.resolve_targets.assert_not_called() + self.lifecycle.run_many.assert_not_called() + self.assertEqual(self.invoke(["stop", "web", "--timeout", "2.25"])[0], 0) + self.lifecycle.run_many.assert_called_once_with("stop", ("vm-test",), timeout=2.25) + + def test_every_delete_and_all_selector_requires_tty_or_yes(self) -> None: + for arguments in ( + ["delete", "web"], ["destroy", "web"], + ["start", "--all"], ["stop", "--all"], ["restart", "--all"], + ["delete", "--all"], ["ports", "sync", "--all"], + ): + with self.subTest(arguments=arguments): + code, stdout, stderr = self.invoke([*arguments, "--json"], stdin=io.StringIO("yes\n")) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("--yes", stderr) + self.lifecycle.run_many.assert_not_called() + self.assertEqual(self.invoke(["start", "--all", "-y"])[0], 0) + + def test_snapshot_is_taken_before_confirmation_and_never_expanded(self) -> None: + events = [] + selected = [self.vm] + + def resolve(identifiers, *, all_vms): + self.assertEqual(identifiers, []) + self.assertTrue(all_vms) + events.append("resolve") + return selected + + def approve(): + events.append("prompt") + selected.append(replace(self.vm, id="vm-new")) + self.vm.id = "vm-renamed-during-prompt" + return "yes\n" + + self.lifecycle.resolve_targets.side_effect = resolve + stdin = Mock(isatty=Mock(return_value=True), readline=Mock(side_effect=approve)) + code, stdout, stderr = self.invoke(["delete", "--all", "--json"], stdin=stdin) + self.assertEqual(code, 0, stderr) + self.assertEqual(events, ["resolve", "prompt"]) + self.lifecycle.resolve_targets.assert_called_once() + self.lifecycle.run_many.assert_called_once_with("delete", ("vm-test",), timeout=5.0) + self.assertIn("vm-test", stderr) + self.assertEqual([result["id"] for result in json.loads(stdout)], ["vm-test"]) + + def test_declining_confirmation_is_a_noop_and_keeps_stdout_json(self) -> None: + for answer in ("n\n", "\n", "", "not yes\n"): + with self.subTest(answer=answer): + code, stdout, stderr = self.invoke(["delete", "web", "--json"], stdin=TtyInput(answer)) + self.assertEqual(code, 0) + self.assertEqual(json.loads(stdout), []) + self.assertIn("Cancelled", stderr) + self.lifecycle.run_many.assert_not_called() + + def test_dry_run_neither_mutates_nor_prompts(self) -> None: + for action in ("start", "stop", "restart", "delete", "destroy"): + with self.subTest(action=action): + stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt"))) + code, stdout, stderr = self.invoke([action, "--all", "--dry-run", "--json"], stdin=stdin) + self.assertEqual(code, 0, stderr) + preview = json.loads(stdout) + self.assertTrue(preview["dry_run"]) + self.assertEqual(preview["action"], "delete" if action == "destroy" else action) + self.assertEqual([vm["id"] for vm in preview["targets"]], [self.vm.id]) + self.lifecycle.run_many.assert_not_called() + + def test_empty_target_set_is_success_without_confirmation_or_mutation(self) -> None: + self.lifecycle.resolve_targets.return_value = [] + for command in (["delete"], ["start"], ["ports", "sync"]): + with self.subTest(command=command): + code, stdout, stderr = self.invoke([*command, "--all", "--json"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(json.loads(stdout), []) + self.lifecycle.run_many.assert_not_called() + + def test_failed_target_resolution_aborts_the_entire_bulk_request(self) -> None: + self.lifecycle.resolve_targets.side_effect = UvmError("VM not found: missing") + code, stdout, stderr = self.invoke(["stop", "web", "missing", "--json"]) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("missing", stderr) + self.lifecycle.run_many.assert_not_called() + + def test_result_outcomes_determine_exit_code_and_preserve_each_result(self) -> None: + for outcomes, expected in ( + (("succeeded", "skipped"), 0), + (("succeeded", "failed"), 1), + (("failed", "interrupted", "skipped"), 130), + ): + with self.subTest(outcomes=outcomes): + self.lifecycle.run_many.side_effect = None + self.lifecycle.run_many.return_value = [ + OperationResult(id=f"vm-{index}", action="stop", outcome=outcome, + error="failure detail" if outcome == "failed" else None) + for index, outcome in enumerate(outcomes) + ] + code, stdout, _ = self.invoke(["stop", "web", "--json"]) + self.assertEqual(code, expected) + data = json.loads(stdout) + self.assertEqual([result["outcome"] for result in data], list(outcomes)) + self.assertTrue(all(set(result) == {"id", "action", "outcome", "error"} for result in data)) + + def test_keyboard_interrupt_returns_130_without_traceback(self) -> None: + self.lifecycle.run_many.side_effect = KeyboardInterrupt + code, stdout, stderr = self.invoke(["stop", "web"]) + self.assertEqual(code, 130) + self.assertEqual(stdout, "") + self.assertIn("interrupted", stderr) + + +class CliPortsTests(CliTestCase): + def test_list_distinguishes_unmanaged_empty_and_enforced_policies(self) -> None: + self.lifecycle.list_vms.return_value = [ + self.listed, + ListedVm( + vm=replace(self.vm, id="vm-legacy", ports=None), + observed_status="running", policy_status="unmanaged", + ), + ListedVm( + vm=replace(self.vm, id="vm-empty", ports=()), + observed_status="running", policy_status="enforced", + ), + ] + code, stdout, stderr = self.invoke(["ports", "list", "--all", "--json"]) + self.assertEqual(code, 0, stderr) + data = json.loads(stdout) + self.assertEqual(data[0]["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}]) + self.assertIsNone(data[1]["ports"]) + self.assertEqual(data[1]["policy_status"], "unmanaged") + self.assertEqual(data[2]["ports"], []) + code, stdout, _ = self.invoke(["ports", "list", "web"]) + self.assertEqual(code, 0) + self.assertIn("PRIVATE-IP", stdout) + self.assertIn("22:TCP,80:HTTP", stdout) + self.assertNotIn("22/TCP", stdout) + self.lifecycle.inspect.assert_called_with("web") + + def test_open_and_close_call_service_modes_using_resolved_id(self) -> None: + for mode, values, expected in ( + ("open", "443:https,8080:tcp", (PortRule(443, "HTTPS"), PortRule(8080))), + ("close", "80:tcp", (PortRule(80),)), + ): + with self.subTest(mode=mode): + code, _, stderr = self.invoke(["ports", mode, "web", "--ports", values, "--json"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode=mode) + + def test_mutation_json_reports_policy_from_the_successful_returned_state(self) -> None: + self.lifecycle.inspect.return_value = replace(self.listed, policy_status="drift") + for status, policy_status in (("running", "enforced"), ("stopped", "inactive")): + self.lifecycle.set_ports.return_value = replace(self.vm, status=status, operation=None) + for options in ( + ["open", "web", "--ports", "443:HTTPS"], + ["close", "web", "--ports", "80:TCP"], + ["set", "web", "--ports", "443:HTTPS", "--yes"], + ): + with self.subTest(status=status, options=options): + code, stdout, stderr = self.invoke(["ports", *options, "--json"]) + self.assertEqual(code, 0, stderr) + data = json.loads(stdout) + self.assertEqual(data["status"], status) + self.assertEqual(data["policy_status"], policy_status) + self.assertIsNone(data["operation"]) + + def test_invalid_port_in_later_argument_aborts_before_service_calls(self) -> None: + for mode in ("open", "close", "set"): + with self.subTest(mode=mode): + code, stdout, _ = self.invoke([ + "ports", mode, "web", "--ports", "80:http", "--ports", "53:udp", "--json", + ]) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.lifecycle.inspect.assert_not_called() + self.lifecycle.set_ports.assert_not_called() + + def test_set_close_all_and_removing_ssh_require_confirmation(self) -> None: + for options in ( + ["set", "web", "--ports", "22,80"], ["set", "web", "--none"], + ["close", "web", "--all"], ["close", "web", "--ports", "22"], + ): + with self.subTest(options=options): + code, stdout, stderr = self.invoke(["ports", *options, "--json"]) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("--yes", stderr) + self.lifecycle.set_ports.assert_not_called() + + def test_set_and_close_all_replace_policy_without_touching_other_vms(self) -> None: + for options, expected in ( + (["close", "web", "--all"], ()), + (["set", "web", "--none"], ()), + (["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)), + ): + with self.subTest(options=options): + code, _, stderr = self.invoke(["ports", *options, "--yes"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode="set") + self.lifecycle.resolve_targets.assert_not_called() + self.lifecycle.run_many.assert_not_called() + + def test_empty_managed_port_target_is_a_noop_without_confirmation(self) -> None: + for status, policy_status in (("running", "enforced"), ("stopped", "inactive")): + vm = replace(self.vm, ports=(), status=status, operation=None) + self.lifecycle.inspect.return_value = ListedVm(vm, status, policy_status) + for options in ( + ["close", "web", "--all"], ["close", "web", "--ports", "22:TCP"], + ["set", "web", "--none"], + ): + for output in ([], ["--json"]): + with self.subTest(status=status, options=options, output=output): + stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt"))) + code, stdout, stderr = self.invoke(["ports", *options, *output], stdin=stdin) + self.assertEqual(code, 0, stderr) + if output: + self.assertEqual(json.loads(stdout)["ports"], []) + self.assertEqual(json.loads(stdout)["policy_status"], policy_status) + else: + self.assertIn("no port changes", stdout) + self.assertEqual(stderr, "") + self.lifecycle.set_ports.assert_not_called() + self.lifecycle.run_many.assert_not_called() + + def test_empty_policy_requires_service_when_unverified_or_pending(self) -> None: + for status, observed, policy, operation in ( + ("running", "running", "missing", None), + ("running", "running", "drift", None), + ("running", "running", "failed", None), + ("running", "running", "pending", None), + ("running", "running", "unknown", None), + ("running", "running", "unmanaged", None), + ("running", "running", "enforced", "ports-sync"), + ("stopped", "stopped", "inactive", "ports-sync"), + ("failed", "failed", "enforced", None), + ("running", "dead", "enforced", None), + ("stopped", "running", "inactive", None), + ("stopped", "stopped", "missing", None), + ): + vm = replace(self.vm, ports=(), status=status, operation=operation) + self.lifecycle.inspect.return_value = ListedVm(vm, observed, policy) + updated_status = "stopped" if status == observed == "stopped" else "running" + self.lifecycle.set_ports.return_value = replace(vm, status=updated_status, operation=None) + for options, requested, mode in ( + (["close", "web", "--all"], (), "set"), + (["close", "web", "--ports", "22:TCP"], (PortRule(22),), "close"), + (["set", "web", "--none"], (), "set"), + ): + with self.subTest(state=(status, observed, policy, operation), options=options): + self.lifecycle.set_ports.reset_mock() + code, stdout, stderr = self.invoke(["ports", *options, "--yes", "--json"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.set_ports.assert_called_once_with(vm.id, requested, mode=mode) + self.assertEqual(json.loads(stdout)["policy_status"], + "inactive" if updated_status == "stopped" else "enforced") + self.assertIsNone(json.loads(stdout)["operation"]) + + def test_empty_policy_reconciliation_failure_is_not_reported_as_a_noop(self) -> None: + vm = replace(self.vm, ports=(), status="failed", operation="ports-sync") + self.lifecycle.inspect.return_value = ListedVm(vm, "failed", "drift") + self.lifecycle.set_ports.side_effect = UvmError("policy reconciliation failed") + code, stdout, stderr = self.invoke(["ports", "close", "web", "--all", "--yes", "--json"]) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("policy reconciliation failed", stderr) + self.lifecycle.set_ports.assert_called_once_with(vm.id, (), mode="set") + + def test_confirmation_uses_stable_id_and_decline_does_not_mutate(self) -> None: + code, stdout, stderr = self.invoke( + ["ports", "close", "web", "--ports", "22", "--json"], stdin=TtyInput("n\n") + ) + self.assertEqual(code, 0) + self.assertIn(self.vm.id, stderr) + self.assertEqual(json.loads(stdout), []) + self.lifecycle.set_ports.assert_not_called() + code, _, _ = self.invoke( + ["ports", "close", "web", "--ports", "22"], stdin=TtyInput("YES\n") + ) + self.assertEqual(code, 0) + self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="close") + + def test_port_dry_runs_render_effective_changes_without_mutation(self) -> None: + for options, expected in ( + (["open", "web", "--ports", "443:https"], + (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))), + (["close", "web", "--ports", "80:tcp"], (PortRule(22),)), + (["close", "web", "--ports", "22"], (PortRule(80, "HTTP"),)), + (["close", "web", "--all"], ()), + (["set", "web", "--none"], ()), + (["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)), + ): + with self.subTest(options=options): + code, stdout, stderr = self.invoke(["ports", *options, "--dry-run", "--json"]) + self.assertEqual(code, 0, stderr) + data = json.loads(stdout) + self.assertTrue(data["dry_run"]) + self.assertEqual(data["changes"]["ports"], { + "before": [rule.to_dict() for rule in self.vm.ports], + "after": [rule.to_dict() for rule in expected], + }) + self.lifecycle.set_ports.assert_not_called() + + def test_open_preview_preserves_labels_of_existing_ports(self) -> None: + code, stdout, stderr = self.invoke([ + "ports", "open", "web", "--ports", "22:HTTPS,80:TCP", "--ports", "443:HTTPS", + "--dry-run", "--json", + ]) + self.assertEqual(code, 0, stderr) + self.assertEqual(json.loads(stdout)["changes"]["ports"], { + "before": [PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict()], + "after": [ + PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict(), PortRule(443, "HTTPS").to_dict(), + ], + }) + self.assertEqual(self.vm.ports, (PortRule(22), PortRule(80, "HTTP"))) + self.lifecycle.set_ports.assert_not_called() + + def test_unmanaged_open_and_close_are_not_silently_treated_as_empty(self) -> None: + self.vm.ports = None + for options in ( + ["open", "web", "--ports", "80"], ["close", "web", "--ports", "80"], + ["close", "web", "--all", "--yes"], + ): + for preview in ([], ["--dry-run"]): + with self.subTest(options=options, preview=preview): + code, _, stderr = self.invoke(["ports", *options, *preview]) + self.assertEqual(code, 1) + self.assertIn("unmanaged", stderr) + self.lifecycle.set_ports.assert_not_called() + + def test_legacy_migration_stopped_requirement_is_owned_by_service(self) -> None: + self.vm.ports = None + self.lifecycle.set_ports.side_effect = UvmError("stop the VM before adopting a port policy") + code, _, stderr = self.invoke(["ports", "set", "web", "--ports", "22", "--yes"]) + self.assertEqual(code, 1) + self.assertIn("stop", stderr) + self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="set") + + def test_setting_an_empty_legacy_policy_is_not_a_noop(self) -> None: + self.vm.ports = None + code, _, stderr = self.invoke(["ports", "set", "web", "--none"]) + self.assertEqual(code, 1) + self.assertIn("--yes", stderr) + self.lifecycle.set_ports.assert_not_called() + self.lifecycle.set_ports.return_value = replace(self.vm, ports=(), status="stopped") + code, _, stderr = self.invoke(["ports", "set", "web", "--none", "--yes"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (), mode="set") + + def test_sync_uses_bulk_action_and_supports_preview(self) -> None: + code, _, stderr = self.invoke(["ports", "sync", "web", "--json"]) + self.assertEqual(code, 0, stderr) + self.lifecycle.resolve_targets.assert_called_with(["web"], all_vms=False) + self.lifecycle.run_many.assert_called_once_with("ports-sync", (self.vm.id,), timeout=None) + self.lifecycle.run_many.reset_mock() + code, stdout, stderr = self.invoke(["ports", "sync", "--all", "--dry-run", "--json"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(json.loads(stdout)["action"], "ports-sync") + self.lifecycle.run_many.assert_not_called() + self.lifecycle.sync_ports.assert_not_called() + + +class CliSshCommandTests(CliTestCase): + def test_options_after_vm_are_not_swallowed_by_remote_command(self) -> None: + with patch("uvm.cli.os.execvp") as execute: + code, _, stderr = self.invoke([ + "ssh", "web", "--key", "/tmp/key with spaces", "--port", "2222", "--user", "admin", + "--", "printf", "%s", "--user", "--port", "literal argument", + ]) + self.assertEqual(code, 0, stderr) + self.lifecycle.find_for_ssh.assert_called_once_with("web") + executable, arguments = execute.call_args.args + self.assertEqual(executable, "ssh") + self.assertEqual(arguments[:5], ["ssh", "-p", "2222", "-i", "/tmp/key with spaces"]) + self.assertIn("HostKeyAlias=uvm-vm-test", arguments) + self.assertEqual(arguments[-6:], [ + "admin@10.42.0.2", "printf", "%s", "--user", "--port", "literal argument", + ]) + + def test_options_before_vm_and_remote_separator_are_supported(self) -> None: + with patch("uvm.cli.os.execvp") as execute: + code, _, stderr = self.invoke([ + "ssh", "--port", "2222", "--insecure-host-key", "web", "--", "uname", "-a", + ]) + self.assertEqual(code, 0, stderr) + arguments = execute.call_args.args[1] + self.assertIn("StrictHostKeyChecking=no", arguments) + self.assertNotIn("HostKeyAlias=uvm-vm-test", arguments) + self.assertEqual(arguments[-3:], ["admin@10.42.0.2", "uname", "-a"]) + + def test_remote_commands_require_literal_separator_and_unknown_options_fail(self) -> None: + with patch("uvm.cli.os.execvp") as execute: + for arguments in ( + ["ssh", "web", "uname", "-a"], ["ssh", "web", "--unknown"], + ["ssh", "web", "--bad-option", "--", "uname"], + ["ssh", "web", "--user", "-bad"], + ): + with self.subTest(arguments=arguments): + self.assertEqual(self.invoke(arguments)[0], 2) + execute.assert_not_called() + + def test_ssh_help_and_option_values_after_separator_belong_to_remote_command(self) -> None: + for remote in (["--help"], ["--port", "not-a-port", "--user", "Not-A-Local-User"], ["--", "-x"]): + with self.subTest(remote=remote), patch("uvm.cli.os.execvp") as execute: + code, _, stderr = self.invoke(["ssh", "web", "--port", "2222", "--", *remote]) + self.assertEqual(code, 0, stderr) + arguments = execute.call_args.args[1] + self.assertEqual(arguments[:3], ["ssh", "-p", "2222"]) + self.assertEqual(arguments[-len(remote):], remote) + with patch("uvm.cli.os.execvp") as execute: + self.assertEqual(self.invoke(["ssh", "web", "--help"])[0], 0) + execute.assert_not_called() + + def test_missing_ssh_executable_is_reported_without_traceback(self) -> None: + with patch("uvm.cli.os.execvp", side_effect=FileNotFoundError("ssh not found")): + code, stdout, stderr = self.invoke(["ssh", "web"]) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("ssh not found", stderr) + + +class CliLogTests(CliTestCase): + def test_tail_handles_empty_partial_and_fewer_lines(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "safe.log" + self.lifecycle.log_path.return_value = path + for content, count, expected in ( + (b"", 100, ""), (b"one\ntwo\nthree\n", 2, "two\nthree\n"), + (b"one\ntwo\nthree", 2, "two\nthree"), (b"one\n", 100, "one\n"), + (b"one\ntwo\n", 0, ""), (b"\n\n", 1, "\n"), + (b"bad\xff\n", 1, "bad\ufffd\n"), + ): + with self.subTest(content=content, count=count): + path.write_bytes(content) + code, stdout, stderr = self.invoke(["logs", "web", "--tail", str(count)]) + self.assertEqual(code, 0, stderr) + self.assertEqual(stdout, expected) + self.lifecycle.log_path.assert_called_with("web") + + def test_default_tail_is_100_and_reads_are_bounded_near_eof(self) -> None: + content = b"not in tail\n" * 200000 + b"last line\n" * 100 + stream = io.BytesIO(content) + bounded = Mock(wraps=stream) + path = Mock() + path.open.return_value.__enter__ = Mock(return_value=bounded) + path.open.return_value.__exit__ = Mock(return_value=False) + self.lifecycle.log_path.return_value = path + code, stdout, stderr = self.invoke(["logs", "web"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(stdout, "last line\n" * 100) + reads = [call.args[0] for call in bounded.read.call_args_list] + self.assertTrue(all(0 < size <= 65536 for size in reads)) + self.assertLess(sum(reads), 131072) + path.open.assert_called_once_with("rb") + + def test_multibyte_and_very_long_lines_are_streamed_correctly(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "safe.log" + content = "skip\n" + "x" * 65535 + "\u00e9" + "z" * 70000 + "\n" + path.write_text(content, encoding="utf-8") + self.lifecycle.log_path.return_value = path + code, stdout, stderr = self.invoke(["logs", "web", "--tail", "1"]) + self.assertEqual(code, 0, stderr) + self.assertEqual(stdout, content.removeprefix("skip\n")) + + def test_follow_uses_safe_path_without_holding_an_operation_lock(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "safe.log" + path.write_text("old\n", encoding="utf-8") + self.lifecycle.log_path.return_value = path + events = [] + + def append_then_interrupt(_seconds): + if events: + raise KeyboardInterrupt + with path.open("a", encoding="utf-8") as stream: + stream.write("new\n") + events.append("appended") + + with patch("uvm.cli.time.sleep", side_effect=append_then_interrupt): + code, stdout, stderr = self.invoke(["logs", "web", "--tail", "0", "-f"]) + self.assertEqual(code, 130) + self.assertEqual(stdout, "new\n") + self.assertIn("interrupted", stderr) + self.lifecycle.log_path.assert_called_once_with("web") + self.application.state_store.operation_lock.assert_not_called() + + def test_missing_or_unsafe_log_path_fails_without_opening_record_path(self) -> None: + self.lifecycle.log_path.side_effect = UvmError("unsafe VM log path") + with patch("uvm.cli.Path.open") as open_path: + code, stdout, stderr = self.invoke(["logs", "web"]) + self.assertEqual(code, 1) + self.assertEqual(stdout, "") + self.assertIn("unsafe", stderr) + open_path.assert_not_called() + self.lifecycle.log_path.side_effect = None + with tempfile.TemporaryDirectory() as directory: + self.lifecycle.log_path.return_value = Path(directory) / "missing.log" + self.assertEqual(self.invoke(["logs", "web"])[0], 1) + + def test_invalid_tail_is_an_argument_error(self) -> None: + for value in ("-1", "1.5", "all"): + with self.subTest(value=value): + self.assertEqual(self.invoke(["logs", "web", "--tail", value])[0], 2) + self.lifecycle.log_path.assert_not_called() + + +class CliDoctorTests(CliTestCase): + def test_doctor_reports_all_checks_and_fails_if_any_check_fails(self) -> None: + for healthy in (True, False): + with self.subTest(healthy=healthy): + self.lifecycle.diagnostics.return_value = [ + {"name": "kvm", "ok": True, "message": "available", "password": "not-output"}, + {"name": "firewall", "ok": healthy, "message": "checked"}, + ] + for options in ([], ["--json"]): + code, stdout, stderr = self.invoke(["doctor", *options]) + self.assertEqual(code, 0 if healthy else 1, stderr) + self.assertIn("firewall", stdout) + self.assertNotIn("password", stdout) + self.assertNotIn("not-output", stdout) + if options: + self.assertEqual(len(json.loads(stdout)), 2) diff --git a/tests/test_firewall.py b/tests/test_firewall.py new file mode 100644 index 0000000..723c306 --- /dev/null +++ b/tests/test_firewall.py @@ -0,0 +1,926 @@ +"""Unprivileged command, drift and rule-semantics tests, not packet verification.""" + +from __future__ import annotations + +import copy +import json +import os +import re +import shutil +import subprocess +import unittest +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +from uvm.config import Settings +from uvm.domain import PortRule, VmRecord +from uvm.errors import CommandError, UvmError, ValidationError +from uvm.firewall import FirewallManager, _ct, _normalize +from uvm.system import CommandResult +from uvm.validation import parse_ports + + +def vm_record(**changes): + fields = dict(id="vm-one", tap="tap-one", guest_ip="10.42.0.2", + mac="02:fc:00:00:00:01", ports=(PortRule(8080),)) + fields.update(changes) + return SimpleNamespace(**fields) + + +class FakeRunner: + """Model atomic nft transactions and readback without executing commands.""" + + def __init__(self): + self.calls = [] + self.tables = {} + self.links = [] + self.handle = 1 + self.release = "6.8.0-test" + self.fail_command = None + self.fail_batch = False + self.before_batch = None + self.after_batch = None + self.listing = None + self.ct_delete_code = 0 + self.ct_remaining = "" + + def run(self, command, *, check=True, capture=False, input_text=None, timeout=None): + args = tuple(command) + self.calls.append((args, dict(check=check, capture=capture, + input_text=input_text, timeout=timeout))) + if args == self.fail_command: + raise CommandError("simulated permission failure or timeout") + stdout, code = "", 0 + if args == ("uname", "-r"): + stdout = self.release + elif args == ("ip", "-j", "-d", "link", "show"): + stdout = json.dumps(self.links) + elif args[:2] == ("nft", "--json") and "list" in args: + if self.listing is not None: + stdout = self.listing + else: + if "tables" in args: + objects = [items[0] for items in self.tables.values()] + else: + if args[-1] not in self.tables: + raise CommandError("table disappeared") + objects = self.tables[args[-1]] + stdout = json.dumps({"nftables": [{"metainfo": {"version": "1.0.9"}}, *objects]}) + elif args[:2] == ("nft", "--json") and args[-2:] == ("-f", "-"): + commands = json.loads(input_text)["nftables"] + if self.before_batch: + self.before_batch() + self.before_batch = None + if self.fail_batch: + raise CommandError("atomic nft transaction rejected") + candidate = copy.deepcopy(self.tables) + for command in commands: + operation, item = next(iter(command.items())) + kind, obj = next(iter(item.items())) + # Match nft 1.0.9 json_parse_set_expr(), not an echo-only fake. + if kind == "set" and obj.get("elem") == []: + raise CommandError("Invalid set elem expression") + if operation == "delete": + name = next((name for name, items in candidate.items() + if items[0]["table"].get("handle") == obj["handle"]), None) + if name is None: + raise CommandError("table handle disappeared") + del candidate[name] + else: + obj["handle"] = self.handle + self.handle += 1 + if kind == "table": + if obj["name"] in candidate: + raise CommandError("exclusive create found an existing table") + candidate[obj["name"]] = [item] + else: + candidate[obj["table"]].append(item) + if "--check" not in args: + self.tables = candidate + if self.after_batch: + self.after_batch() + elif args == ("conntrack", "--version"): + stdout = "conntrack v1.4.8 (conntrack-tools)" + elif args[:2] == ("conntrack", "--delete"): + code = self.ct_delete_code + elif args[:2] == ("conntrack", "--list"): + stdout = self.ct_remaining + else: + raise AssertionError(f"unexpected command: {args}") + if check and code: + raise CommandError("simulated command failure") + return CommandResult(args=args, returncode=code, stdout=stdout) + + @property + def batches(self): + return [json.loads(options["input_text"])["nftables"] for args, options in self.calls + if args[0] == "nft" and options["input_text"] is not None and "--check" not in args] + + +class FirewallManagerTests(unittest.TestCase): + def setUp(self): + self.runner = FakeRunner() + self.manager = FirewallManager(Settings(), self.runner) + self.vm = vm_record() + + def policy(self): + return next(iter(self.runner.tables.values())) + + def test_apply_is_atomic_bounded_and_contains_no_nat_or_global_mutations(self): + self.manager.apply(self.vm) + self.assertEqual(self.manager.status(self.vm), "enforced") + self.assertEqual(len(self.runner.batches), 1) + self.assertIn("create", self.runner.batches[0][0]) + for args, options in self.runner.calls: + if args[0] != "nft": + self.assertEqual(args, ("ip", "-j", "-d", "link", "show")) + self.assertTrue(options["capture"]) + self.assertGreater(options["timeout"], 0) + self.assertLessEqual(options["timeout"], 10) + rendered = json.dumps(self.runner.batches) + for forbidden in ("flush", "dnat", "snat", "masquerade", "redirect", "notrack"): + self.assertNotIn(f'"{forbidden}"', rendered) + for item in self.policy(): + self.assertEqual(next(iter(item.values()))["family"], "bridge") + + def test_update_and_remove_do_not_rebuild_or_delete_siblings(self): + self.manager.apply(self.vm) + sibling = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3") + self.manager.apply(sibling) + name = next(reversed(self.runner.tables)) + saved = copy.deepcopy(self.runner.tables[name]) + old_handle = self.policy()[0]["table"]["handle"] + self.vm.ports = () + self.manager.apply(self.vm) + self.assertEqual(self.runner.batches[-1][0], { + "delete": {"table": {"family": "bridge", "handle": old_handle}}, + }) + self.assertEqual(self.runner.tables[name], saved) + self.assertEqual(self.manager.status(self.vm), "enforced") + self.manager.remove(self.vm) + self.manager.remove(self.vm) + self.assertEqual(self.runner.tables, {name: saved}) + self.assertEqual(self.manager.status(self.vm), "missing") + + def test_table_identity_is_stable_but_scoped_to_base_and_vm_id(self): + self.manager.apply(self.vm) + name = next(iter(self.runner.tables)) + self.vm.ports = () + self.manager.apply(self.vm) + self.assertEqual(list(self.runner.tables), [name]) + other = FirewallManager(replace(Settings(), base=Path("/var/lib/another-uvm")), self.runner) + other.apply(self.vm) + self.assertEqual(len(self.runner.tables), 2) + self.assertRegex(name, r"^uvm_fw_[0-9a-f]{24}$") + self.assertLess(len(self.policy()[0]["table"]["comment"]), 128) + + def test_legacy_is_unmanaged_and_normal_apply_refuses_without_commands(self): + self.vm.ports = None + with self.assertRaises(ValidationError): + self.manager.apply(self.vm) + self.assertEqual(self.runner.calls, []) + self.assertEqual(self.manager.status(self.vm), "unmanaged") + self.vm.guest_ip, self.vm.mac = "damaged", "damaged" + self.manager.apply(self.vm, quarantine=True) + self.assertEqual(self.manager.status(self.vm), "quarantined") + + def test_none_does_not_hide_an_existing_managed_table(self): + self.manager.apply(self.vm) + self.vm.ports = None + self.assertEqual(self.manager.status(self.vm), "drift") + + def test_protocol_aliases_deduplicate_tcp_ports_and_allow_empty_policy(self): + self.vm.ports = tuple(PortRule(port, protocol) for port, protocol in ( + (443, "HTTPS"), (8080, "HTTP"), (443, "TCP"), (22, "TCP"), + )) + self.manager.apply(self.vm) + self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item), + [22, 443, 8080]) + self.vm.ports = () + self.manager.apply(self.vm) + self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item)) + + def test_real_port_models_and_cli_syntax_reach_the_same_tcp_set(self): + ports = parse_ports(("1:tcp,80:http", "443:HTTPS,65535")) + vm = VmRecord(id="vm-real", cpu=1, ram_mib=128, guest_ip="10.42.0.2", + gateway="10.42.0.1", tap="tap-real", mac=self.vm.mac, + socket="", config="", log="", ports=ports, + network="10.42.0.0/24", bridge="uvm0") + self.manager.apply(vm) + self.assertEqual(self.manager.status(vm), "enforced") + self.assertEqual(next(item["set"]["elem"] for item in self.policy() if "set" in item), + [1, 80, 443, 65535]) + for text in ("0", "65536", "-1", "1.5", "80:UDP", "80:HTTP:TCP", "TCP:80", "80:", ":TCP"): + with self.subTest(text=text), self.assertRaises(ValidationError): + parse_ports((text,)) + + def test_invalid_ports_are_rejected_before_commands(self): + for port, protocol in ((True, "TCP"), (0, "TCP"), (-1, "TCP"), (65536, "TCP"), + (80.0, "TCP"), ("80", "TCP"), (80, "tcp"), (53, "UDP"), + (80, "HTTP:TCP"), (80, None), (80, "HTTP; flush ruleset")): + with self.subTest(port=port, protocol=protocol): + self.vm.ports = (SimpleNamespace(port=port, protocol=protocol),) + with self.assertRaises(ValidationError): + self.manager.apply(self.vm) + self.assertEqual(self.runner.calls, []) + + def test_invalid_identity_is_rejected_before_commands(self): + for field, value in (("tap", "tap*"), ("tap", 'x"; drop'), ("tap", "-bad"), + ("tap", "a" * 16), ("tap", "uvm0"), ("id", ""), + ("mac", "ff:ff:ff:ff:ff:ff"), ("mac", "00:00:00:00:00:00"), + ("guest_ip", "10.42.0.1"), ("guest_ip", "10.42.0.255"), + ("guest_ip", "10.43.0.2"), ("guest_ip", "127.0.0.1"), + ("guest_ip", "8.8.8.8"), ("guest_ip", "::1")): + with self.subTest(field=field, value=value), self.assertRaises(ValidationError): + self.manager.apply(vm_record(**{field: value})) + self.assertEqual(self.runner.calls, []) + + def test_persisted_network_can_differ_from_current_settings(self): + vm = vm_record(guest_ip="172.20.1.2", network="172.20.0.0/16", bridge="old-bridge") + self.manager.apply(vm) + self.assertEqual(self.manager.status(vm), "enforced") + + def test_live_tap_must_route_lldp_through_early_filter_and_disable_stp(self): + port = {"info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": "0x4000"}} + bridge = {"info_kind": "bridge", "info_data": {"stp_state": 0}} + self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": port}, + {"ifname": "uvm0", "ifindex": 7, "linkinfo": bridge}] + self.manager.apply(self.vm) + self.assertEqual(self.manager.status(self.vm), "enforced") + for stp, mask in ((1, "0x4000"), (0, 0), (0, "unreadable")): + bridge["info_data"]["stp_state"] = stp + port["info_slave_data"]["group_fwd_mask"] = mask + self.assertEqual(self.manager.status(self.vm), "drift") + for quarantine in (False, True): + with self.assertRaisesRegex(UvmError, "LLDP forwarding"): + self.manager.apply(self.vm, quarantine=quarantine) + bridge["info_data"]["stp_state"] = 0 + port["info_slave_data"]["group_fwd_mask"] = 0x4008 + self.runner.links[0]["master"] = 7 # iproute may use an index instead of a name. + self.manager.apply(self.vm, quarantine=True) + self.assertEqual(self.manager.status(self.vm), "quarantined") + + def test_foreign_ownership_is_never_adopted_even_for_quarantine_or_remove(self): + self.manager.apply(self.vm) + self.policy()[0]["table"]["comment"] = "someone else's firewall" + saved = copy.deepcopy(self.runner.tables) + self.assertEqual(self.manager.status(self.vm), "drift") + for operation in (lambda: self.manager.apply(self.vm), + lambda: self.manager.apply(self.vm, quarantine=True), + lambda: self.manager.remove(self.vm)): + with self.assertRaisesRegex(UvmError, "foreign"): + operation() + self.assertEqual(self.runner.tables, saved) + self.assertEqual(len(self.runner.batches), 1) + + def test_table_replacement_race_uses_exclusive_create_and_old_handle(self): + self.manager.apply(self.vm) + + def replace_table(): + self.policy()[0]["table"].update(handle=90000, comment="foreign replacement") + + self.runner.before_batch = replace_table + with self.assertRaises(CommandError): + self.manager.apply(self.vm, quarantine=True) + self.assertEqual(self.policy()[0]["table"]["comment"], "foreign replacement") + + def test_initial_create_race_does_not_append_to_a_foreign_table(self): + table = self.manager._policy(self.vm, quarantine=True)[0]["table"] + + def create_foreign(): + self.runner.tables[table["name"]] = [{"table": {**table, "handle": 1000, "comment": "foreign"}}] + + self.runner.before_batch = create_foreign + with self.assertRaises(CommandError): + self.manager.apply(self.vm) + self.assertEqual(len(self.policy()), 1) + + def test_atomic_failure_keeps_old_policy_and_readback_failure_is_not_success(self): + self.manager.apply(self.vm) + saved = copy.deepcopy(self.runner.tables) + self.runner.fail_batch = True + self.vm.ports = () + with self.assertRaises(CommandError): + self.manager.apply(self.vm) + self.assertEqual(self.runner.tables, saved) + self.runner.fail_batch = False + self.runner.after_batch = lambda: self.policy().pop() + with self.assertRaisesRegex(UvmError, "readback"): + self.manager.apply(self.vm) + + def test_status_does_not_trust_owner_comment_rule_count_or_saved_flags(self): + self.manager.apply(self.vm) + original = copy.deepcopy(self.runner.tables) + self.vm.firewall_applied = True + mutations = ( + lambda items: items.pop(), + lambda items: next(item["set"] for item in items if "set" in item)["elem"].append(22), + lambda items: next(item["chain"] for item in items if "chain" in item).update(hook="postrouting"), + lambda items: next(item["chain"] for item in items if "chain" in item).update(prio=1), + lambda items: next(item["chain"] for item in items if "chain" in item).update(policy="drop"), + lambda items: items[0]["table"].update(flags="dormant"), + lambda items: next(item["rule"] for item in items if "rule" in item)["expr"].pop(0), + lambda items: next(item["rule"] for item in items if "rule" in item).update(expr=[{"accept": None}]), + lambda items: items.append({"flowtable": {"family": "bridge", "name": "bypass"}}), + ) + for mutate in mutations: + with self.subTest(mutation=mutate): + self.runner.tables = copy.deepcopy(original) + mutate(self.policy()) + self.assertEqual(self.manager.status(self.vm), "drift") + self.runner.tables = original + for item in self.policy(): + if "rule" in item and item["rule"]["chain"] == "from_vm": + item["rule"]["expr"] = [{"return": None}] + break + self.assertEqual(self.manager.status(self.vm), "drift") + + def test_rule_order_is_not_normalized_away(self): + self.manager.apply(self.vm) + positions = [index for index, item in enumerate(self.policy()) + if "rule" in item and item["rule"]["chain"] == "from_vm"] + first, last = positions[0], positions[-1] + self.policy()[first], self.policy()[last] = self.policy()[last], self.policy()[first] + self.assertEqual(self.manager.status(self.vm), "drift") + + def test_normalizes_nft_json_presentation_not_effective_policy(self): + self.manager.apply(self.vm) + for item in self.policy(): + obj = next(iter(item.values())) + obj["handle"] += 100 + if "set" in item: + obj["elem"].reverse() + obj["flags"] = "constant" + if "rule" not in item: + continue + exprs = obj["expr"] + protocols = {expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol") + for expr in exprs} + # nft elides EtherType dependencies of ARP/IP payloads, and + # coalesces/sorts adjacent pure payload matches when listing. + has_tcp_payload = '"protocol": "tcp"' in json.dumps(exprs) + if protocols & {"arp", "ip"} and not has_tcp_payload: + exprs[:] = [expr for expr in exprs if expr.get("match", {}).get("left") != { + "payload": {"protocol": "ether", "field": "type"}, + }] + for expr in exprs: + match = expr.get("match", {}) + if match.get("right") == "@tcp_ports": + match["op"] = "in" + if match.get("left") == {"ct": {"key": "state"}} and isinstance(match["right"], list): + match["right"].reverse() + if "&" in match.get("left", {}): + match["left"]["&"][1] = {"|": [{"|": ["fin", "syn"]}, {"|": ["rst", "ack"]}]} + match["right"] = "syn" + exprs[:-1] = reversed(exprs[:-1]) + self.assertEqual(self.manager.status(self.vm), "enforced") + + def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self): + self.vm.ports = () + self.manager.apply(self.vm) + self.assertNotIn("elem", next(item["set"] for item in self.policy() if "set" in item)) + self.assertEqual(self.manager.status(self.vm), "enforced") + next(item["set"] for item in self.policy() if "set" in item)["elem"] = [] + self.assertEqual(self.manager.status(self.vm), "enforced") + for item in self.policy(): + if "rule" in item: + item["rule"]["expr"] = [expr for expr in item["rule"]["expr"] + if expr.get("match", {}).get("left") != {"ct": {"key": "direction"}}] + self.assertEqual(self.manager.status(self.vm), "drift") + + def test_missing_tools_permissions_timeouts_and_bad_json_are_unknown_not_missing(self): + self.runner.fail_command = ("nft", "--json", "list", "tables", "bridge") + self.assertEqual(self.manager.status(self.vm), "unknown") + with self.assertRaises(CommandError): + self.manager.apply(self.vm) + self.runner.fail_command = None + for listing in ("", "not json", "{}", '{"nftables": null}', + '{"nftables": [null]}', '{"nftables": [{"table": {}}]}'): + with self.subTest(listing=listing): + self.runner.listing = listing + self.assertEqual(self.manager.status(self.vm), "unknown") + with self.assertRaises(UvmError): + self.manager.apply(self.vm) + self.assertEqual(self.runner.batches, []) + + def test_remove_and_cleanup_refuse_a_live_tap_even_when_quarantined(self): + self.manager.apply(self.vm, quarantine=True) + self.runner.links = [{"ifname": self.vm.tap, "master": "uvm0", "linkinfo": { + "info_slave_kind": "bridge", "info_slave_data": {"group_fwd_mask": 0x4000}, + }}, {"ifname": "uvm0", "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}}}] + for operation in (self.manager.remove, self.manager.cleanup_connections): + with self.assertRaisesRegex(UvmError, "still exists"): + operation(self.vm) + self.assertEqual(self.manager.status(self.vm), "quarantined") + self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls)) + + def test_offline_check_errors_do_not_release_policy(self): + self.manager.apply(self.vm, quarantine=True) + self.runner.fail_command = ("ip", "-j", "-d", "link", "show") + with self.assertRaises(CommandError): + self.manager.remove(self.vm) + self.runner.fail_command = None + self.runner.links = [{}] + with self.assertRaises(UvmError): + self.manager.cleanup_connections(self.vm) + self.assertEqual(self.manager.status(self.vm), "unknown") + + def test_offline_conntrack_cleanup_is_scoped_to_all_four_tuple_addresses(self): + self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too. + self.manager.cleanup_connections(self.vm) + for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): + for command in ("--delete", "--list"): + self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip), + [args for args, _ in self.runner.calls]) + self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls)) + self.runner.ct_remaining = "tcp 6 100 ESTABLISHED ..." + with self.assertRaisesRegex(UvmError, "do not reuse"): + self.manager.cleanup_connections(self.vm) + self.runner.ct_remaining = "" + self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip) + with self.assertRaises(CommandError): + self.manager.cleanup_connections(self.vm) + + def test_availability_checks_kernel_and_full_policy_without_committing(self): + self.manager.check_available() + self.assertEqual(self.runner.tables, {}) + self.assertEqual(self.runner.calls[0][0], ("conntrack", "--version")) + self.assertTrue(self.runner.calls[0][1]["check"]) + self.assertEqual(self.runner.calls[0][1]["timeout"], 5) + batch = next((args, options) for args, options in self.runner.calls if "--check" in args) + self.assertIn('"ct"', batch[1]["input_text"]) + self.assertIn('"hook": "prerouting"', batch[1]["input_text"]) + for release in ("5.2.99", "4.19.0", "not-linux"): + self.runner.release = release + with self.assertRaisesRegex(UvmError, ">= 5.3"): + self.manager.check_available() + self.runner.release = "5.3.0" + self.runner.fail_batch = True + with self.assertRaisesRegex(UvmError, "unavailable"): + self.manager.check_available() + + def test_missing_cleanup_utility_fails_preflight_before_nft(self): + self.runner.fail_command = ("conntrack", "--version") + with self.assertRaises(CommandError): + self.manager.check_available() + self.assertEqual([args for args, _ in self.runner.calls], [("conntrack", "--version")]) + + def test_status_rejects_changed_ct_keys_masks_verdicts_and_quarantine_hooks(self): + self.manager.apply(self.vm) + original = copy.deepcopy(self.runner.tables) + for alteration in ("tuple-key", "ct-mask", "tcp-mask", "return", "extra-set-property"): + self.runner.tables = copy.deepcopy(original) + rules = [item["rule"] for item in self.policy() if "rule" in item] + expressions = [expr for rule in rules for expr in rule["expr"]] + if alteration == "tuple-key": + match = next(expr["match"] for expr in expressions + if expr.get("match", {}).get("left") == {"ct": {"key": "ip saddr", "dir": "original"}}) + match["left"] = {"ct": {"key": "saddr", "family": "ip", "dir": "original"}} + elif alteration == "ct-mask": + next(expr["match"] for expr in expressions + if expr.get("match", {}).get("left") == {"ct": {"key": "state"}})["right"] = "established" + elif alteration == "tcp-mask": + next(expr["match"] for expr in expressions + if "&" in expr.get("match", {}).get("left", {}))["left"]["&"][1] = 2 + elif alteration == "return": + verdict = next(expr for expr in expressions if "return" in expr) + verdict.clear() + verdict["accept"] = None + else: + next(item["set"] for item in self.policy() if "set" in item)["timeout"] = 30 + with self.subTest(alteration=alteration): + self.assertEqual(self.manager.status(self.vm), "drift") + self.runner.tables = original + self.manager.apply(self.vm, quarantine=True) + self.policy().pop() + self.assertEqual(self.manager.status(self.vm), "drift") + + @unittest.skipUnless(os.path.exists("/proc/self/ns/net"), "Linux network namespace handles required") + def test_integration_harness_refuses_to_run_in_the_parent_namespace(self): + from tests.integration.test_private_ports import _sandbox + + with open("/proc/self/ns/net", "rb") as parent: + with self.assertRaisesRegex(RuntimeError, "parent network namespace"): + _sandbox(parent.fileno()) + + +class NftDumpFixtureTests(unittest.TestCase): + """Literal upstream JSON fixtures, not output synthesized by FakeRunner. + + Sources below are from nftables tag v1.0.9: + https://git.netfilter.org/nftables/tree/tests?h=v1.0.9 + https://github.com/1g4-mirror/nftables/tree/v1.0.9/tests + tests/py/nft-test.py lists with numeric-protocol enabled. Files ending in + .json.output are its recorded expected nft dumps, not packet tests run here. + """ + + @staticmethod + def normalize_rule(text): + return _normalize([{"rule": {"family": "bridge", "table": "fixture", "chain": "input", + "expr": json.loads(text)}}]) + + def test_explicit_ct_address_keys_match_upstream_wire_format(self): + # tests/py/inet/ct.t.json, "ct original ip daddr 1.2.3.4 accept". + # There is no .json.output override for this example: dump == input. + dumped = json.loads('''[ + {"match": {"left": {"ct": {"dir": "original", "key": "ip daddr"}}, + "op": "==", "right": "1.2.3.4"}}, + {"accept": null} + ]''') + self.assertEqual(_ct("ip daddr", "original"), dumped[0]["match"]["left"]) + # ct_templates[NFT_CT_SRC_IP] / [NFT_CT_DST_IP] in src/ct.c; the + # v1.0.9 json_parse_ct_expr() looks up these keys and ignores family. + self.assertEqual(_ct("ip saddr", "original"), {"ct": {"key": "ip saddr", "dir": "original"}}) + policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False) + addresses = [expr["match"]["left"]["ct"] for item in policy if "rule" in item + for expr in item["rule"]["expr"] if "ct" in expr.get("match", {}).get("left", {}) + and "addr" in expr["match"]["left"]["ct"]["key"]] + self.assertEqual({ct["key"] for ct in addresses}, {"ip saddr", "ip daddr"}) + self.assertTrue(all(ct["dir"] == "original" and "family" not in ct for ct in addresses)) + + def test_upstream_ct_list_and_set_dumps_preserve_distinct_operators(self): + # tests/py/any/ct.t.json and ct.t.json.output, ct state list vs set. + pairs = ( + ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", + "right": ["new", "established", "related", "untracked"]}}]''', + '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", + "right": ["established", "related", "new", "untracked"]}}]'''), + ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", + "right": {"set": ["new", "established", "related", "untracked"]}}}]''', + '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "==", + "right": {"set": ["established", "related", "new", "untracked"]}}}]'''), + ('''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": 8}}]''', + '''[{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}}]'''), + ) + for source, dumped in pairs: + with self.subTest(source=source): + self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) + # Equality against the ORed mask is NOT the list's any-bit test. + self.assertNotEqual(self.normalize_rule(pairs[0][1]), + self.normalize_rule(pairs[0][1].replace('"op": "in"', '"op": "=="'))) + + def test_upstream_bridge_icmp_numeric_protocol_dump(self): + # tests/py/bridge/icmpX.t.json and icmpX.t.json.output. + source = '''[ + {"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}}, + "op": "==", "right": "icmp"}}, + {"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}}, + "op": "==", "right": "echo-request"}} + ]''' + dumped = '''[ + {"match": {"left": {"payload": {"field": "protocol", "protocol": "ip"}}, + "op": "==", "right": 1}}, + {"match": {"left": {"payload": {"field": "type", "protocol": "icmp"}}, + "op": "==", "right": "echo-request"}} + ]''' + self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) + self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace('"right": 1', '"right": 6'))) + + def test_upstream_arp_payload_field_names_and_reordering(self): + # tests/py/arp/arp.t.json and arp.t.json.output: + # arp daddr ether fe:ed:00:c0:ff:ee arp saddr ip 192.168.1.1 + source = '''[ + {"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}}, + "op": "==", "right": "fe:ed:00:c0:ff:ee"}}, + {"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}}, + "op": "==", "right": "192.168.1.1"}} + ]''' + dumped = '''[ + {"match": {"left": {"payload": {"field": "saddr ip", "protocol": "arp"}}, + "op": "==", "right": "192.168.1.1"}}, + {"match": {"left": {"payload": {"field": "daddr ether", "protocol": "arp"}}, + "op": "==", "right": "fe:ed:00:c0:ff:ee"}} + ]''' + self.assertEqual(self.normalize_rule(source), self.normalize_rule(dumped)) + self.assertNotEqual(self.normalize_rule(source), self.normalize_rule(dumped.replace("saddr ip", "daddr ip"))) + policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False) + fields = {expr["match"]["left"]["payload"]["field"] for item in policy if "rule" in item + for expr in item["rule"]["expr"] + if expr.get("match", {}).get("left", {}).get("payload", {}).get("protocol") == "arp"} + self.assertEqual(fields, {"htype", "ptype", "hlen", "plen", "operation", + "saddr ip", "saddr ether", "daddr ip", "daddr ether"}) + + def test_upstream_tcp_flag_dump_does_not_hide_mask_changes(self): + # tests/py/inet/tcp.t.json.output, tcp flags & (syn|fin) == (syn|fin). + dumped = '''[{"match": { + "left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, {"|": ["fin", "syn"]}]}, + "op": "==", "right": {"|": ["fin", "syn"]} + }}]''' + numeric = '''[{"match": { + "left": {"&": [{"payload": {"field": "flags", "protocol": "tcp"}}, 3]}, + "op": "==", "right": 3 + }}]''' + self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric)) + self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]'))) + + def test_upstream_full_dump_ignores_only_handles_and_metainfo(self): + # Literal RULESET from tests/shell/testcases/json/0006obj_comment_0. + dumped = json.loads('''{"nftables": [ + {"metainfo": {"version": "1.0.5", "release_name": "Lester Gooch #4", "json_schema_version": 1}}, + {"table": {"family": "inet", "name": "t", "handle": 9}}, + {"counter": {"family": "inet", "name": "mycounter", "table": "t", "handle": 1, + "comment": "my comment in counter", "packets": 0, "bytes": 0}} + ]}''')["nftables"] + expected = [ + {"table": {"family": "inet", "name": "t"}}, + {"counter": {"family": "inet", "name": "mycounter", "table": "t", + "comment": "my comment in counter", "packets": 0, "bytes": 0}}, + ] + self.assertEqual(_normalize(dumped), _normalize(expected)) + dumped[1]["table"]["flags"] = "dormant" + self.assertNotEqual(_normalize(dumped), _normalize(expected)) + + +@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks") +class NftJsonParserTests(unittest.TestCase): + """Exercise the actual JSON parser, NOT semantic/kernel/packet validation. + + A deliberately invalid final command must be the first command rejected. + This proves all earlier commands parsed even when cache initialization lacks + CAP_NET_ADMIN. --check and the invalid command both prevent any commit. + """ + + @staticmethod + def parse(commands): + commands = [*commands, {"add": {"uvm_schema_sentinel": None}}] + result = subprocess.run( + ("nft", "--check", "--json", "-f", "-"), + input=json.dumps({"nftables": commands}), text=True, capture_output=True, + timeout=5, env={**os.environ, "LC_ALL": "C"}, + ) + indices = [int(index) for index in re.findall(r"Parsing command array at index (\d+) failed", result.stderr)] + return result, indices + + @classmethod + def setUpClass(cls): + result, indices = cls.parse([]) + if indices != [0]: + raise unittest.SkipTest(f"nft parser diagnostics unavailable before netlink: {result.stderr.strip()}") + + def test_all_generated_transactions_reach_only_the_final_invalid_command(self): + runner = FakeRunner() + manager = FirewallManager(Settings(), runner) + vm = vm_record(ports=(PortRule(1), PortRule(443, "HTTPS"), PortRule(65535))) + manager.apply(vm) + vm.ports = () + manager.apply(vm) + vm.ports = None + manager.apply(vm, quarantine=True) + manager.remove(vm) + manager.check_available() + transactions = [json.loads(options["input_text"])["nftables"] + for args, options in runner.calls if args[0] == "nft" and options["input_text"]] + for index, commands in enumerate(transactions): + with self.subTest(transaction=index): + result, rejected = self.parse(commands) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(rejected, [len(commands)], result.stderr) + + def test_parser_negative_controls_fail_before_the_sentinel(self): + bad_commands = ( + {"add": {"set": {"family": "bridge", "table": "probe", "name": "ports", + "type": "inet_service", "elem": []}}}, + {"delete": {"table": {"family": "bridge", "handle": "not-an-integer"}}}, + {"add": {"chain": {"family": "bridge", "table": "probe", "name": "input", + "type": "filter", "hook": "not-a-hook", "prio": 0}}}, + {"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [ + {"match": {"left": {"ct": {"key": "state", "dir": "original"}}, "op": "in", "right": "new"}}, + ]}}}, + {"add": {"rule": {"family": "bridge", "table": "probe", "chain": "input", "expr": [ + {"match": {"left": {"payload": {"protocol": "arp", "field": "saddr"}}, "op": "==", "right": "10.42.0.2"}}, + ]}}}, + ) + for command in bad_commands: + with self.subTest(command=command): + result, rejected = self.parse([command]) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(rejected, [0], result.stderr) + + +class RulesetModel: + """Evaluate the emitted pure match/jump/return/drop subset, not a kernel. + + Conntrack metadata is supplied by each test. This catches policy/direction + mistakes but deliberately does not pretend to test native bridge tracking. + """ + + def __init__(self, tables): + self.tables = tables + + def permits(self, packet, *hooks): + for hook in hooks: + for objects in self.tables: + chains = {item["chain"]["name"]: item["chain"] for item in objects if "chain" in item} + rules = {} + sets = {item["set"]["name"]: item["set"].get("elem", []) for item in objects if "set" in item} + for item in objects: + if "rule" in item: + rules.setdefault(item["rule"]["chain"], []).append(item["rule"]["expr"]) + + def value(expr): + if isinstance(expr, str) and expr.startswith("@"): + return sets[expr[1:]] + if not isinstance(expr, dict): + return expr + if "meta" in expr: + return packet.get(expr["meta"]["key"]) + if "payload" in expr: + payload = expr["payload"] + return packet.get((payload["protocol"], payload["field"])) + if "ct" in expr: + ct = expr["ct"] + return packet.get(("ct", ct["key"], ct.get("dir"))) + if "set" in expr: + return expr["set"] + if "&" in expr: + left, right = map(value, expr["&"]) + return None if left is None else left & right + raise AssertionError(f"unmodeled expression {expr}") + + def evaluate(name): + for expressions in rules.get(name, []): + for expr in expressions: + if "match" in expr: + match = expr["match"] + left, right = value(match["left"]), value(match["right"]) + if left is None: + break + same = left in right if isinstance(right, list) else left == right + if not (not same if match["op"] == "!=" else same): + break + elif "jump" in expr: + verdict = evaluate(expr["jump"]["target"]) + if verdict != "return": + return verdict + elif "return" in expr: + return "return" + elif "drop" in expr: + return "drop" + elif "accept" in expr: + return "accept" + else: + raise AssertionError(f"unmodeled statement {expr}") + return "return" + + for name, chain in chains.items(): + if chain.get("hook") == hook and evaluate(name) == "drop": + return False + return True + + +def packet(src, dst, *, protocol="tcp", reply=False, state="established", dport=8080, flags=16): + original_src, original_dst = (dst, src) if reply else (src, dst) + return { + "iifname": src.tap, "oifname": dst.tap, + ("ether", "type"): "ip", ("ether", "saddr"): src.mac, ("ether", "daddr"): dst.mac, + ("ip", "saddr"): src.guest_ip, ("ip", "daddr"): dst.guest_ip, ("ip", "protocol"): protocol, + ("tcp", "flags"): flags, ("ct", "direction", None): "reply" if reply else "original", + ("ct", "ip saddr", "original"): original_src.guest_ip, + ("ct", "ip daddr", "original"): original_dst.guest_ip, + ("ct", "proto-dst", "original"): dport, ("ct", "protocol", None): protocol, + ("ct", "state", None): state, ("icmp", "type"): "echo-reply" if reply else "echo-request", + } + + +class FirewallPolicyTests(unittest.TestCase): + def setUp(self): + self.manager = FirewallManager(Settings(), FakeRunner()) + self.guest = vm_record() + self.peer = vm_record(id="vm-two", tap="tap-two", guest_ip="10.42.0.3", + mac="02:fc:00:00:00:02", ports=()) + self.host = vm_record(tap="", guest_ip="10.42.0.1", mac="02:00:00:00:00:01") + + def model(self, *, quarantine=False, peer=True, reverse=False): + tables = [self.manager._policy(self.guest, quarantine=quarantine)] + if peer: + tables.append(self.manager._policy(self.peer, quarantine=False)) + return RulesetModel(list(reversed(tables)) if reverse else tables) + + def test_all_hooks_and_return_verdicts_are_present_with_early_identity(self): + policy = self.manager._policy(self.guest, quarantine=False) + chains = [item["chain"] for item in policy if "chain" in item and "hook" in item["chain"]] + self.assertEqual({chain["hook"] for chain in chains}, {"prerouting", "input", "forward", "output"}) + self.assertLess(next(chain["prio"] for chain in chains if chain["hook"] == "prerouting"), -200) + verdicts = [expr for item in policy if "rule" in item for expr in item["rule"]["expr"]] + self.assertIn({"return": None}, verdicts) + self.assertNotIn({"accept": None}, verdicts) + + def test_host_and_peer_service_connections_close_in_both_directions(self): + for client, incoming, outgoing in ((self.host, ("output",), ("prerouting", "input")), + (self.peer, ("prerouting", "forward"), ("prerouting", "forward"))): + for reverse in (False, True): + with self.subTest(client=client.guest_ip, reverse=reverse): + self.guest.ports = (SimpleNamespace(port=8080, protocol="TCP"),) + traffic = packet(client, self.guest) + response = packet(self.guest, client, reply=True) + self.assertTrue(self.model(reverse=reverse).permits(traffic, *incoming)) + self.assertTrue(self.model(reverse=reverse).permits(response, *outgoing)) + self.guest.ports = () + self.assertFalse(self.model(reverse=reverse).permits(traffic, *incoming)) + self.assertFalse(self.model(reverse=reverse).permits(response, *outgoing)) + + def test_closed_policy_preserves_guest_egress_tcp_udp_and_icmp_replies(self): + self.guest.ports = () + for protocol in ("tcp", "udp", "icmp"): + with self.subTest(protocol=protocol): + request = packet(self.guest, self.host, protocol=protocol, state="new", flags=2) + response = packet(self.host, self.guest, protocol=protocol, reply=True) + self.assertTrue(self.model().permits(request, "prerouting", "input")) + self.assertTrue(self.model().permits(response, "output")) + response[("ct", "direction", None)] = "original" + self.assertFalse(self.model().permits(response, "output")) + + def test_peer_egress_never_skips_the_destination_policy(self): + for reverse in (False, True): + traffic = packet(self.guest, self.peer, state="new", flags=2) + self.assertFalse(self.model(reverse=reverse).permits(traffic, "prerouting", "forward")) + self.peer.ports = (SimpleNamespace(port=8080, protocol="TCP"),) + self.assertTrue(self.model(reverse=reverse).permits(traffic, "prerouting", "forward")) + self.peer.ports = () + + def test_new_tcp_requires_syn_for_inbound_and_egress_tracking_loss(self): + for flags in (0, 16, 18, 24, 1, 4, 3): + with self.subTest(flags=flags): + ingress = packet(self.host, self.guest, state="new", flags=flags) + lost_server_flow = packet(self.guest, self.host, state="new", flags=flags) + self.assertFalse(self.model().permits(ingress, "output")) + self.assertFalse(self.model().permits(lost_server_flow, "prerouting", "input")) + for flags in (2, 2 | 64 | 128): + self.assertTrue(self.model().permits(packet(self.host, self.guest, state="new", flags=flags), "output")) + for state in ("invalid", "untracked", "related"): + self.assertFalse(self.model().permits(packet(self.host, self.guest, state=state), "output")) + + def test_current_original_destination_port_and_address_are_required(self): + for key, value in ((("ct", "proto-dst", "original"), 22), + (("ct", "ip daddr", "original"), "10.42.0.99"), + (("ct", "protocol", None), "udp")): + for reply in (False, True): + traffic = packet(self.guest, self.host, reply=True) if reply else packet(self.host, self.guest) + traffic[key] = value + self.assertFalse(self.model().permits(traffic, *(('prerouting', 'input') if reply else ('output',)))) + + def test_inbound_udp_and_non_tcp_transports_are_not_opened(self): + for protocol in ("udp", "sctp", "gre"): + for state in ("new", "established", "related"): + self.assertFalse(self.model().permits(packet(self.host, self.guest, protocol=protocol, state=state), "output")) + + def test_required_icmp_is_authorized_by_parent_tuple_not_blanket_related(self): + self.guest.ports = () + self.assertTrue(self.model().permits(packet(self.host, self.guest, protocol="icmp", state="new"), "output")) + for protocol in ("tcp", "udp", "icmp"): + traffic = packet(self.host, self.guest, protocol=protocol, state="related", reply=True) + traffic[("ip", "protocol")] = "icmp" + for icmp in ("destination-unreachable", "time-exceeded", "parameter-problem"): + traffic[("icmp", "type")] = icmp + self.assertTrue(self.model().permits(traffic, "output")) + traffic[("icmp", "type")] = "redirect" + self.assertFalse(self.model().permits(traffic, "output")) + service_error = packet(self.host, self.guest, state="related") + service_error.update({("ip", "protocol"): "icmp", ("icmp", "type"): "destination-unreachable"}) + self.assertFalse(self.model().permits(service_error, "output")) + + def test_identity_is_enforced_before_learning_and_ipv6_and_vlan_are_dropped(self): + for key, value in ((("ether", "saddr"), self.peer.mac), + (("ip", "saddr"), self.peer.guest_ip), + (("ether", "type"), "ip6"), (("ether", "type"), "vlan"), + (("ether", "type"), "8021ad")): + traffic = packet(self.guest, self.host) + traffic[key] = value + self.assertFalse(self.model().permits(traffic, "prerouting")) + for ethertype in ("ip6", "vlan", "8021ad"): + traffic = packet(self.host, self.guest) + traffic[("ether", "type")] = ethertype + self.assertFalse(self.model().permits(traffic, "output")) + traffic = packet(self.host, self.guest) + traffic[("ip", "daddr")] = self.peer.guest_ip + self.assertFalse(self.model().permits(traffic, "output")) + + def test_arp_identity_and_address_probes_are_narrow(self): + traffic = packet(self.guest, self.host) + traffic.update({("ether", "type"): "arp", ("arp", "htype"): 1, + ("arp", "ptype"): "ip", ("arp", "hlen"): 6, ("arp", "plen"): 4, + ("arp", "operation"): "request", ("arp", "saddr ether"): self.guest.mac, + ("arp", "saddr ip"): self.guest.guest_ip, ("arp", "daddr ip"): self.host.guest_ip}) + self.assertTrue(self.model().permits(traffic, "prerouting", "input")) + for key, bad in ((("arp", "saddr ip"), self.peer.guest_ip), + (("arp", "saddr ether"), self.peer.mac), (("arp", "hlen"), 5), + (("arp", "operation"), "rreply")): + self.assertFalse(self.model().permits({**traffic, key: bad}, "prerouting")) + self.assertFalse(self.model().permits({**traffic, key: bad}, "input")) + traffic[("arp", "saddr ip")] = "0.0.0.0" + self.assertFalse(self.model().permits(traffic, "prerouting")) + traffic[("arp", "daddr ip")] = self.guest.guest_ip + self.assertTrue(self.model().permits(traffic, "prerouting")) + + def test_quarantine_drops_every_path_but_unrelated_traffic_is_untouched(self): + for protocol in ("tcp", "udp", "icmp"): + for endpoint, hook in ((self.host, "input"), (self.peer, "forward")): + self.assertFalse(self.model(quarantine=True).permits(packet(self.guest, endpoint, protocol=protocol), "prerouting", hook)) + self.assertFalse(self.model(quarantine=True).permits(packet(endpoint, self.guest, protocol=protocol), "output" if hook == "input" else "forward")) + other = vm_record(tap="unmanaged", guest_ip="10.42.0.99") + self.assertTrue(self.model(quarantine=True, peer=False).permits(packet(other, self.host), "prerouting", "input")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 95604ba..b5b6eca 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,109 +2,325 @@ from __future__ import annotations import tempfile import unittest +from collections.abc import Callable, Iterable, Iterator +from contextlib import contextmanager from dataclasses import replace from ipaddress import IPv4Address from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import patch from uvm.config import Settings -from uvm.domain import VmSpec -from uvm.errors import FirecrackerError, UvmError +from uvm.domain import State, VmRecord, VmSpec +from uvm.errors import ConflictError, FirecrackerError, UvmError, ValidationError from uvm.firecracker.process import ProcessInfo from uvm.images import ImageStore from uvm.lifecycle import LifecycleService +from uvm.network import NetworkManager from uvm.state import StateStore +class TrackedStateStore(StateStore): + def __init__(self, settings: Settings) -> None: + super().__init__(settings) + self.operation_active = False + self.transaction_active = False + self.operation_locks = 0 + + @contextmanager + def operation_lock(self) -> Iterator[None]: + if self.operation_active or self.transaction_active: + raise AssertionError("nested or inverted operation lock") + self.operation_locks += 1 + with super().operation_lock(): + self.operation_active = True + try: + yield + finally: + self.operation_active = False + + @contextmanager + def transaction(self) -> Iterator[State]: + if self.transaction_active: + raise AssertionError("nested state transaction") + self.transaction_active = True + try: + with super().transaction() as state: + yield state + finally: + self.transaction_active = False + + +class FakeHost: + """Record boundary calls and inject one-shot failures before their effects.""" + + def __init__(self, store: TrackedStateStore) -> None: + self.store = store + self.events: list[tuple[str, str | None]] = [] + self.records: dict[tuple[str, str], VmRecord] = {} + self.failures: dict[tuple[str, str | None], BaseException] = {} + + def call(self, action: str, vm_id: str | None = None, *, readonly: bool = False) -> None: + if self.store.transaction_active: + raise AssertionError(f"state transaction held during {action}") + if not readonly and not self.store.operation_active: + raise AssertionError(f"operation lock missing during {action}") + self.events.append((action, vm_id)) + if vm_id is not None: + vm = self.store.load().vms.get(vm_id) + if vm is not None: + self.records[action, vm_id] = vm + error = self.failures.pop((action, vm_id), None) + if error is None: + error = self.failures.pop((action, None), None) + if error is not None: + raise error + + def actions(self, vm_id: str) -> list[str]: + return [action for action, target in self.events if target == vm_id] + + +class FakeFirewall: + def __init__(self, host: FakeHost, network: FakeNetwork) -> None: + self.host = host + self.network = network + self.tables: dict[str, tuple[VmRecord, bool]] = {} + + def check_available(self) -> None: + self.host.call("firewall.check_available", readonly=True) + + def apply(self, vm: VmRecord, *, quarantine: bool = False) -> None: + self.host.call("firewall.quarantine" if quarantine else "firewall.apply", vm.id) + if vm.ports is None and not quarantine: + raise ValidationError("legacy VM has no managed port policy") + self.tables[vm.id] = (replace(vm), quarantine) + self.host.call("firewall.readback", vm.id) + + def status(self, vm: VmRecord) -> str: + self.host.call("firewall.status", vm.id, readonly=True) + if vm.id not in self.tables: + return "unmanaged" if vm.ports is None else "missing" + installed, quarantine = self.tables[vm.id] + if installed.tap != vm.tap: + return "drift" + if quarantine: + return "quarantined" + if vm.ports is None: + return "drift" + same_identity = installed.guest_ip == vm.guest_ip and installed.mac == vm.mac + same_ports = {rule.port for rule in installed.ports} == {rule.port for rule in vm.ports} + return "enforced" if same_identity and same_ports else "drift" + + def cleanup_connections(self, vm: VmRecord) -> None: + self.host.call("firewall.cleanup_connections", vm.id) + if vm.tap in self.network.taps: + raise UvmError("cannot clean connections while TAP exists") + + def remove(self, vm: VmRecord) -> None: + self.host.call("firewall.remove", vm.id) + if vm.tap in self.network.taps: + raise UvmError("cannot remove guard while TAP exists") + self.tables.pop(vm.id, None) + + class FakeNetwork: - def __init__(self) -> None: + def __init__(self, settings: Settings, host: FakeHost) -> None: + self._settings = settings + self.host = host + self.taps: dict[str, str] = {} + self.active_taps: set[str] = set() self.created_taps: list[str] = [] self.deleted_taps: list[str] = [] + self.firewall = FakeFirewall(host, self) - def allocate_ip(self, _vms, requested: IPv4Address | None) -> IPv4Address: - return requested or IPv4Address("10.42.0.2") + def allocate_ip(self, vms: Iterable[VmRecord], requested: IPv4Address | None) -> IPv4Address: + return NetworkManager.allocate_ip(self, vms, requested) # type: ignore[arg-type] - @staticmethod - def mac_for(index: int) -> str: - return f"02:fc:00:00:00:{index:02x}" - - @staticmethod - def tap_name(vm_id: str) -> str: - return f"uvm-{vm_id[-8:]}"[:15] + mac_for = staticmethod(NetworkManager.mac_for) + tap_name = staticmethod(NetworkManager.tap_name) def ensure_bridge(self) -> None: - return None + self.host.call("network.ensure_bridge") - def create_tap(self, name: str) -> None: - self.created_taps.append(name) + def prepare_tap(self, vm: VmRecord) -> None: + self.host.call("network.prepare_tap", vm.id) + if vm.tap in self.taps: + raise ConflictError(f"TAP device already exists: {vm.tap}") + self.taps[vm.tap] = vm.id + self.created_taps.append(vm.tap) + vm.tap_owned = True - def delete_tap(self, name: str) -> None: - self.deleted_taps.append(name) + def has_tap(self, vm: VmRecord) -> bool: + self.host.call("network.has_tap", vm.id, readonly=True) + owner = self.taps.get(vm.tap) + if owner is not None and owner != vm.id: + raise ConflictError(f"refusing to modify unowned TAP {vm.tap}") + return owner is not None + + def activate_tap(self, vm: VmRecord) -> None: + self.host.call("network.activate_tap", vm.id) + if not self.has_tap(vm): + raise UvmError(f"TAP device missing: {vm.tap}") + self.active_taps.add(vm.tap) + + def deactivate_tap(self, vm: VmRecord) -> None: + self.host.call("network.deactivate_tap", vm.id) + if self.has_tap(vm): + self.active_taps.discard(vm.tap) + + def delete_vm_tap(self, vm: VmRecord) -> None: + self.host.call("network.delete_vm_tap", vm.id) + if self.has_tap(vm): + del self.taps[vm.tap] + self.active_taps.discard(vm.tap) + self.deleted_taps.append(vm.tap) + vm.tap_owned = False class FakeProcess: - def __init__(self) -> None: - self.started = False - self.terminated: list[int | None] = [] - self.alive = True + def __init__(self, host: FakeHost) -> None: + self.host = host + self.boot_id = "test-host-boot" + self.next_pid = 12345 + self.running: dict[str, ProcessInfo] = {} + self.unknown: set[str] = set() + self.started: list[ProcessInfo] = [] + self.terminated: list[tuple[str, int | None, float | None]] = [] - def start(self, _vm) -> ProcessInfo: - self.started = True - return ProcessInfo(pid=12345, start_time="42") + def host_boot_id(self) -> str: + return self.boot_id - def is_alive(self, _vm) -> bool: - return self.alive + def observe(self, vm: VmRecord) -> str: + self.host.call("process.observe", vm.id, readonly=True) + if vm.pid is not None and vm.pid <= 0: + return "unknown" + if vm.boot_id and vm.boot_id != self.boot_id: + return "gone" + if vm.id in self.unknown: + return "unknown" + if vm.pid is None: + if (vm.spawn_pending or vm.operation == "spawning" + or vm.process_start_time is not None or Path(vm.socket).exists()): + return "unknown" + return "gone" + info = self.running.get(vm.id) + if info is None or info.pid != vm.pid: + return "gone" + if vm.process_start_time is not None and info.start_time != vm.process_start_time: + return "gone" + if vm.process_start_time is None or vm.boot_id is None: + return "unknown" + return "alive" - def terminate(self, vm) -> None: - self.terminated.append(vm.pid) - self.alive = False + def adopt_legacy_identity(self, vm: VmRecord) -> bool: + self.host.call("process.adopt_legacy_identity", vm.id) + info = self.running.get(vm.id) + if (vm.boot_id is None and info is not None and vm.id not in self.unknown + and (vm.pid, vm.process_start_time) == (info.pid, info.start_time)): + vm.boot_id = info.boot_id + return True + return False + + def start(self, vm: VmRecord, *, on_spawn: Callable[[ProcessInfo], None] | None = None) -> ProcessInfo: + self.host.call("process.start", vm.id) + if self.observe(vm) != "gone": + raise FirecrackerError("refusing to replace an existing or ambiguous process") + vm.operation = "spawning" + vm.spawn_pending = True + vm.boot_id = self.host_boot_id() + # A failure here models an interrupt inside Popen, before identity is known. + self.host.call("process.spawn", vm.id) + info = ProcessInfo(self.next_pid, str(self.next_pid * 10), self.boot_id) + self.next_pid += 1 + self.running[vm.id] = info + self.started.append(info) + vm.pid, vm.process_start_time = info.pid, info.start_time + if on_spawn is not None: + on_spawn(info) + else: + vm.spawn_pending = False + Path(vm.socket).touch() + with Path(vm.log).open("ab") as log: + log.write(f"boot {info.pid}\n".encode("ascii")) + self.host.call("process.ready", vm.id) + return info + + def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None: + self.terminated.append((vm.id, vm.pid, timeout)) + self.host.call("process.terminate", vm.id) + observation = self.observe(vm) + if observation == "unknown": + raise FirecrackerError("process identity is ambiguous") + if observation == "alive": + del self.running[vm.id] + Path(vm.socket).unlink(missing_ok=True) + self.host.call("process.gone", vm.id) class FakeClient: - def __init__(self, should_fail: bool = False) -> None: - self.should_fail = should_fail - self.config = None + def __init__(self, host: FakeHost, vm_id: str) -> None: + self.host = host + self.vm_id = vm_id + self.config: dict | None = None - def configure_and_start(self, config) -> None: + def configure_and_start(self, config: dict) -> None: + self.host.call("client.configure", self.vm_id) self.config = config - if self.should_fail: - raise FirecrackerError("simulated API failure") -class LifecycleTests(unittest.TestCase): +class LifecycleTestCase(unittest.TestCase): def setUp(self) -> None: - self._temporary_directory = tempfile.TemporaryDirectory() - base = Path(self._temporary_directory.name) / "uvm" + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + base = Path(temporary.name) / "uvm" self.settings = replace(Settings(), base=base, allow_unverified_downloads=True) self.settings.bin_dir.mkdir(parents=True) self.settings.firecracker_binary.touch() self.settings.images_dir.mkdir(parents=True) self.settings.kernel_image.write_bytes(b"kernel") self.settings.rootfs_image.write_bytes(b"rootfs") - self.store = StateStore(self.settings) + self.store = TrackedStateStore(self.settings) + self.host = FakeHost(self.store) self.images = ImageStore(self.settings) - self.images.provision_credentials = Mock() # type: ignore[method-assign] - self.network = FakeNetwork() - self.process = FakeProcess() - self.client = FakeClient() + self.provision = self.enterContext(patch.object(self.images, "provision_credentials")) + self.copy_disk = self.enterContext(patch.object( + self.images, "create_vm_disk", wraps=self.images.create_vm_disk, + )) + self.network = FakeNetwork(self.settings, self.host) + self.process = FakeProcess(self.host) + self.clients: dict[str, FakeClient] = {} self.service = LifecycleService( self.settings, self.store, self.images, self.network, # type: ignore[arg-type] self.process, # type: ignore[arg-type] - client_factory=lambda _socket, _timeout: self.client, # type: ignore[arg-type] + client_factory=self.make_client, # type: ignore[arg-type] ) - self._root_patch = patch("uvm.lifecycle.require_root") - self._kvm_patch = patch("uvm.lifecycle.check_kvm") - self._root_patch.start() - self._kvm_patch.start() + self.require_root = self.enterContext(patch("uvm.lifecycle.require_root")) + self.check_kvm = self.enterContext(patch("uvm.lifecycle.check_kvm")) + for target in ("uvm.system.CommandRunner.run", "subprocess.run", "subprocess.Popen", "os.kill"): + self.enterContext(patch(target, side_effect=AssertionError(f"unexpected host operation: {target}"))) - def tearDown(self) -> None: - self._kvm_patch.stop() - self._root_patch.stop() - self._temporary_directory.cleanup() + def make_client(self, socket: Path, _timeout: float) -> FakeClient: + client = FakeClient(self.host, socket.parent.name) + self.clients[client.vm_id] = client + return client + def create_vm(self, **kwargs) -> VmRecord: + return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs)) + + def persist(self, vm: VmRecord, **changes) -> VmRecord: + with self.store.transaction() as state: + updated = replace(state.vms[vm.id], **changes) + state.vms[vm.id] = updated + return updated + + def assert_call_order(self, vm: VmRecord, *expected: str) -> None: + actual = [action for action in self.host.actions(vm.id) if action in expected] + self.assertEqual(actual, list(expected)) + + +class LifecycleTests(LifecycleTestCase): def test_create_uses_a_private_disk_and_persists_running_state(self) -> None: vm = self.service.create(VmSpec(cpu=1, ram_mib=512)) @@ -115,8 +331,14 @@ class LifecycleTests(unittest.TestCase): self.assertEqual(vm.password, "root") self.assertEqual(self.store.load().vms[vm.id].pid, 12345) self.assertEqual(self.network.created_taps, [vm.tap]) - self.assertEqual(self.client.config["drives"][0]["path_on_host"], vm.disk) - self.images.provision_credentials.assert_called_once_with( + self.assertEqual(self.clients[vm.id].config["drives"][0]["path_on_host"], vm.disk) + self.assertTrue(vm.runtime_owned) + self.assertTrue(vm.tap_owned) + self.assertTrue(vm.disk_ready) + self.assertFalse(vm.spawn_pending) + self.assertEqual(self.process.observe(vm), "alive") + self.assertEqual(self.network.firewall.status(vm), "enforced") + self.provision.assert_called_once_with( Path(vm.disk), "root", "root", @@ -130,27 +352,30 @@ class LifecycleTests(unittest.TestCase): persisted = self.store.load().vms[vm.id] self.assertEqual(persisted.username, "admin") self.assertEqual(persisted.password, "secret-value") - self.images.provision_credentials.assert_called_once_with( + self.provision.assert_called_once_with( Path(vm.disk), "admin", "secret-value", ) def test_create_rolls_back_when_firecracker_configuration_fails(self) -> None: - self.client.should_fail = True + self.host.failures["client.configure", None] = FirecrackerError("simulated API failure") - with self.assertRaises(FirecrackerError): + with self.assertRaisesRegex(UvmError, r"failed to create vm-.*: simulated API failure") as raised: self.service.create(VmSpec(cpu=1, ram_mib=512)) + self.assertIsInstance(raised.exception.__cause__, FirecrackerError) self.assertEqual(self.store.load().vms, {}) self.assertEqual(len(self.network.deleted_taps), 1) - self.assertEqual(self.process.terminated, [12345]) + self.assertEqual([pid for _, pid, _ in self.process.terminated], [12345]) + self.assertEqual(self.network.firewall.tables, {}) + self.assertEqual(self.process.running, {}) self.assertEqual(list(self.settings.vms_dir.iterdir()), []) def test_create_rolls_back_before_network_setup_when_provisioning_fails(self) -> None: - self.images.provision_credentials.side_effect = UvmError("invalid guest image") + self.provision.side_effect = UvmError("invalid guest image") - with self.assertRaises(UvmError): + with self.assertRaisesRegex(UvmError, r"failed to create vm-.*: invalid guest image"): self.service.create(VmSpec(cpu=1, ram_mib=512)) self.assertEqual(self.store.load().vms, {}) @@ -159,10 +384,7 @@ class LifecycleTests(unittest.TestCase): self.assertEqual(list(self.settings.vms_dir.iterdir()), []) def test_create_rolls_back_when_interrupted(self) -> None: - def interrupting_create_tap(_name: str) -> None: - raise KeyboardInterrupt - - self.network.create_tap = interrupting_create_tap + self.host.failures["network.prepare_tap", None] = KeyboardInterrupt() with self.assertRaises(KeyboardInterrupt): self.service.create(VmSpec(cpu=1, ram_mib=512)) @@ -183,3 +405,286 @@ class LifecycleTests(unittest.TestCase): self.assertEqual(destroyed.id, vm.id) self.assertFalse(self.settings.vm_dir(vm.id).exists()) self.assertEqual(self.store.load().vms, {}) + + def test_stop_edit_disk_start_preserves_guest_data_credentials_and_identity(self) -> None: + vm = self.create_vm(name="database", username="admin", password="original-secret") + original = replace(vm) + disk = Path(vm.disk) + inode = disk.stat().st_ino + stopped = self.service.stop(vm.name, timeout=0.25) + self.assertEqual(self.process.terminated[-1], (vm.id, vm.pid, 0.25)) + self.assertEqual(stopped.status, "stopped") + self.assertNotIn(vm.tap, self.network.taps) + self.assertNotIn(vm.id, self.network.firewall.tables) + + disk.write_bytes(b"guest-edited-data\npassword-hash\noriginal-ssh-host-keys\n") + edited = disk.read_bytes() + started = self.service.start(vm.guest_ip) + + for field in ("id", "name", "guest_ip", "mac", "tap", "disk", "username", "password", "ports", "created_at"): + self.assertEqual(getattr(started, field), getattr(original, field), field) + self.assertNotEqual(started.pid, original.pid) + self.assertNotEqual(started.process_start_time, original.process_start_time) + self.assertEqual(started.boot_id, self.process.host_boot_id()) + self.assertEqual(disk.stat().st_ino, inode) + self.assertEqual(disk.read_bytes(), edited) + self.assertEqual(self.settings.rootfs_image.read_bytes(), b"rootfs") + self.provision.assert_called_once_with(disk, "admin", "original-secret") + self.copy_disk.assert_called_once() + self.assertEqual(self.store.get(vm.id).to_dict(), started.to_dict()) + + def test_start_uses_private_disk_when_rootfs_template_has_been_removed(self) -> None: + vm = self.create_vm() + self.service.stop(vm.id) + self.settings.rootfs_image.unlink() + with patch.object(self.images, "installed_assets", side_effect=AssertionError("start needs only the kernel")): + started = self.service.start(vm.id) + self.assertEqual(started.status, "running") + self.assertEqual(Path(started.disk).read_bytes(), b"rootfs") + self.assertEqual(self.provision.call_count, 1) + self.assertEqual(self.copy_disk.call_count, 1) + + def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None: + vm = self.create_vm() + self.assert_call_order( + vm, "network.prepare_tap", "firewall.apply", "firewall.readback", + "network.activate_tap", "process.start", "process.spawn", "process.ready", + "client.configure", "firewall.status", + ) + policy_record = self.host.records["firewall.apply", vm.id] + self.assertTrue(policy_record.disk_ready) + self.assertTrue(policy_record.tap_owned) + pending = self.host.records["process.start", vm.id] + self.assertTrue(pending.spawn_pending) + self.assertEqual(pending.operation, "spawning") + self.assertIsNone(pending.pid) + spawned = self.host.records["process.ready", vm.id] + self.assertEqual((spawned.pid, spawned.process_start_time, spawned.boot_id), + (vm.pid, vm.process_start_time, vm.boot_id)) + self.assertFalse(spawned.spawn_pending) + self.assertEqual(spawned.operation, "configuring") + self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid) + + def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None: + self.host.failures["firewall.apply", None] = UvmError("policy unavailable") + with self.assertRaisesRegex(UvmError, "policy unavailable"): + self.create_vm() + self.assertFalse(any(action in {"network.activate_tap", "process.start", "client.configure"} + for action, _ in self.host.events)) + self.assertEqual(self.store.load().vms, {}) + self.assertEqual(self.network.taps, {}) + self.assertEqual(self.network.firewall.tables, {}) + + def test_unavailable_firewall_fails_preflight_before_reserving_or_copying(self) -> None: + self.host.failures["firewall.check_available", None] = UvmError("bridge filtering unavailable") + with self.assertRaisesRegex(UvmError, "bridge filtering unavailable"): + self.create_vm() + self.assertEqual(self.store.load().vms, {}) + self.assertEqual(self.host.events, [("firewall.check_available", None)]) + self.assertEqual(list(self.settings.vms_dir.iterdir()), []) + self.provision.assert_not_called() + self.copy_disk.assert_not_called() + + def test_create_cleans_up_if_policy_readback_is_not_enforced_after_boot(self) -> None: + with patch.object(self.network.firewall, "status", return_value="drift"): + with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"): + self.create_vm() + self.assertEqual(self.store.load().vms, {}) + self.assertEqual(self.process.running, {}) + self.assertEqual(self.network.taps, {}) + self.assertEqual(self.network.firewall.tables, {}) + self.assertEqual(list(self.settings.vms_dir.iterdir()), []) + + def test_start_rejects_missing_or_unprovisioned_disk_without_network_mutation(self) -> None: + for problem in ("missing", "unprovisioned"): + with self.subTest(problem=problem): + vm = self.create_vm() + self.service.stop(vm.id) + if problem == "missing": + Path(vm.disk).unlink() + else: + self.persist(vm, disk_ready=False) + before = self.settings.state_path.read_bytes() + self.host.events.clear() + with self.assertRaisesRegex(UvmError, "disk"): + self.service.start(vm.id) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"}) + + def test_stop_quarantines_and_confirms_exit_before_releasing_network(self) -> None: + vm = self.create_vm() + self.host.events.clear() + self.service.stop(vm.id, timeout=0) + self.assert_call_order( + vm, "firewall.quarantine", "network.deactivate_tap", "process.terminate", + "process.gone", "network.delete_vm_tap", "firewall.cleanup_connections", "firewall.remove", + ) + pending = self.host.records["process.terminate", vm.id] + self.assertEqual((pending.status, pending.operation), ("stopping", "stop")) + self.assertEqual(self.process.terminated[-1], (vm.id, vm.pid, 0)) + + def test_failed_process_cleanup_keeps_disk_tap_guard_identity_and_reservation(self) -> None: + for action in ("stop", "destroy", "restart"): + with self.subTest(action=action): + vm = self.create_vm(name=f"failed-{action}") + disk = Path(vm.disk) + self.host.events.clear() + self.host.failures["process.terminate", vm.id] = FirecrackerError("SIGKILL exit unconfirmed") + with self.assertRaisesRegex(UvmError, "process exit unconfirmed.*SIGKILL"): + getattr(self.service, action)(vm.id, timeout=0.1) + retained = self.store.get(vm.id) + self.assertEqual(retained.status, "failed") + self.assertEqual((retained.pid, retained.process_start_time), (vm.pid, vm.process_start_time)) + self.assertEqual(retained.guest_ip, vm.guest_ip) + self.assertEqual(disk.read_bytes(), b"rootfs") + self.assertTrue(retained.runtime_owned) + self.assertTrue(retained.tap_owned) + self.assertEqual(self.network.taps[vm.tap], vm.id) + self.assertNotIn(vm.tap, self.network.active_taps) + self.assertEqual(self.network.firewall.status(retained), "quarantined") + self.assertEqual(self.process.observe(retained), "alive") + self.assertFalse(set(self.host.actions(vm.id)) & { + "network.delete_vm_tap", "firewall.cleanup_connections", "firewall.remove", "process.start", + }) + + def test_create_retains_resources_when_rollback_cannot_confirm_exit(self) -> None: + self.host.failures["client.configure", None] = FirecrackerError("API failed") + self.host.failures["process.terminate", None] = FirecrackerError("still alive") + with self.assertRaisesRegex(UvmError, "failed to create.*API failed; cleanup incomplete.*still alive"): + self.create_vm() + vm, = self.store.load().vms.values() + self.assertEqual(vm.status, "failed") + self.assertEqual(self.process.observe(vm), "alive") + self.assertTrue(Path(vm.disk).exists()) + self.assertTrue(vm.runtime_owned) + self.assertTrue(vm.tap_owned) + self.assertEqual(self.network.taps[vm.tap], vm.id) + self.assertEqual(self.network.firewall.status(vm), "quarantined") + self.assertNotIn("network.delete_vm_tap", self.host.actions(vm.id)) + self.assertNotIn("firewall.remove", self.host.actions(vm.id)) + + def test_uncertain_spawn_interrupt_retains_resources_and_survives_stop_retry(self) -> None: + for interrupt in (KeyboardInterrupt(), SystemExit(143)): + with self.subTest(interrupt=type(interrupt).__name__): + self.host.failures["process.spawn", None] = interrupt + previous = set(self.store.load().vms) + with self.assertRaises(type(interrupt)): + self.create_vm() + vm, = [vm for vm in self.store.load().vms.values() if vm.id not in previous] + self.assertEqual(self.process.observe(vm), "unknown") + self.assertTrue(vm.spawn_pending or vm.operation == "spawning") + self.assertIsNone(vm.pid) + self.assertEqual(vm.status, "failed") + self.assertTrue(Path(vm.disk).exists()) + self.assertEqual(self.network.taps[vm.tap], vm.id) + self.assertEqual(self.network.firewall.status(vm), "quarantined") + self.assertNotIn(vm.tap, self.network.active_taps) + for action in ("stop", "destroy"): + with self.assertRaisesRegex(UvmError, "process exit unconfirmed"): + getattr(self.service, action)(vm.id) + retained = self.store.get(vm.id) + self.assertEqual(self.process.observe(retained), "unknown") + self.assertTrue(Path(vm.disk).exists()) + self.assertIn(vm.tap, self.network.taps) + self.assertNotIn("network.delete_vm_tap", self.host.actions(vm.id)) + self.assertNotIn("firewall.remove", self.host.actions(vm.id)) + + def test_directory_reservation_collision_never_deletes_existing_contents(self) -> None: + directory = self.settings.vm_dir("vm-existing") + directory.mkdir(parents=True) + disk = directory / "rootfs.ext4" + disk.write_bytes(b"not-owned-by-this-create") + marker = directory / "operator-data" + marker.write_bytes(b"keep") + before = {path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()} + with patch("uvm.lifecycle.new_vm_id", return_value="vm-existing"): + with self.assertRaisesRegex(UvmError, "failed to create vm-existing"): + self.create_vm() + self.assertEqual({path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()}, before) + self.provision.assert_not_called() + self.copy_disk.assert_not_called() + self.assertEqual(self.network.created_taps, []) + self.assertEqual(self.process.started, []) + retained = self.store.load().vms.get("vm-existing") + if retained is not None: + self.assertFalse(retained.runtime_owned) + self.assertFalse(retained.disk_ready) + + def test_stale_stopping_and_deleting_records_can_finish_cleanup(self) -> None: + for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")): + with self.subTest(action=action): + vm = self.create_vm() + self.persist(vm, status=status, operation=operation) + del self.process.running[vm.id] + self.host.events.clear() + result = getattr(self.service, action)(vm.id) + self.assertEqual(result.id, vm.id) + self.assertNotIn(vm.tap, self.network.taps) + self.assertNotIn(vm.id, self.network.firewall.tables) + self.assertFalse(Path(vm.socket).exists()) + self.assertNotIn("process.start", self.host.actions(vm.id)) + if action == "stop": + retained = self.store.get(vm.id) + self.assertEqual((retained.status, retained.operation), ("stopped", None)) + self.assertTrue(Path(vm.disk).exists()) + else: + self.assertNotIn(vm.id, self.store.load().vms) + self.assertFalse(self.settings.vm_dir(vm.id).exists()) + + def test_failed_delete_intent_cannot_be_resurrected_by_stop_or_start(self) -> None: + vm = self.create_vm() + self.host.failures["firewall.cleanup_connections", vm.id] = UvmError("conntrack remains") + with self.assertRaisesRegex(UvmError, "failed to delete.*conntrack remains"): + self.service.destroy(vm.id) + retained = self.store.get(vm.id) + self.assertEqual((retained.status, retained.operation), ("failed", "delete")) + self.assertTrue(Path(vm.disk).exists()) + self.assertIn(vm.id, self.network.firewall.tables) + for action in ("start", "restart", "sync_ports"): + with self.subTest(action=action), self.assertRaisesRegex(ConflictError, "deletion is pending"): + getattr(self.service, action)(vm.id) + with self.assertRaisesRegex(ConflictError, "deletion is pending"): + self.service.set_ports(vm.id, ()) + stopped = self.service.stop(vm.id) + self.assertEqual((stopped.status, stopped.operation), ("stopped", "delete")) + with self.assertRaisesRegex(ConflictError, "deletion is pending"): + self.service.start(vm.id) + self.service.destroy(vm.id) + self.assertNotIn(vm.id, self.store.load().vms) + self.assertFalse(self.settings.vm_dir(vm.id).exists()) + self.assertEqual(len(self.process.started), 1) + + def test_failed_start_retains_edited_disk_and_retries_without_provisioning(self) -> None: + vm = self.create_vm() + self.service.stop(vm.id) + Path(vm.disk).write_bytes(b"retained edits and host keys") + self.host.failures["client.configure", vm.id] = FirecrackerError("API retry needed") + with self.assertRaisesRegex(UvmError, "failed to start.*API retry needed"): + self.service.start(vm.id) + failed = self.store.get(vm.id) + self.assertEqual(failed.status, "failed") + self.assertIsNone(failed.pid) + self.assertTrue(failed.disk_ready) + self.assertTrue(failed.runtime_owned) + self.assertNotIn(vm.tap, self.network.taps) + started = self.service.start(vm.id) + self.assertEqual(started.status, "running") + self.assertEqual(Path(vm.disk).read_bytes(), b"retained edits and host keys") + self.assertEqual(len({info.pid for info in self.process.started}), 3) + self.assertEqual(self.provision.call_count, 1) + self.assertEqual(self.copy_disk.call_count, 1) + + def test_restart_uses_one_lock_and_replaces_only_target_process(self) -> None: + vm = self.create_vm() + other = self.create_vm() + other_record = self.store.get(other.id).to_dict() + before_locks = self.store.operation_locks + self.host.events.clear() + restarted = self.service.restart(vm.id, timeout=0.75) + self.assertEqual(self.store.operation_locks - before_locks, 1) + self.assertNotEqual(restarted.pid, vm.pid) + self.assertEqual(self.process.terminated[0], (vm.id, vm.pid, 0.75)) + self.assertEqual(self.store.get(other.id).to_dict(), other_record) + self.assertEqual(self.process.observe(other), "alive") + self.assertEqual(self.network.firewall.status(other), "enforced") + self.assertEqual(self.provision.call_count, 2) diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py new file mode 100644 index 0000000..e5eed4a --- /dev/null +++ b/tests/test_lifecycle_commands.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +from dataclasses import replace +from ipaddress import IPv4Address +from pathlib import Path +from unittest.mock import patch + +from tests.test_lifecycle import LifecycleTestCase +from uvm.domain import PortRule +from uvm.errors import ConflictError, FirecrackerError, NotFoundError, UvmError, ValidationError +from uvm.lifecycle import LifecycleService, OperationResult +from uvm.state import StateStore + + +class LifecycleLookupTests(LifecycleTestCase): + def test_vm_resources_are_distinct_and_aliases_resolve_to_the_same_record(self) -> None: + first = self.create_vm(name="web") + second = self.create_vm(name="database", guest_ip=IPv4Address("10.42.0.9")) + third = self.create_vm() + for field in ("id", "guest_ip", "mac", "tap", "pid", "disk"): + self.assertEqual(len({getattr(vm, field) for vm in (first, second, third)}), 3, field) + self.assertEqual((first.guest_ip, second.guest_ip, third.guest_ip), + ("10.42.0.2", "10.42.0.9", "10.42.0.3")) + for identifier in (second.id, second.name, second.guest_ip): + with self.subTest(identifier=identifier): + self.assertEqual(self.service.inspect(identifier).vm.id, second.id) + self.assertEqual(self.service.find_for_ssh(identifier).id, second.id) + self.assertEqual(self.service.log_path(identifier), Path(second.log)) + selected = self.service.resolve_targets([ + second.name, first.guest_ip, second.id, first.name, third.id, second.guest_ip, + ]) + self.assertEqual([vm.id for vm in selected], [second.id, first.id, third.id]) + self.service.stop(second.name) + self.assertEqual(self.process.observe(self.store.get(second.id)), "gone") + self.assertEqual(self.process.observe(first), "alive") + self.assertEqual(self.process.observe(third), "alive") + self.assertEqual(self.network.firewall.status(first), "enforced") + self.assertEqual(self.network.firewall.status(third), "enforced") + + def test_name_and_stopped_ip_collisions_do_not_reserve_or_provision_again(self) -> None: + vm = self.create_vm(name="reserved") + self.service.stop(vm.id) + before = self.settings.state_path.read_bytes() + for kwargs, message in ( + ({"name": vm.name}, "VM name already exists"), + ({"name": vm.id}, "VM name already exists"), + ({"guest_ip": IPv4Address(vm.guest_ip)}, "IP already allocated"), + ): + with self.subTest(kwargs=kwargs), self.assertRaisesRegex(UvmError, message): + self.create_vm(**kwargs) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.provision.call_count, 1) + self.assertEqual(self.copy_disk.call_count, 1) + self.assertEqual(len(self.process.started), 1) + + def test_generated_id_retries_id_name_and_tap_collisions(self) -> None: + with patch("uvm.lifecycle.new_vm_id", return_value="vm-first-same1234567"): + first = self.create_vm(name="reserved-name") + collision = "vm-other-same1234567" + self.assertEqual(self.network.tap_name(collision), first.tap) + with patch("uvm.lifecycle.new_vm_id", side_effect=[ + first.id, first.name, collision, "vm-new-unique12345", + ]) as generate: + second = self.create_vm() + self.assertEqual(generate.call_count, 4) + self.assertEqual(second.id, "vm-new-unique12345") + self.assertNotEqual(second.tap, first.tap) + self.assertNotEqual(second.guest_ip, first.guest_ip) + self.assertEqual(self.network.taps, {first.tap: first.id, second.tap: second.id}) + + def test_ambiguous_persisted_name_ip_or_id_never_selects_an_arbitrary_vm(self) -> None: + first = self.create_vm(name="first") + second = self.create_vm(name="second") + for identifier in (first.guest_ip, first.id, first.name): + with self.subTest(identifier=identifier): + self.persist(second, name=identifier) + before = self.settings.state_path.read_bytes() + self.host.events.clear() + for method in (self.service.inspect, self.service.find_for_ssh, + self.service.log_path, self.service.stop): + with self.assertRaisesRegex(ConflictError, "ambiguous VM identifier"): + method(identifier) + with self.assertRaisesRegex(ConflictError, "ambiguous VM identifier"): + self.service.resolve_targets([identifier]) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.host.events, []) + + def test_target_resolution_rejects_invalid_or_missing_targets_without_mutation(self) -> None: + vm = self.create_vm(name="web") + before = self.settings.state_path.read_bytes() + before_locks = self.store.operation_locks + self.host.events.clear() + with self.assertRaises(ValidationError): + self.service.resolve_targets([]) + with self.assertRaises(ValidationError): + self.service.resolve_targets([vm.id], all_vms=True) + with self.assertRaises(NotFoundError): + self.service.resolve_targets([vm.name, "missing"]) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.store.operation_locks, before_locks) + self.assertEqual(self.host.events, []) + + +class LifecyclePortTests(LifecycleTestCase): + def test_stopped_port_edits_and_sync_are_metadata_only(self) -> None: + vm = self.create_vm() + stopped = self.service.stop(vm.id) + self.assertFalse(stopped.tap_owned) + for mode, ports, expected in ( + ("open", (PortRule(443, "HTTPS"), PortRule(80, "HTTP")), + (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))), + ("close", (PortRule(80),), (PortRule(22), PortRule(443, "HTTPS"))), + ("set", (), ()), + ): + with self.subTest(mode=mode): + self.host.events.clear() + updated = self.service.set_ports(vm.id, ports, mode=mode) + self.assertEqual(updated.ports, expected) + self.assertEqual((updated.status, updated.operation, updated.pid), ("stopped", None, None)) + self.assertFalse(updated.tap_owned) + self.assertEqual(self.store.get(vm.id).ports, expected) + self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"}) + self.host.events.clear() + self.service.sync_ports(vm.id) + self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"}) + self.assertEqual(self.network.firewall.tables, {}) + self.assertEqual(len(self.process.started), 1) + self.assertEqual(self.provision.call_count, 1) + + def test_live_port_modes_persist_sorted_rules_without_flushing_connections(self) -> None: + vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP"))) + other = self.create_vm(ports=(PortRule(443, "HTTPS"),)) + before_other = self.store.get(other.id).to_dict() + for mode, ports, expected in ( + ("open", (PortRule(443, "HTTPS"), PortRule(80)), + (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))), + ("close", (PortRule(80), PortRule(9000)), (PortRule(22), PortRule(443, "HTTPS"))), + ("set", (), ()), + ): + with self.subTest(mode=mode): + self.host.events.clear() + updated = self.service.set_ports(vm.id, ports, mode=mode) + self.assertEqual(updated.ports, expected) + self.assertEqual(updated.pid, vm.pid) + self.assertEqual(updated.status, "running") + self.assertIsNone(updated.operation) + pending = self.host.records["firewall.apply", vm.id] + self.assertEqual(pending.ports, expected) + self.assertEqual(pending.operation, "policy") + self.assert_call_order(vm, "firewall.apply", "network.activate_tap") + self.assertFalse(set(self.host.actions(vm.id)) & { + "firewall.cleanup_connections", "firewall.remove", "network.delete_vm_tap", + "process.terminate", "process.start", "client.configure", + }) + self.assertEqual(self.network.firewall.status(updated), "enforced") + self.assertEqual(self.store.get(other.id).to_dict(), before_other) + self.assertEqual(self.network.firewall.status(other), "enforced") + self.assertIn(other.tap, self.network.active_taps) + + def test_failed_live_close_persists_pending_policy_quarantines_and_never_reopens_old_ports(self) -> None: + for failure in ("firewall.apply", "firewall.readback", "network.activate_tap"): + with self.subTest(failure=failure): + vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP"))) + self.host.events.clear() + self.host.failures[failure, vm.id] = UvmError("policy update failed") + with self.assertRaisesRegex(UvmError, "policy pending.*policy update failed"): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + pending = self.store.get(vm.id) + self.assertEqual((pending.status, pending.operation), ("failed", "policy")) + self.assertEqual(pending.ports, (PortRule(22),)) + self.assertEqual(pending.pid, vm.pid) + self.assertIn("policy update failed", pending.last_error) + self.assertEqual(self.host.records["firewall.apply", vm.id].ports, pending.ports) + self.assertEqual(self.host.records["firewall.quarantine", vm.id].ports, pending.ports) + self.assertEqual(self.network.firewall.status(pending), "quarantined") + self.assertNotIn(vm.tap, self.network.active_taps) + self.assertFalse(set(self.host.actions(vm.id)) & { + "process.terminate", "firewall.cleanup_connections", "firewall.remove", + }) + self.host.events.clear() + synced = self.service.sync_ports(vm.id) + self.assertEqual(synced.ports, (PortRule(22),)) + self.assertEqual((synced.status, synced.operation, synced.last_error), ("running", None, None)) + self.assertEqual(self.network.firewall.status(synced), "enforced") + self.assertIn(vm.tap, self.network.active_taps) + self.assert_call_order(vm, "firewall.apply", "network.activate_tap") + + def test_failed_close_still_quarantines_when_tap_deactivation_fails(self) -> None: + vm = self.create_vm(ports=(PortRule(22), PortRule(80, "HTTP"))) + self.host.events.clear() + self.host.failures["firewall.apply", vm.id] = UvmError("policy rejected") + self.host.failures["network.deactivate_tap", vm.id] = UvmError("link-down failed") + with self.assertRaisesRegex(UvmError, "policy pending"): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + pending = self.store.get(vm.id) + self.assertEqual(pending.ports, (PortRule(22),)) + self.assertEqual(pending.operation, "policy") + self.assertEqual(self.network.firewall.status(pending), "quarantined") + self.assertIn("firewall.quarantine", self.host.actions(vm.id)) + + def test_failed_close_keeps_tap_down_if_quarantine_also_fails(self) -> None: + vm = self.create_vm(ports=(PortRule(22), PortRule(80))) + self.host.events.clear() + self.host.failures["firewall.apply", vm.id] = UvmError("policy rejected") + self.host.failures["firewall.quarantine", vm.id] = UvmError("quarantine rejected") + with self.assertRaisesRegex(UvmError, "policy pending"): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + pending = self.store.get(vm.id) + self.assertEqual(pending.ports, (PortRule(22),)) + self.assertEqual(pending.operation, "policy") + self.assertNotIn(vm.tap, self.network.active_taps) + self.assertIn(vm.id, self.network.firewall.tables) + self.assertNotIn("network.activate_tap", self.host.actions(vm.id)) + self.assertNotIn("firewall.remove", self.host.actions(vm.id)) + + def test_interrupted_close_retains_new_intent_and_quarantines(self) -> None: + for interrupt in (KeyboardInterrupt(), SystemExit(143)): + with self.subTest(interrupt=type(interrupt).__name__): + vm = self.create_vm(ports=(PortRule(22), PortRule(80))) + self.host.failures["firewall.apply", vm.id] = interrupt + with self.assertRaises(type(interrupt)): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + pending = self.store.get(vm.id) + self.assertEqual((pending.ports, pending.operation), ((PortRule(22),), "policy")) + self.assertNotIn(vm.tap, self.network.active_taps) + self.assertEqual(self.network.firewall.status(pending), "quarantined") + + def test_legacy_policy_requires_explicit_set_while_stopped(self) -> None: + vm = self.create_vm() + self.persist(vm, ports=None) + before = self.settings.state_path.read_bytes() + for mode in ("open", "close", "set"): + with self.subTest(mode=mode), self.assertRaisesRegex(ConflictError, "legacy VM policy"): + self.service.set_ports(vm.id, (PortRule(22),), mode=mode) + with self.assertRaisesRegex(ConflictError, "legacy VM has no port policy"): + self.service.start(vm.id) + with self.assertRaisesRegex(ConflictError, "legacy VM has no managed policy"): + self.service.sync_ports(vm.id) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.service.stop(vm.id) + self.assertEqual(self.service.inspect(vm.id).policy_status, "unmanaged") + for mode in ("open", "close"): + with self.subTest(mode=mode), self.assertRaises(ConflictError): + self.service.set_ports(vm.id, (), mode=mode) + migrated = self.service.set_ports(vm.id, (), mode="set") + self.assertEqual(migrated.ports, ()) + self.assertEqual(self.service.inspect(vm.id).policy_status, "inactive") + self.assertEqual(self.service.start(vm.id).status, "running") + + def test_invalid_port_requests_fail_before_lock_state_or_host_mutation(self) -> None: + vm = self.create_vm() + before = self.settings.state_path.read_bytes() + before_locks = self.store.operation_locks + self.host.events.clear() + for ports, mode in ( + ([PortRule(22)], "set"), (("22",), "set"), + ((PortRule(80, "HTTP"), PortRule(80, "TCP")), "set"), + ((PortRule(22),), "replace"), + ): + with self.subTest(ports=ports, mode=mode), self.assertRaises(ValidationError): + self.service.set_ports(vm.id, ports, mode=mode) + self.assertEqual(self.store.operation_locks, before_locks) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.host.events, []) + + def test_start_of_running_vm_repairs_policy_without_reboot_or_reprovision(self) -> None: + vm = self.create_vm() + del self.network.firewall.tables[vm.id] + self.host.events.clear() + started = self.service.start(vm.id) + self.assertEqual(started.pid, vm.pid) + self.assertEqual(self.network.firewall.status(started), "enforced") + self.assertNotIn("process.start", self.host.actions(vm.id)) + self.assertNotIn("process.terminate", self.host.actions(vm.id)) + self.assertNotIn("client.configure", self.host.actions(vm.id)) + self.assertEqual(self.provision.call_count, 1) + self.assertEqual(self.copy_disk.call_count, 1) + + +class LifecycleBulkTests(LifecycleTestCase): + def test_all_bulk_actions_take_one_outer_lock_and_use_only_selected_ids(self) -> None: + untouched = self.create_vm() + untouched_record = self.store.get(untouched.id).to_dict() + for action in ("start", "stop", "restart", "delete", "ports-sync"): + with self.subTest(action=action): + first, second = self.create_vm(), self.create_vm() + ids = (first.id, second.id) + if action == "start": + for vm_id in ids: + self.service.stop(vm_id) + before_locks = self.store.operation_locks + self.host.events.clear() + self.process.terminated.clear() + results = self.service.run_many(action, ids, timeout=0.5) + self.assertEqual(results, [OperationResult(vm_id, action, "succeeded") for vm_id in ids]) + self.assertEqual(self.store.operation_locks - before_locks, 1) + self.assertFalse(self.store.operation_active) + self.assertFalse(self.store.transaction_active) + self.assertEqual(self.store.get(untouched.id).to_dict(), untouched_record) + self.assertEqual(self.host.actions(untouched.id), []) + if action in {"stop", "restart", "delete"}: + self.assertEqual([(vm_id, timeout) for vm_id, _, timeout in self.process.terminated + if timeout is not None], [(vm_id, 0.5) for vm_id in ids]) + for vm in (first, second): + if action == "delete": + self.assertNotIn(vm.id, self.store.load().vms) + self.assertFalse(self.settings.vm_dir(vm.id).exists()) + else: + current = self.store.get(vm.id) + self.assertEqual(current.status, "stopped" if action == "stop" else "running") + if action in {"start", "restart"}: + self.assertNotEqual(current.pid, vm.pid) + + def test_partial_failure_reports_each_target_and_continues(self) -> None: + first, failed, last = self.create_vm(), self.create_vm(), self.create_vm() + self.host.failures["process.terminate", failed.id] = FirecrackerError("exit unconfirmed") + results = self.service.run_many("stop", (first.id, failed.id, last.id), timeout=0) + self.assertEqual([result.id for result in results], [first.id, failed.id, last.id]) + self.assertEqual([result.outcome for result in results], ["succeeded", "failed", "succeeded"]) + self.assertIn(failed.id, results[1].error) + self.assertIn("exit unconfirmed", results[1].error) + self.assertIsNone(results[0].error) + self.assertIsNone(results[2].error) + self.assertEqual(self.store.get(first.id).status, "stopped") + self.assertEqual(self.store.get(last.id).status, "stopped") + retained = self.store.get(failed.id) + self.assertEqual(retained.pid, failed.pid) + self.assertTrue(Path(failed.disk).exists()) + self.assertIn(failed.tap, self.network.taps) + self.assertEqual(self.network.firewall.status(retained), "quarantined") + retry = self.service.run_many("stop", (failed.id,)) + self.assertEqual(retry, [OperationResult(failed.id, "stop", "succeeded")]) + + def test_fixed_snapshot_excludes_later_vms_and_reused_name_or_ip(self) -> None: + removed = self.create_vm(name="web") + retained = self.create_vm(name="database") + selected = tuple(vm.id for vm in self.service.resolve_targets([], all_vms=True)) + self.service.destroy(removed.id) + replacement = self.create_vm(name=removed.name, guest_ip=IPv4Address(removed.guest_ip)) + later = self.create_vm(name="later") + before = {vm.id: self.store.get(vm.id).to_dict() for vm in (replacement, later)} + results = self.service.run_many("stop", selected) + self.assertEqual({result.id: result.outcome for result in results}, + {removed.id: "skipped", retained.id: "succeeded"}) + for vm in (replacement, later): + self.assertEqual(self.store.get(vm.id).to_dict(), before[vm.id]) + self.assertEqual(self.process.observe(vm), "alive") + self.assertEqual(self.store.get(retained.id).status, "stopped") + + def test_bulk_exact_ids_do_not_retarget_a_new_vm_named_after_a_deleted_id(self) -> None: + original = self.create_vm(name="original") + selected = tuple(vm.id for vm in self.service.resolve_targets([original.name])) + self.service.destroy(original.id) + replacement = self.create_vm(name=original.id) + before = self.store.get(replacement.id).to_dict() + self.host.events.clear() + results = self.service.run_many("delete", selected) + self.assertEqual(results, [OperationResult(original.id, "delete", "skipped", "already absent")]) + self.assertEqual(self.store.get(replacement.id).to_dict(), before) + self.assertEqual(self.host.actions(replacement.id), []) + self.assertTrue(Path(replacement.disk).exists()) + + def test_interrupt_preserves_current_resources_and_skips_remaining_targets(self) -> None: + first, interrupted, last = self.create_vm(), self.create_vm(), self.create_vm() + last_record = self.store.get(last.id).to_dict() + self.host.events.clear() + self.host.failures["process.terminate", interrupted.id] = KeyboardInterrupt() + results = self.service.run_many("stop", (first.id, interrupted.id, last.id)) + self.assertEqual(results, [ + OperationResult(first.id, "stop", "succeeded"), + OperationResult(interrupted.id, "stop", "interrupted", "interrupted"), + OperationResult(last.id, "stop", "skipped", "not attempted after interruption"), + ]) + self.assertEqual(self.store.get(first.id).status, "stopped") + pending = self.store.get(interrupted.id) + self.assertEqual((pending.status, pending.operation), ("failed", "stop")) + self.assertEqual(pending.pid, interrupted.pid) + self.assertTrue(Path(interrupted.disk).exists()) + self.assertIn(interrupted.tap, self.network.taps) + self.assertEqual(self.network.firewall.status(pending), "quarantined") + self.assertEqual(self.store.get(last.id).to_dict(), last_record) + self.assertEqual(self.host.actions(last.id), []) + self.assertFalse(self.store.operation_active) + self.assertEqual(self.service.stop(last.id).status, "stopped") + + def test_empty_bulk_requests_have_no_vm_or_host_effects(self) -> None: + self.assertEqual(self.service.resolve_targets([], all_vms=True), []) + for action in ("start", "stop", "restart", "delete", "ports-sync"): + with self.subTest(action=action): + self.assertEqual(self.service.run_many(action, ()), []) + self.assertEqual(self.store.load().vms, {}) + self.assertFalse(self.settings.state_path.exists()) + self.assertEqual(self.host.events, []) + self.provision.assert_not_called() + self.copy_disk.assert_not_called() + + def test_invalid_bulk_action_fails_before_lock_or_mutation(self) -> None: + with self.assertRaisesRegex(ValidationError, "invalid lifecycle action"): + self.service.run_many("create", ("vm-missing",)) + self.assertEqual(self.store.operation_locks, 0) + self.assertEqual(self.host.events, []) + + +class LifecycleInspectionTests(LifecycleTestCase): + def test_unknown_pid_inspection_is_read_only_and_never_adopts_or_cleans_up(self) -> None: + vm = self.create_vm(name="legacy-identity") + self.persist(vm, boot_id=None) + before = self.settings.state_path.read_bytes() + before_files = {path: (path.stat().st_ino, path.read_bytes()) + for path in self.settings.vm_dir(vm.id).iterdir()} + before_locks = self.store.operation_locks + self.host.events.clear() + self.require_root.reset_mock() + self.check_kvm.reset_mock() + with patch.object(self.store, "transaction", side_effect=AssertionError("read must not persist")): + inspected = self.service.inspect(vm.name) + listed, = self.service.list_vms() + self.assertEqual(self.service.find_for_ssh(vm.guest_ip).id, vm.id) + self.assertEqual(self.service.log_path(vm.id), Path(vm.log)) + self.assertEqual([item.id for item in self.service.resolve_targets([vm.name])], [vm.id]) + self.assertEqual(inspected.observed_status, "unknown") + self.assertEqual(listed.observed_status, "unknown") + self.assertEqual(inspected.vm.status, "running") + self.assertIsNone(inspected.vm.boot_id) + self.assertEqual(self.store.operation_locks, before_locks) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual({path: (path.stat().st_ino, path.read_bytes()) + for path in self.settings.vm_dir(vm.id).iterdir()}, before_files) + self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "firewall.status"}) + self.require_root.assert_not_called() + self.check_kvm.assert_not_called() + + def test_mutation_adopts_legacy_identity_under_lock_before_termination(self) -> None: + vm = self.create_vm() + self.persist(vm, boot_id=None) + self.host.events.clear() + stopped = self.service.stop(vm.id) + self.assertEqual(stopped.status, "stopped") + self.assertEqual(self.host.records["process.terminate", vm.id].boot_id, self.process.host_boot_id()) + self.assert_call_order(vm, "process.adopt_legacy_identity", "process.terminate") + + def test_unknown_identity_blocks_start_and_port_edits_without_mutation(self) -> None: + vm = self.create_vm() + self.process.unknown.add(vm.id) + before = self.settings.state_path.read_bytes() + self.host.events.clear() + for method in (self.service.start, self.service.sync_ports): + with self.assertRaisesRegex(ConflictError, "unknown"): + method(vm.id) + with self.assertRaisesRegex(ConflictError, "known process identity"): + self.service.set_ports(vm.id, ()) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertFalse(set(self.host.actions(vm.id)) - {"process.observe", "process.adopt_legacy_identity"}) + + def test_observed_dead_or_running_status_never_rewrites_desired_state(self) -> None: + dead = self.create_vm() + alive = self.create_vm() + del self.process.running[dead.id] + self.persist(alive, status="stopped") + before = self.settings.state_path.read_bytes() + observations = {item.vm.id: item for item in self.service.list_vms()} + self.assertEqual(observations[dead.id].observed_status, "dead") + self.assertEqual(observations[dead.id].vm.status, "running") + self.assertEqual(observations[alive.id].observed_status, "running") + self.assertEqual(observations[alive.id].vm.status, "stopped") + self.assertEqual(self.settings.state_path.read_bytes(), before) + + def test_empty_inspection_does_not_create_an_uninstalled_registry(self) -> None: + settings = replace(self.settings, base=self.settings.base / "not-installed") + service = LifecycleService(settings, StateStore(settings), self.images, self.network, self.process) + self.assertEqual(service.list_vms(), []) + self.assertEqual(service.resolve_targets([], all_vms=True), []) + for method in (service.inspect, service.find_for_ssh, service.log_path): + with self.assertRaises(NotFoundError): + method("missing") + self.assertFalse(settings.base.exists()) + self.assertEqual(self.host.events, []) + + def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None: + self.check_kvm.side_effect = UvmError("KVM unavailable") + self.host.failures["firewall.check_available", None] = UvmError("nft unavailable") + with patch("uvm.lifecycle.shutil.which", return_value=None): + checks = {item["name"]: item for item in self.service.diagnostics()} + self.assertTrue(checks["root"]["ok"]) + self.assertTrue(checks["guest assets"]["ok"]) + self.assertFalse(checks["kvm"]["ok"]) + self.assertEqual(checks["kvm"]["message"], "KVM unavailable") + self.assertFalse(checks["private firewall"]["ok"]) + self.assertEqual(checks["private firewall"]["message"], "nft unavailable") + self.assertFalse(checks["nft"]["ok"]) + self.assertEqual(self.store.operation_locks, 0) + self.assertFalse(self.settings.state_path.exists()) + self.assertEqual(self.host.events, [("firewall.check_available", None)]) diff --git a/tests/test_network.py b/tests/test_network.py index 0a3450f..9987395 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,35 +1,78 @@ from __future__ import annotations +import json import unittest +from copy import deepcopy +from dataclasses import replace from ipaddress import IPv4Address, IPv4Network from uvm.config import Settings -from uvm.domain import VmRecord -from uvm.errors import UvmError, ValidationError +from uvm.domain import PortRule, VmRecord +from uvm.errors import CommandError, ConflictError, UvmError, ValidationError from uvm.network import NetworkManager from uvm.system import CommandResult +LINK_INVENTORY = ("ip", "-j", "-d", "link", "show") + + class FakeRunner: def __init__(self) -> None: self.calls: list[tuple[str, ...]] = [] - self.existing_links: set[str] = set() - self.link_details: dict[str, str] = {} + self.links: dict[str, dict] = {} + self.inventory: str | None = None + self.failures: dict[tuple[str, ...], BaseException] = {} def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult: - del check, capture, timeout + del capture, timeout args = tuple(str(part) for part in command) self.calls.append(args) - if args[:3] == ("ip", "link", "show") and args[3] in self.existing_links: - return CommandResult(args=args, returncode=0) - if args[:6] == ("ip", "-j", "-d", "link", "show", "dev"): - return CommandResult(args=args, returncode=0, stdout=self.link_details[args[6]]) - if args[:4] == ("ip", "link", "show", "uvm0"): - return CommandResult(args=args, returncode=1) + if failure := self.failures.get(args): + if check or not isinstance(failure, CommandError): + raise failure + return CommandResult(args=args, returncode=1, stderr=str(failure)) + if args[:5] == LINK_INVENTORY: + links = list(self.links.values()) if args == LINK_INVENTORY else [self.links[args[6]]] + stdout = self.inventory if self.inventory is not None else json.dumps(links) + return CommandResult(args=args, returncode=0, stdout=stdout) + if args[:3] == ("ip", "link", "show"): + return CommandResult(args=args, returncode=0 if args[3] in self.links else 1) + if args[:4] == ("ip", "tuntap", "add", "dev"): + if args[4] in self.links: + raise CommandError("interface already exists") + self.links[args[4]] = { + "ifname": args[4], "flags": [], + "linkinfo": {"info_kind": "tun", "info_data": {"type": "tap"}}, + } + elif args[:3] == ("ip", "link", "add"): + self.links[args[3]] = { + "ifname": args[3], "flags": [], + "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}}, + } + elif args[:3] == ("ip", "link", "set"): + name, *options = args[4:] if args[3] == "dev" else args[3:] + link = self.links[name] + if options[0] == "alias": + link["ifalias"] = options[1] + elif options[0] == "master": + link["master"] = options[1] + elif options in (["up"], ["down"]): + link["flags"] = ["UP"] if options == ["up"] else [] + elif options[:3] == ["type", "bridge_slave", "group_fwd_mask"]: + link["linkinfo"]["info_slave_kind"] = "bridge" + link["linkinfo"]["info_slave_data"] = {"group_fwd_mask": int(options[3])} + else: + raise AssertionError(f"unexpected link command: {args}") + elif args[:3] == ("ip", "link", "del"): + self.links.pop(args[3], None) + elif args[:3] == ("ip", "addr", "replace") or args[:2] == ("sysctl", "-w"): + pass + elif args[0] == "iptables": + return CommandResult(args=args, returncode=1 if "-C" in args else 0) + elif args != ("ip", "route", "show", "default"): + raise AssertionError(f"unexpected host command: {args}") if args == ("ip", "route", "show", "default"): return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n") - if len(args) > 3 and args[0] == "iptables" and "-C" in args: - return CommandResult(args=args, returncode=1) return CommandResult(args=args, returncode=0) @@ -37,13 +80,8 @@ class NetworkManagerTests(unittest.TestCase): def setUp(self) -> None: self.runner = FakeRunner() self.network = NetworkManager(Settings(), runner=self.runner) # type: ignore[arg-type] - - def test_allocates_first_available_guest_address(self) -> None: - self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2")) - - def test_rejects_gateway_and_used_requested_addresses(self) -> None: - vm = VmRecord( - id="vm-test", + self.vm = VmRecord( + id="vm-0123456789abcdef0123456789abcdef", cpu=1, ram_mib=512, guest_ip="10.42.0.2", @@ -53,11 +91,23 @@ class NetworkManagerTests(unittest.TestCase): socket="/tmp/firecracker.sock", config="/tmp/config.json", log="/tmp/firecracker.log", + ports=(PortRule(22),), + bridge="uvm0", ) + self.tap_link = { + "ifname": self.vm.tap, "ifalias": f"uvm:{self.vm.id}", "flags": [], + "master": "uvm0", + "linkinfo": {"info_kind": "tun", "info_data": {"type": "tap"}}, + } + + def test_allocates_first_available_guest_address(self) -> None: + self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2")) + + def test_rejects_gateway_and_used_requested_addresses(self) -> None: with self.assertRaises(ValidationError): - self.network.allocate_ip([vm], IPv4Address("10.42.0.1")) + self.network.allocate_ip([self.vm], IPv4Address("10.42.0.1")) with self.assertRaises(ValidationError): - self.network.allocate_ip([vm], IPv4Address("10.42.0.2")) + self.network.allocate_ip([self.vm], IPv4Address("10.42.0.2")) with self.assertRaises(ValidationError): self.network.allocate_ip([], IPv4Address("10.42.0.0")) with self.assertRaises(ValidationError): @@ -130,19 +180,21 @@ class NetworkManagerTests(unittest.TestCase): ) def test_refuses_to_adopt_an_existing_tap_device(self) -> None: - self.runner.existing_links.add("uvm-existing") - - with self.assertRaises(UvmError): - self.network.create_tap("uvm-existing") - - self.assertNotIn( - ("ip", "tuntap", "add", "dev", "uvm-existing", "mode", "tap"), - self.runner.calls, - ) + for alias in (None, "foreign-owner", f"uvm:{self.vm.id}"): + for owned in (False, True): + with self.subTest(alias=alias, owned=owned): + link = {**deepcopy(self.tap_link), "ifalias": alias} + self.runner.links = {self.vm.tap: link} + self.runner.calls.clear() + self.vm.tap_owned = owned + with self.assertRaisesRegex(ConflictError, "already exists"): + self.network.prepare_tap(self.vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertEqual(self.runner.links, {self.vm.tap: link}) + self.assertIs(self.vm.tap_owned, owned) def test_refuses_to_reconfigure_an_existing_non_bridge_interface(self) -> None: - self.runner.existing_links.add("uvm0") - self.runner.link_details["uvm0"] = '[{"linkinfo": {"info_kind": "dummy"}}]' + self.runner.links["uvm0"] = {"ifname": "uvm0", "linkinfo": {"info_kind": "dummy"}} with self.assertRaises(UvmError): self.network.ensure_bridge() @@ -151,3 +203,273 @@ class NetworkManagerTests(unittest.TestCase): ("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"), self.runner.calls, ) + + def test_existing_stp_disabled_bridge_is_reused(self) -> None: + self.runner.links["uvm0"] = { + "ifname": "uvm0", "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": 0}}, + } + self.network.ensure_bridge() + self.assertNotIn(("ip", "link", "add", "uvm0", "type", "bridge"), self.runner.calls) + self.assertIn(("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"), self.runner.calls) + + def test_stp_enabled_bridge_is_rejected_before_any_mutation(self) -> None: + for stp_state in (1, 2): + with self.subTest(stp_state=stp_state): + self.runner.links["uvm0"] = { + "ifname": "uvm0", + "linkinfo": {"info_kind": "bridge", "info_data": {"stp_state": stp_state}}, + } + self.runner.calls.clear() + with self.assertRaisesRegex(UvmError, "STP-disabled"): + self.network.ensure_bridge() + self.assertEqual(self.runner.calls, [ + ("ip", "link", "show", "uvm0"), (*LINK_INVENTORY, "dev", "uvm0"), + ]) + + def test_invalid_bridge_inventory_is_rejected_before_any_mutation(self) -> None: + self.runner.links["uvm0"] = {"ifname": "uvm0"} + for inventory in ("invalid-json", "null", "{}", "[]", "[{}]"): + with self.subTest(inventory=inventory): + self.runner.inventory = inventory + self.runner.calls.clear() + with self.assertRaises(UvmError): + self.network.ensure_bridge() + self.assertEqual(self.runner.calls, [ + ("ip", "link", "show", "uvm0"), (*LINK_INVENTORY, "dev", "uvm0"), + ]) + + def test_prepare_tap_sets_owner_bridge_and_lldp_mask_without_activating_it(self) -> None: + unrelated = {"ifname": f"{self.vm.tap}-other", "linkinfo": {"info_kind": "dummy"}} + for bridge in (None, "uvm-retained"): + with self.subTest(bridge=bridge): + self.runner.links = {unrelated["ifname"]: deepcopy(unrelated)} + self.runner.calls.clear() + vm = replace(self.vm, bridge=bridge, tap_owned=False) + self.network.prepare_tap(vm) + + link = self.runner.links[vm.tap] + self.assertIs(vm.tap_owned, True) + self.assertEqual(link["ifalias"], f"uvm:{vm.id}") + self.assertEqual(link["master"], bridge or "uvm0") + self.assertEqual(link["linkinfo"]["info_kind"], "tun") + self.assertEqual(link["linkinfo"]["info_data"]["type"], "tap") + self.assertEqual(link["linkinfo"]["info_slave_data"]["group_fwd_mask"], 16384) + self.assertNotIn("UP", link["flags"]) + self.assertFalse(any(call[-1] == "up" for call in self.runner.calls)) + self.assertEqual(self.runner.links[unrelated["ifname"]], unrelated) + self.assertEqual(self.runner.calls[0], LINK_INVENTORY) + commands = ( + ("ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"), + ("ip", "link", "set", "dev", vm.tap, "alias", f"uvm:{vm.id}"), + ("ip", "link", "set", vm.tap, "master", bridge or "uvm0"), + ("ip", "link", "set", "dev", vm.tap, "type", "bridge_slave", "group_fwd_mask", "16384"), + ) + positions = [self.runner.calls.index(command) for command in commands] + self.assertEqual(positions, sorted(positions)) + + def test_owned_tap_can_be_activated_deactivated_and_deleted(self) -> None: + unrelated = {"ifname": "eth0", "flags": ["UP"], "linkinfo": {"info_kind": "veth"}} + for owned in (False, True): + with self.subTest(owned=owned): + vm = replace(self.vm, tap_owned=owned) + self.runner.links = {"eth0": deepcopy(unrelated), vm.tap: deepcopy(self.tap_link)} + self.runner.calls.clear() + self.assertTrue(self.network.has_tap(vm)) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + + self.network.activate_tap(vm) + self.assertIn("UP", self.runner.links[vm.tap]["flags"]) + self.network.deactivate_tap(vm) + self.assertNotIn("UP", self.runner.links[vm.tap]["flags"]) + self.assertIs(vm.tap_owned, owned) + self.network.delete_vm_tap(vm) + self.assertNotIn(vm.tap, self.runner.links) + self.assertIs(vm.tap_owned, False) + self.assertFalse(self.network.has_tap(vm)) + self.assertEqual(self.runner.links, {"eth0": unrelated}) + + def test_missing_tap_cannot_be_activated_and_cleanup_is_idempotent(self) -> None: + self.vm.tap_owned = True + with self.assertRaisesRegex(UvmError, "TAP device missing"): + self.network.activate_tap(self.vm) + self.assertIs(self.vm.tap_owned, True) + self.network.deactivate_tap(self.vm) + self.assertFalse(self.network.has_tap(self.vm)) + self.network.delete_vm_tap(self.vm) + self.network.delete_vm_tap(self.vm) + self.assertIs(self.vm.tap_owned, False) + self.assertTrue(all(call == LINK_INVENTORY for call in self.runner.calls)) + + def test_named_foreign_taps_are_never_activated_deactivated_deleted_or_claimed(self) -> None: + cases = ( + ((PortRule(22),), True, None), ((), True, ""), ((), False, None), + (None, False, None), (None, True, "uvm:another-vm"), + ((), True, "uvm:another-vm"), ((), True, self.vm.id), + ((), True, f"uvm:{self.vm.id}-suffix"), + ) + for ports, owned, alias in cases: + for operation in ("activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"): + with self.subTest(ports=ports, owned=owned, alias=alias, operation=operation): + vm = replace(self.vm, ports=ports, tap_owned=owned) + link = {**deepcopy(self.tap_link), "ifalias": alias} + self.runner.links = {vm.tap: link} + before = deepcopy(self.runner.links) + self.runner.calls.clear() + with self.assertRaisesRegex(ConflictError, "unowned TAP"): + getattr(self.network, operation)(vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertEqual(self.runner.links, before) + self.assertIs(vm.tap_owned, owned) + + def test_owned_legacy_unaliased_records_can_only_manage_real_tun_taps(self) -> None: + for alias in (None, ""): + with self.subTest(alias=alias): + vm = replace(self.vm, ports=None, tap_owned=True) + link = deepcopy(self.tap_link) + if alias is None: + link.pop("ifalias") + else: + link["ifalias"] = alias + self.runner.links = {vm.tap: link} + self.assertTrue(self.network.has_tap(vm)) + self.network.activate_tap(vm) + self.assertIn("UP", link["flags"]) + self.network.deactivate_tap(vm) + self.assertNotIn("UP", link["flags"]) + self.network.delete_vm_tap(vm) + self.assertIs(vm.tap_owned, False) + self.assertNotIn(vm.tap, self.runner.links) + + def test_ownership_markers_do_not_authorize_non_tap_interfaces(self) -> None: + for info in ( + {}, {"info_kind": "dummy"}, {"info_kind": "bridge"}, {"info_kind": "veth"}, + {"info_kind": "tap"}, {"info_kind": "tun"}, + {"info_kind": "tun", "info_data": {"type": "tun"}}, + ): + for ports, alias in ((None, None), ((), f"uvm:{self.vm.id}")): + for operation in ("activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"): + with self.subTest(info=info, ports=ports, operation=operation): + vm = replace(self.vm, ports=ports, tap_owned=True) + link = {"ifname": vm.tap, "ifalias": alias, "linkinfo": info} + self.runner.links = {vm.tap: link} + self.runner.calls.clear() + with self.assertRaises(ConflictError): + getattr(self.network, operation)(vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertIs(vm.tap_owned, True) + + def test_invalid_or_ambiguous_inventory_fails_closed(self) -> None: + for inventory in ( + "invalid-json", "null", "{}", "[null]", "[[]]", "[1]", + json.dumps([self.tap_link, self.tap_link]), + ): + for operation in ("prepare_tap", "activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"): + with self.subTest(inventory=inventory, operation=operation): + self.runner.inventory = inventory + self.runner.calls.clear() + self.vm.tap_owned = True + with self.assertRaisesRegex(UvmError, "cannot inspect network interfaces"): + getattr(self.network, operation)(self.vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertIs(self.vm.tap_owned, True) + + def test_invalid_interface_names_cannot_authorize_creation_or_cleanup(self) -> None: + for link in ({}, {"ifname": None}, {"ifname": 123}, {"ifname": ""}): + for operation, owned in (("prepare_tap", False), ("delete_vm_tap", True)): + with self.subTest(link=link, operation=operation): + self.runner.inventory = json.dumps([link]) + self.runner.links.clear() + self.runner.calls.clear() + self.vm.tap_owned = owned + with self.assertRaises(UvmError): + getattr(self.network, operation)(self.vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertIs(self.vm.tap_owned, owned) + + def test_malformed_tap_metadata_is_a_controlled_error_without_mutation(self) -> None: + for info in ( + None, [], {"info_kind": "tun", "info_data": None}, + {"info_kind": "tun", "info_data": []}, + ): + for operation in ("activate_tap", "delete_vm_tap"): + with self.subTest(info=info, operation=operation): + self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}} + self.runner.calls.clear() + self.vm.tap_owned = True + with self.assertRaises(UvmError): + getattr(self.network, operation)(self.vm) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertIs(self.vm.tap_owned, True) + + def test_inventory_command_failure_never_mutates_a_link_or_ownership(self) -> None: + failure = CommandError("inventory unavailable") + self.runner.failures[LINK_INVENTORY] = failure + for operation in ("prepare_tap", "activate_tap", "deactivate_tap", "delete_vm_tap", "has_tap"): + with self.subTest(operation=operation): + self.runner.calls.clear() + self.vm.tap_owned = True + with self.assertRaises(CommandError) as raised: + getattr(self.network, operation)(self.vm) + self.assertIs(raised.exception, failure) + self.assertEqual(self.runner.calls, [LINK_INVENTORY]) + self.assertIs(self.vm.tap_owned, True) + + def test_prepare_failure_cleans_created_taps_or_retains_the_persistable_owned_flag(self) -> None: + stages = ( + ("ip", "tuntap", "add", "dev", self.vm.tap, "mode", "tap"), + ("ip", "link", "set", "dev", self.vm.tap, "alias", f"uvm:{self.vm.id}"), + ("ip", "link", "set", self.vm.tap, "master", "uvm0"), + ("ip", "link", "set", "dev", self.vm.tap, "type", "bridge_slave", "group_fwd_mask", "16384"), + ) + delete = ("ip", "link", "del", self.vm.tap) + for index, stage in enumerate(stages): + for cleanup_fails in (False, True): + with self.subTest(stage=stage, cleanup_fails=cleanup_fails): + vm = replace(self.vm, tap_owned=False) + self.runner.links.clear() + self.runner.calls.clear() + failure = CommandError("preparation failed") + self.runner.failures = {stage: failure} + if cleanup_fails: + self.runner.failures[delete] = CommandError("deletion failed") + with self.assertRaises(CommandError) as raised: + self.network.prepare_tap(vm) + + retained = index > 0 and cleanup_fails + self.assertIs(raised.exception, failure) + self.assertIs(vm.tap_owned, retained) + restored = VmRecord.from_dict(json.loads(json.dumps(vm.to_dict()))) + self.assertIs(restored.tap_owned, retained) + self.assertEqual(vm.tap in self.runner.links, retained) + self.assertEqual(delete in self.runner.calls, index > 0) + self.assertFalse(any(call[-1] == "up" for call in self.runner.calls)) + + def test_interrupted_preparation_cleans_up_and_reraises_the_interrupt(self) -> None: + stage = ("ip", "link", "set", self.vm.tap, "master", "uvm0") + for failure in (KeyboardInterrupt(), SystemExit(2)): + with self.subTest(failure=type(failure).__name__): + self.runner.calls.clear() + self.runner.failures = {stage: failure} + with self.assertRaises(type(failure)) as raised: + self.network.prepare_tap(self.vm) + self.assertIs(raised.exception, failure) + self.assertNotIn(self.vm.tap, self.runner.links) + self.assertIs(self.vm.tap_owned, False) + self.assertIn(("ip", "link", "del", self.vm.tap), self.runner.calls) + + def test_failed_deletion_keeps_ownership_until_a_successful_retry(self) -> None: + self.vm.tap_owned = True + self.runner.links[self.vm.tap] = deepcopy(self.tap_link) + delete = ("ip", "link", "del", self.vm.tap) + self.runner.failures[delete] = CommandError("device busy") + + with self.assertRaisesRegex(UvmError, "could not delete TAP.*device busy"): + self.network.delete_vm_tap(self.vm) + self.assertIn(self.vm.tap, self.runner.links) + self.assertIs(self.vm.tap_owned, True) + self.assertIs(VmRecord.from_dict(self.vm.to_dict()).tap_owned, True) + + self.runner.failures.clear() + self.network.delete_vm_tap(self.vm) + self.assertNotIn(self.vm.tap, self.runner.links) + self.assertIs(self.vm.tap_owned, False) diff --git a/tests/test_process.py b/tests/test_process.py index 2cb7f7d..754c873 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -1,17 +1,41 @@ from __future__ import annotations +import errno +import os +import signal +import socket +import stat +import subprocess +import sys import tempfile import unittest +from dataclasses import replace from pathlib import Path -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import Mock, call, patch from uvm.config import Settings from uvm.domain import VmRecord from uvm.errors import FirecrackerError -from uvm.firecracker.process import FirecrackerProcessManager +from uvm.firecracker import process as process_module +from uvm.firecracker.process import FirecrackerProcessManager, ProcessInfo -def make_vm(pid: int | None, start_time: str | None) -> VmRecord: +BOOT_ID = "6a2e1188-3925-456b-867c-855e7542b092" +OLD_BOOT_ID = "02e208af-7560-41ee-b8f9-df3579e57c71" +PID = 12345 +PIDFD = 91 + + +def make_vm( + directory: Path, + pid: int | None = PID, + start_time: str | None = "42", + *, + boot_id: str | None = BOOT_ID, + operation: str | None = None, + spawn_pending: bool = False, +) -> VmRecord: return VmRecord( id="vm-test", cpu=1, @@ -20,59 +44,1248 @@ def make_vm(pid: int | None, start_time: str | None) -> VmRecord: gateway="10.42.0.1", tap="uvm-test", mac="02:fc:00:00:00:01", - socket="/tmp/uvm-missing.sock", - config="/tmp/config.json", - log="/tmp/firecracker.log", pid=pid, process_start_time=start_time, + boot_id=boot_id, + operation=operation, + spawn_pending=spawn_pending, + socket=str(directory / "firecracker.sock"), + config=str(directory / "config.json"), + log=str(directory / "firecracker.log"), + disk=str(directory / "rootfs.ext4"), ) -class FirecrackerProcessSafetyTests(unittest.TestCase): +class FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def monotonic(self) -> float: + return self.now + + def sleep(self, duration: float) -> None: + self.now += duration + + +class ProcessTestCase(unittest.TestCase): + def setUp(self) -> None: + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self.directory = Path(temporary_directory.name) + self.clock = FakeClock() + self.child = Mock(pid=PID) + self.child.poll.return_value = None + self.popen = Mock(return_value=self.child) + self.manager = FirecrackerProcessManager( + Settings( + base=self.directory, + api_socket_timeout_s=0.1, + terminate_timeout_s=0.1, + ), + popen=self.popen, + sleep=self.clock.sleep, + monotonic=self.clock.monotonic, + ) + self.vm = make_vm(self.directory) + self.boot_id = self.enterContext(patch.object(self.manager, "_boot_id", return_value=BOOT_ID)) + self.stat = self.enterContext( + patch.object(self.manager, "_process_stat", return_value=("S", "42")) + ) + # Every signal/handle boundary is mocked for the synthetic PID and fd. + self.kill = Mock() + self.close = Mock() + self.enterContext( + patch.object( + process_module, + "os", + SimpleNamespace( + kill=self.kill, close=self.close, getpid=os.getpid, fsencode=os.fsencode, + ), + ) + ) + self.open_pidfd = self.enterContext(patch.object(process_module, "_open_pidfd", return_value=PIDFD)) + self.signal_pidfd = self.enterContext(patch.object(process_module, "_signal_pidfd")) + self.exited = self.enterContext(patch.object(process_module, "_pidfd_exited", return_value=False)) + + def tearDown(self) -> None: + for invocation in self.kill.call_args_list: + self.assertEqual(invocation.args[1], 0, "never signal a bare PID") + + +class FirecrackerProcessSafetyTests(ProcessTestCase): + def test_observes_a_matching_identity_as_alive(self) -> None: + self.assertEqual(self.manager.observe(self.vm), "alive") + self.assertTrue(self.manager.is_alive(self.vm)) + def test_refuses_to_signal_an_unidentified_legacy_pid(self) -> None: - manager = FirecrackerProcessManager(Settings()) - vm = make_vm(pid=12345, start_time=None) + self.vm.process_start_time = None + Path(self.vm.socket).touch() - with patch("uvm.firecracker.process.os.kill") as kill: - with self.assertRaises(FirecrackerError): - manager.terminate(vm) + with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"): + self.manager.terminate(self.vm) - kill.assert_called_once_with(12345, 0) + self.assertTrue(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + self.open_pidfd.assert_not_called() + + def test_unreadable_legacy_proc_with_a_possible_live_pid_is_unknown(self) -> None: + self.vm.process_start_time = None + self.stat.return_value = None + + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.kill.assert_called_once_with(PID, 0) + self.signal_pidfd.assert_not_called() def test_allows_cleanup_when_an_unidentified_legacy_pid_is_already_gone(self) -> None: - manager = FirecrackerProcessManager(Settings()) - vm = make_vm(pid=12345, start_time=None) + self.vm.process_start_time = None + self.stat.return_value = None + self.kill.side_effect = ProcessLookupError + Path(self.vm.socket).touch() - with patch("uvm.firecracker.process.os.kill", side_effect=ProcessLookupError): - manager.terminate(vm) + self.manager.terminate(self.vm) - def test_rejects_non_positive_pids_without_signaling(self) -> None: - manager = FirecrackerProcessManager(Settings()) - vm = make_vm(pid=0, start_time="42") + self.assertFalse(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() - with patch("uvm.firecracker.process.os.kill") as kill: - with self.assertRaises(FirecrackerError): - manager.terminate(vm) + def test_rejects_non_positive_pids_without_signaling_or_unlinking(self) -> None: + Path(self.vm.socket).touch() + for pid in (0, -1): + with self.subTest(pid=pid): + self.vm.pid = pid + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaisesRegex(FirecrackerError, "invalid persisted"): + self.manager.terminate(self.vm) - kill.assert_not_called() + self.assertTrue(Path(self.vm.socket).exists()) + self.kill.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_a_prior_boot_proves_exit_even_if_pid_and_start_time_match(self) -> None: + self.vm.boot_id = OLD_BOOT_ID + Path(self.vm.socket).touch() + + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + + self.assertFalse(Path(self.vm.socket).exists()) + self.stat.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_missing_saved_or_current_boot_identity_is_unknown(self) -> None: + for saved_boot, current_boot in ((None, BOOT_ID), (BOOT_ID, None), (None, None)): + with self.subTest(saved=saved_boot, current=current_boot): + self.vm.boot_id = saved_boot + self.boot_id.return_value = current_boot + self.assertEqual(self.manager.observe(self.vm), "unknown") + self.assertTrue(self.manager.is_alive(self.vm)) + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.signal_pidfd.assert_not_called() + + def test_malformed_saved_tokens_do_not_prove_pid_reuse(self) -> None: + Path(self.vm.socket).touch() + for boot_id in (None, BOOT_ID): + for start_time in ("invalid", "-42", " 42", "42\0", "\u0664\u0662", 42): + with self.subTest(boot=boot_id, start_time=start_time): + self.vm.boot_id = boot_id + self.vm.process_start_time = start_time + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + + def test_unreadable_proc_is_unknown_unless_pid_is_proven_missing(self) -> None: + self.stat.return_value = None + for error in (None, PermissionError(), OSError(errno.EIO, "unavailable"), OverflowError()): + with self.subTest(error=error): + self.kill.side_effect = error + self.assertEqual(self.manager.observe(self.vm), "unknown") + self.kill.side_effect = ProcessLookupError + self.assertEqual(self.manager.observe(self.vm), "gone") + self.assertFalse(self.manager.is_alive(self.vm)) + + def test_pid_reuse_is_gone_and_never_signaled(self) -> None: + self.stat.return_value = ("S", "99") + Path(self.vm.socket).touch() + + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + + self.assertFalse(Path(self.vm.socket).exists()) + self.open_pidfd.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_zombie_and_dead_processes_are_gone_when_the_group_has_exited(self) -> None: + self.exited.return_value = True + for state in ("Z", "X", "x"): + with self.subTest(state=state): + self.stat.return_value = (state, "42") + self.assertEqual(self.manager.observe(self.vm), "gone") + + self.assertEqual(self.close.call_count, 3) + self.signal_pidfd.assert_not_called() + + def test_zombie_leader_with_live_threads_is_not_safe_to_delete(self) -> None: + self.stat.return_value = ("Z", "42") + Path(self.vm.socket).touch() + + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + + def test_zombie_observation_fails_closed_without_a_safe_handle(self) -> None: + self.stat.return_value = ("Z", "42") + self.open_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported") + self.assertEqual(self.manager.observe(self.vm), "unknown") + + def test_no_pid_during_spawning_is_unknown_even_without_a_socket(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + self.vm.operation = "spawning" + for saved_boot in (None, BOOT_ID): + with self.subTest(boot=saved_boot): + self.vm.boot_id = saved_boot + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + with self.assertRaises(FirecrackerError): + self.manager.start(self.vm) + + self.popen.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_pending_spawn_survives_persisted_stop_and_delete_intent_without_a_pid(self) -> None: + for operation in ("stop", "delete"): + for saved_boot, current_boot in ((None, BOOT_ID), (BOOT_ID, BOOT_ID), (OLD_BOOT_ID, None)): + with self.subTest(operation=operation, saved_boot=saved_boot, current_boot=current_boot): + vm = make_vm( + self.directory, pid=None, start_time=None, boot_id=saved_boot, + operation=operation, spawn_pending=True, + ) + vm = VmRecord.from_dict(vm.to_dict()) + self.boot_id.return_value = current_boot + self.assertEqual(self.manager.observe(vm), "unknown") + with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"): + self.manager.terminate(vm) + with self.assertRaises(FirecrackerError): + self.manager.start(vm) + self.assertTrue(vm.spawn_pending) + + self.popen.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_prior_boot_proves_a_pending_spawn_gone_despite_stop_or_delete_intent(self) -> None: + for operation in ("stop", "delete"): + with self.subTest(operation=operation): + vm = make_vm( + self.directory, pid=None, start_time=None, boot_id=OLD_BOOT_ID, + operation=operation, spawn_pending=True, + ) + Path(vm.socket).touch() + self.assertEqual(self.manager.observe(vm), "gone") + self.manager.terminate(vm) + self.assertFalse(Path(vm.socket).exists()) + + self.signal_pidfd.assert_not_called() + + def test_known_pid_observation_is_independent_of_spawn_pending(self) -> None: + self.vm.spawn_pending = True + self.vm.operation = "delete" + self.assertEqual(self.manager.observe(self.vm), "alive") + self.stat.return_value = None + self.kill.side_effect = ProcessLookupError + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + + def test_no_pid_spawning_from_a_proven_prior_boot_is_gone(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + self.vm.operation = "spawning" + self.vm.boot_id = OLD_BOOT_ID + Path(self.vm.socket).touch() + + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + + self.assertFalse(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + + def test_no_pid_spawning_cannot_infer_a_reboot_from_an_unreadable_boot_id(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + self.vm.operation = "spawning" + self.vm.boot_id = OLD_BOOT_ID + self.boot_id.return_value = None + self.assertEqual(self.manager.observe(self.vm), "unknown") + + def test_a_clean_unspawned_record_is_gone(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + self.vm.boot_id = None + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + self.signal_pidfd.assert_not_called() + + def test_an_unowned_socket_is_not_assumed_stale(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + Path(self.vm.socket).touch() + + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaises(FirecrackerError): + self.manager.start(self.vm) + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.popen.assert_not_called() + + def test_an_unreadable_unowned_socket_is_unknown(self) -> None: + self.vm.pid = None + self.vm.process_start_time = None + with patch.object(Path, "lstat", side_effect=PermissionError): + self.assertEqual(self.manager.observe(self.vm), "unknown") + + def test_term_exit_is_confirmed_before_unlinking_and_identity_is_retained(self) -> None: + Path(self.vm.socket).touch() + self.exited.side_effect = [False, True] + self.signal_pidfd.side_effect = lambda *_args: self.assertTrue(Path(self.vm.socket).exists()) + + self.manager.terminate(self.vm) + + self.signal_pidfd.assert_called_once_with(PIDFD, signal.SIGTERM) + self.close.assert_called_once_with(PIDFD) + self.assertFalse(Path(self.vm.socket).exists()) + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + self.kill.assert_not_called() + + def test_forced_stop_waits_for_exit_even_with_zero_term_timeout(self) -> None: + Path(self.vm.socket).touch() + self.exited.side_effect = [False, False, False, True] + self.signal_pidfd.side_effect = lambda *_args: self.assertTrue(Path(self.vm.socket).exists()) + + self.manager.terminate(self.vm, timeout=0) + + self.assertEqual( + self.signal_pidfd.call_args_list, + [call(PIDFD, signal.SIGTERM), call(PIDFD, signal.SIGKILL)], + ) + self.assertGreater(self.clock.now, 0) + self.assertFalse(Path(self.vm.socket).exists()) + + def test_unconfirmed_kill_retains_socket_and_identity(self) -> None: + Path(self.vm.socket).touch() + + with self.assertRaisesRegex(FirecrackerError, "could not confirm exit.*SIGKILL"): + self.manager.terminate(self.vm, timeout=0) + + self.assertTrue(Path(self.vm.socket).exists()) + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + self.assertEqual(self.clock.now, 1.0) + self.close.assert_called_once_with(PIDFD) + + def test_signal_errors_preserve_the_socket(self) -> None: + Path(self.vm.socket).touch() + for failing_signal in (signal.SIGTERM, signal.SIGKILL): + with self.subTest(signal=failing_signal): + def fail_signal(_pidfd: int, signal_number: int) -> None: + if signal_number == failing_signal: + raise PermissionError("denied") + + self.signal_pidfd.side_effect = fail_signal + with self.assertRaisesRegex(FirecrackerError, "could not safely stop"): + self.manager.terminate(self.vm, timeout=0) + self.assertTrue(Path(self.vm.socket).exists()) + + def test_signal_lookup_error_still_requires_exit_confirmation(self) -> None: + Path(self.vm.socket).touch() + self.signal_pidfd.side_effect = ProcessLookupError + self.exited.side_effect = [False, True] + + self.manager.terminate(self.vm) + + self.assertFalse(Path(self.vm.socket).exists()) + self.assertEqual(self.exited.call_count, 2) + + def test_signal_lookup_error_does_not_hide_an_unconfirmed_exit(self) -> None: + Path(self.vm.socket).touch() + self.signal_pidfd.side_effect = ProcessLookupError + + with self.assertRaisesRegex(FirecrackerError, "could not confirm exit"): + self.manager.terminate(self.vm, timeout=0) + + self.assertTrue(Path(self.vm.socket).exists()) + + def test_pid_disappearing_before_handle_acquisition_is_safe_to_clean(self) -> None: + Path(self.vm.socket).touch() + self.open_pidfd.side_effect = ProcessLookupError + + self.manager.terminate(self.vm) + + self.assertFalse(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + self.close.assert_not_called() + + def test_unavailable_or_denied_pidfd_does_not_fall_back_to_pid_signals(self) -> None: + Path(self.vm.socket).touch() + for error in (OSError(errno.ENOSYS, "unsupported"), PermissionError("denied")): + with self.subTest(error=error): + self.open_pidfd.side_effect = error + with self.assertRaisesRegex(FirecrackerError, "safely open a pidfd"): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.kill.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_unavailable_pidfd_signaling_does_not_fall_back_to_pid_signals(self) -> None: + Path(self.vm.socket).touch() + self.signal_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported") + + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.kill.assert_not_called() + self.close.assert_called_once_with(PIDFD) + + def test_identity_is_rechecked_after_opening_the_pidfd(self) -> None: + Path(self.vm.socket).touch() + self.stat.side_effect = [("S", "42"), ("S", "99")] + + self.manager.terminate(self.vm) + + self.signal_pidfd.assert_not_called() + self.close.assert_called_once_with(PIDFD) + self.assertFalse(Path(self.vm.socket).exists()) + + def test_identity_becoming_unreadable_after_open_is_not_signaled(self) -> None: + Path(self.vm.socket).touch() + self.stat.side_effect = [("S", "42"), None] + + with self.assertRaisesRegex(FirecrackerError, "identity became ambiguous"): + self.manager.terminate(self.vm) + + self.signal_pidfd.assert_not_called() + self.close.assert_called_once_with(PIDFD) + self.assertTrue(Path(self.vm.socket).exists()) + + def test_an_already_exited_handle_needs_no_signal(self) -> None: + Path(self.vm.socket).touch() + self.exited.return_value = True + + self.manager.terminate(self.vm) + + self.signal_pidfd.assert_not_called() + self.assertFalse(Path(self.vm.socket).exists()) + + def test_poll_failure_retains_the_socket_and_closes_the_handle(self) -> None: + Path(self.vm.socket).touch() + self.exited.side_effect = OSError(errno.EIO, "poll failed") + + with self.assertRaises(FirecrackerError): + self.manager.terminate(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.close.assert_called_once_with(PIDFD) + self.signal_pidfd.assert_not_called() + + def test_socket_unlink_errors_are_not_suppressed(self) -> None: + self.vm.boot_id = OLD_BOOT_ID + with patch.object(Path, "unlink", side_effect=PermissionError("denied")): + with self.assertRaisesRegex(FirecrackerError, "could not remove Firecracker socket"): + self.manager.terminate(self.vm) + + def test_timeout_override_controls_term_grace_not_exit_confirmation(self) -> None: + with patch.object(self.manager, "_wait_for_exit", side_effect=[False, True]) as wait: + self.manager.terminate(self.vm, timeout=0.025) + + self.assertEqual(wait.call_args_list, [call(PIDFD, 0.025), call(PIDFD, 1.0)]) + + def test_invalid_timeouts_are_rejected_before_signaling(self) -> None: + for timeout in (-1, float("nan"), float("inf")): + with self.subTest(timeout=timeout): + with self.assertRaisesRegex(FirecrackerError, "timeout must be"): + self.manager.terminate(self.vm, timeout=timeout) + self.signal_pidfd.assert_not_called() + + def test_observation_reaps_only_owned_children(self) -> None: + self.manager._children[PID] = self.child + self.child.poll.return_value = 0 + self.stat.return_value = None + self.kill.side_effect = ProcessLookupError + + self.assertEqual(self.manager.observe(self.vm), "gone") + + self.child.poll.assert_called_once_with() + self.assertNotIn(PID, self.manager._children) + + +class FirecrackerProcessStartTests(ProcessTestCase): + def setUp(self) -> None: + super().setUp() + self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None) + self.wait = self.enterContext(patch.object(self.manager, "_wait_for_socket")) + + def test_identity_is_in_memory_and_published_before_socket_readiness(self) -> None: + events = [] + self.vm.operation = "boot" + persisted = [replace(self.vm, operation="spawning", boot_id=BOOT_ID, spawn_pending=True).to_dict()] + self.assertEqual(self.manager.observe(VmRecord.from_dict(persisted[0])), "unknown") + + def on_spawn(info: ProcessInfo) -> None: + events.append("spawn") + self.assertEqual(info, ProcessInfo(PID, "42", BOOT_ID)) + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + self.assertTrue(self.vm.spawn_pending) + self.wait.assert_not_called() + self.vm.operation = "configuring" + self.vm.spawn_pending = False + persisted.append(self.vm.to_dict()) + + self.wait.side_effect = lambda *_args: events.append("socket") + + info = self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertEqual(info, ProcessInfo(PID, "42", BOOT_ID)) + self.assertEqual(events, ["spawn", "socket"]) + self.assertEqual(self.vm.operation, "configuring") + self.assertFalse(self.vm.spawn_pending) + self.assertEqual(VmRecord.from_dict(persisted[1]), self.vm) + self.assertTrue(self.popen.call_args.kwargs["start_new_session"]) + self.signal_pidfd.assert_called_once_with(PIDFD, 0) + self.open_pidfd.assert_called_once_with(os.getpid()) + self.assertIsNone(ProcessInfo(PID, "42").boot_id) + + def test_start_clears_pending_after_identity_only_when_no_callback_is_supplied(self) -> None: + def spawn(*_args, **_kwargs): + self.assertTrue(self.vm.spawn_pending) + self.assertEqual(self.vm.operation, "spawning") + self.assertIsNone(self.vm.pid) + return self.child + + self.popen.side_effect = spawn + for on_spawn in (None, Mock()): + with self.subTest(callback=on_spawn is not None): + self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None) + self.wait.side_effect = lambda *_args: self.assertEqual( + self.vm.spawn_pending, on_spawn is not None, + ) + self.manager.start(self.vm, on_spawn=on_spawn) + self.assertEqual(self.vm.spawn_pending, on_spawn is not None) + self.assertEqual(self.manager.observe(self.vm), "alive") + + def test_start_refuses_live_or_ambiguous_existing_identity(self) -> None: + self.vm.pid = PID + self.vm.boot_id = BOOT_ID + Path(self.vm.socket).touch() + for start_time in ("42", None): + with self.subTest(start_time=start_time): + self.vm.process_start_time = start_time + with self.assertRaisesRegex(FirecrackerError, "refusing to start"): + self.manager.start(self.vm) + + self.assertTrue(Path(self.vm.socket).exists()) + self.popen.assert_not_called() + self.open_pidfd.assert_not_called() + + def test_start_replaces_only_a_proven_stale_socket(self) -> None: + self.vm.boot_id = OLD_BOOT_ID + self.vm.operation = "spawning" + Path(self.vm.socket).touch() + + def on_spawn(_info: ProcessInfo) -> None: + self.assertFalse(Path(self.vm.socket).exists()) + + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertEqual(self.vm.boot_id, BOOT_ID) + self.assertEqual(self.vm.pid, PID) + + def test_failing_persistence_callback_cleans_up_without_losing_identity(self) -> None: + failure = RuntimeError("persistence failed") + self.exited.side_effect = [False, True] + self.child.poll.side_effect = [None, None, -signal.SIGTERM] + + def on_spawn(_info: ProcessInfo) -> None: + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + Path(self.vm.socket).touch() + raise failure + + with self.assertRaises(RuntimeError) as raised: + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertIs(raised.exception, failure) + self.wait.assert_not_called() + self.assertFalse(Path(self.vm.socket).exists()) + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + self.assertNotIn(PID, self.manager._children) + self.assertEqual(self.signal_pidfd.call_args_list, [call(PIDFD, 0), call(PIDFD, signal.SIGTERM)]) def test_interrupt_during_startup_terminates_the_child_process(self) -> None: - class FakePopen: - pid = 12345 + self.wait.side_effect = KeyboardInterrupt + self.exited.side_effect = [False, True] - def poll(self) -> int | None: - return None + with self.assertRaises(KeyboardInterrupt): + self.manager.start(self.vm) - with tempfile.TemporaryDirectory() as temporary_directory: - settings = Settings(base=Path(temporary_directory), terminate_timeout_s=0) - manager = FirecrackerProcessManager(settings, popen=lambda *_args, **_kwargs: FakePopen()) - manager._wait_for_socket = lambda *_args: (_ for _ in ()).throw(KeyboardInterrupt) - vm = make_vm(pid=None, start_time=None) - vm.log = str(Path(temporary_directory) / "firecracker.log") - vm.socket = str(Path(temporary_directory) / "firecracker.sock") + self.assertEqual(self.signal_pidfd.call_args_list, [call(PIDFD, 0), call(PIDFD, signal.SIGTERM)]) + self.assertEqual(self.vm.pid, PID) - with patch("uvm.firecracker.process.os.kill") as kill: - with self.assertRaises(KeyboardInterrupt): - manager.start(vm) + def test_incomplete_spawn_cleanup_surfaces_both_failures_and_preserves_resources(self) -> None: + failure = RuntimeError("persistence failed") - self.assertEqual(kill.call_count, 2) + def on_spawn(_info: ProcessInfo) -> None: + Path(self.vm.socket).touch() + raise failure + + with self.assertRaisesRegex(FirecrackerError, "persistence failed.*cleanup incomplete") as raised: + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertIs(raised.exception.__cause__, failure) + self.wait.assert_not_called() + self.assertTrue(Path(self.vm.socket).exists()) + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + + def test_identity_capture_failure_retains_partial_identity_and_reports_incomplete_cleanup(self) -> None: + self.stat.return_value = None + on_spawn = Mock() + + def spawn(*_args, **_kwargs): + Path(self.vm.socket).touch() + return self.child + + self.popen.side_effect = spawn + + with self.assertRaisesRegex(FirecrackerError, "cleanup incomplete.*identity is ambiguous"): + self.manager.start(self.vm, on_spawn=on_spawn) + + on_spawn.assert_not_called() + self.wait.assert_not_called() + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, None, BOOT_ID)) + self.assertTrue(self.vm.spawn_pending) + self.assertTrue(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_called_once_with(PIDFD, 0) + + def test_popen_oserror_restores_prior_operation_and_metadata(self) -> None: + self.popen.side_effect = FileNotFoundError(errno.ENOENT, "missing binary") + for pid, start_time, boot_id, operation, spawn_pending in ( + (None, None, None, None, False), + (None, None, BOOT_ID, "boot", False), + (PID, "42", OLD_BOOT_ID, "boot", False), + (None, None, OLD_BOOT_ID, "spawning", True), + (None, None, OLD_BOOT_ID, "boot", True), + ): + with self.subTest(pid=pid, boot=boot_id, operation=operation): + self.vm = make_vm( + self.directory, pid, start_time, boot_id=boot_id, operation=operation, + spawn_pending=spawn_pending, + ) + previous = self.vm.to_dict() + on_spawn = Mock() + + with self.assertRaisesRegex(FirecrackerError, "could not start Firecracker"): + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertEqual(self.vm.to_dict(), previous) + self.assertEqual(self.manager.observe(self.vm), "gone") + on_spawn.assert_not_called() + self.wait.assert_not_called() + self.assertEqual(self.manager._children, {}) + + def test_uncertain_popen_failure_preserves_the_spawning_marker(self) -> None: + for error in ( + KeyboardInterrupt(), SystemExit(), RuntimeError("uncertain spawn"), + InterruptedError(errno.EINTR, "interrupted spawn"), + ): + with self.subTest(error=type(error).__name__): + self.vm = make_vm( + self.directory, pid=None, start_time=None, boot_id=None, operation="boot", + ) + self.popen.side_effect = error + exception_type = FirecrackerError if isinstance(error, OSError) else type(error) + + with self.assertRaises(exception_type): + self.manager.start(self.vm) + + self.assertEqual(self.vm.operation, "spawning") + self.assertTrue(self.vm.spawn_pending) + self.assertEqual(self.vm.boot_id, BOOT_ID) + self.assertIsNone(self.vm.pid) + self.assertIsNone(self.vm.process_start_time) + self.assertEqual(self.manager.observe(self.vm), "unknown") + + def test_post_spawn_oserror_does_not_restore_the_old_identity(self) -> None: + self.vm.operation = "boot" + self.exited.side_effect = [False, True] + + def on_spawn(_info: ProcessInfo) -> None: + self.vm.operation = "configuring" + raise OSError("persistence failed") + + with self.assertRaisesRegex(FirecrackerError, "persistence failed"): + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertEqual(self.vm.operation, "configuring") + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID)) + self.wait.assert_not_called() + + def test_unsupported_pidfd_prevents_spawn_and_preserves_the_old_record(self) -> None: + self.vm.boot_id = OLD_BOOT_ID + Path(self.vm.socket).touch() + self.open_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported") + + with self.assertRaisesRegex(FirecrackerError, "safe pidfd support is required"): + self.manager.start(self.vm) + + self.popen.assert_not_called() + self.assertTrue(Path(self.vm.socket).exists()) + self.assertEqual(self.vm.boot_id, OLD_BOOT_ID) + self.assertIsNone(self.vm.operation) + + def test_unknown_current_boot_prevents_spawn(self) -> None: + self.boot_id.return_value = None + + with self.assertRaisesRegex(FirecrackerError, "current host boot identity"): + self.manager.start(self.vm) + + self.popen.assert_not_called() + self.open_pidfd.assert_not_called() + + def test_socket_wait_checks_exit_before_socket_presence(self) -> None: + self.child.poll.return_value = 0 + with patch.object(Path, "is_socket", return_value=True) as is_socket: + with self.assertRaisesRegex(FirecrackerError, "exited early"): + FirecrackerProcessManager._wait_for_socket( + self.manager, self.child, Path(self.vm.socket), Path(self.vm.log) + ) + is_socket.assert_not_called() + + def test_socket_wait_does_not_accept_a_regular_file(self) -> None: + Path(self.vm.socket).touch() + with self.assertRaisesRegex(FirecrackerError, "timed out waiting"): + FirecrackerProcessManager._wait_for_socket( + self.manager, self.child, Path(self.vm.socket), Path(self.vm.log) + ) + + def test_socket_timeout_occurs_after_identity_publication_and_confirms_cleanup(self) -> None: + self.wait.side_effect = FirecrackerError("timed out waiting for Firecracker API socket") + self.exited.side_effect = [False, True] + on_spawn = Mock() + + with self.assertRaisesRegex(FirecrackerError, "timed out waiting"): + self.manager.start(self.vm, on_spawn=on_spawn) + + on_spawn.assert_called_once_with(ProcessInfo(PID, "42", BOOT_ID)) + self.assertEqual(self.vm.pid, PID) + + +class FirecrackerLegacyAdoptionTests(ProcessTestCase): + def setUp(self) -> None: + super().setUp() + self.runtime = self.manager._settings.vm_dir(self.vm.id) + self.runtime.mkdir(parents=True, mode=0o700) + self.runtime.parent.chmod(0o755) + self.vm = make_vm(self.runtime, boot_id=None) + self.binary = self.manager._settings.firecracker_binary + self.binary.parent.mkdir(mode=0o755) + self.binary.touch(mode=0o755) + listener = socket.socket(socket.AF_UNIX) + self.addCleanup(listener.close) + listener.bind(self.vm.socket) + + # Production runs as root; preserve real file types/modes in unprivileged tests. + real_lstat = Path.lstat + + def root_lstat(path: Path) -> os.stat_result: + metadata = list(real_lstat(path)) + metadata[4] = 0 + return os.stat_result(metadata) + + self.root_lstat = root_lstat + self.enterContext(patch.object(Path, "lstat", autospec=True, side_effect=root_lstat)) + self.executable = self.enterContext( + patch.object(Path, "readlink", autospec=True, return_value=self.binary) + ) + self.samefile = self.enterContext(patch.object(Path, "samefile", autospec=True, return_value=True)) + self.expected_cmdline = b"\0".join( + (os.fsencode(self.binary), b"--api-sock", os.fsencode(self.vm.socket), b"") + ) + self.cmdline = self.enterContext( + patch.object(Path, "read_bytes", autospec=True, return_value=self.expected_cmdline) + ) + + def test_verified_v1_identity_can_be_persisted_and_explicitly_stopped(self) -> None: + self.vm.status = "running" + legacy = self.vm.to_dict() + for name in ( + "boot_id", "operation", "disk_ready", "runtime_owned", "tap_owned", + "ports", "network", "bridge", "name", "spawn_pending", + ): + legacy.pop(name) + self.vm = VmRecord.from_dict(legacy) + previous = self.vm.to_dict() + self.assertEqual(self.manager.observe(self.vm), "unknown") + with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"): + self.manager.terminate(self.vm) + + self.assertTrue(self.manager.adopt_legacy_identity(self.vm)) + + self.assertEqual(self.vm.to_dict(), previous | {"boot_id": BOOT_ID}) + self.signal_pidfd.assert_not_called() + self.kill.assert_not_called() + self.close.assert_called_once_with(PIDFD) + persisted = VmRecord.from_dict(self.vm.to_dict()) + self.assertEqual(self.manager.observe(persisted), "alive") + self.exited.side_effect = [False, True] + self.manager.terminate(persisted) + self.signal_pidfd.assert_called_once_with(PIDFD, signal.SIGTERM) + self.assertFalse(Path(self.vm.socket).exists()) + + def test_pidfd_pins_the_process_before_any_identity_read_until_final_exit_check(self) -> None: + events = Mock() + for name, boundary in ( + ("open", self.open_pidfd), ("stat", self.stat), ("exe", self.executable), + ("samefile", self.samefile), ("cmdline", self.cmdline), ("boot", self.boot_id), + ("exited", self.exited), ("close", self.close), + ): + events.attach_mock(boundary, name) + self.close.side_effect = lambda _fd: self.assertIsNone(self.vm.boot_id) + + self.assertTrue(self.manager.adopt_legacy_identity(self.vm)) + + proc = Path(f"/proc/{PID}") + self.assertEqual(events.mock_calls, [ + call.open(PID), call.stat(PID), call.exe(proc / "exe"), + call.samefile(proc / "exe", self.binary), call.cmdline(proc / "cmdline"), + call.boot(), call.exited(PIDFD), call.close(PIDFD), + ]) + + def test_only_missing_boot_id_with_a_valid_pid_and_original_token_is_eligible(self) -> None: + for boot_id, pid, start_time in ( + (BOOT_ID, PID, "42"), (OLD_BOOT_ID, PID, "42"), ("", PID, "42"), + (None, None, "42"), (None, 0, "42"), (None, -1, "42"), + (None, 2**32 + PID, "42"), (None, True, "42"), + (None, PID, None), (None, PID, ""), (None, PID, "invalid"), + (None, PID, "-42"), (None, PID, " 42"), (None, PID, "42\0"), + (None, PID, "\u0664\u0662"), (None, PID, 42), + ): + with self.subTest(boot=boot_id, pid=pid, start_time=start_time): + vm = replace(self.vm, boot_id=boot_id, pid=pid, process_start_time=start_time) + previous = vm.to_dict() + self.assertFalse(self.manager.adopt_legacy_identity(vm)) + self.assertEqual(vm.to_dict(), previous) + + self.open_pidfd.assert_not_called() + self.signal_pidfd.assert_not_called() + self.stat.assert_not_called() + + def test_missing_mismatched_or_dead_proc_identity_is_never_adopted(self) -> None: + for process_stat in (None, ("S", "99"), ("Z", "42"), ("X", "42"), ("x", "42")): + with self.subTest(stat=process_stat): + self.stat.return_value = process_stat + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.assertEqual(self.close.call_count, 5) + self.cmdline.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_unavailable_or_denied_pidfd_is_not_replaced_by_a_pid_probe(self) -> None: + for error in (ProcessLookupError(), PermissionError(), OSError(errno.ENOSYS, "unsupported")): + with self.subTest(error=error): + self.open_pidfd.side_effect = error + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.stat.assert_not_called() + self.kill.assert_not_called() + self.close.assert_not_called() + + def test_executable_must_match_both_the_configured_path_and_inode(self) -> None: + for executable, samefile in ( + (Path("/usr/bin/sleep"), True), + (Path(str(self.binary) + " (deleted)"), True), + (Path("firecracker"), True), + (self.binary, False), + ): + with self.subTest(executable=executable, samefile=samefile): + self.executable.return_value = executable + self.samefile.return_value = samefile + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.cmdline.assert_not_called() + self.signal_pidfd.assert_not_called() + + def test_cmdline_must_match_all_three_arguments_and_nul_boundaries_exactly(self) -> None: + binary, api_socket = str(self.binary), self.vm.socket + commands = [ + ["firecracker", "--api-sock", api_socket], + ["/usr/bin/sleep", "--api-sock", api_socket], + [binary, "--api-sock", api_socket + ".other"], + [binary, "--api-sock", str(self.runtime.parent / "other-vm" / "firecracker.sock")], + [binary, "--api-sock", "firecracker.sock"], + [binary, "--api-sock", api_socket, "--extra"], + [binary, f"--api-sock={api_socket}"], + [binary, api_socket, "--api-sock"], + [binary, "--api-sock"], + ["/bin/sh", binary, "--api-sock", api_socket], + ] + invalid_cmdlines = [ + b"\0".join(os.fsencode(argument) for argument in command) + b"\0" + for command in commands + ] + [b"", self.expected_cmdline[:-1], self.expected_cmdline + b"\0"] + for cmdline in invalid_cmdlines: + with self.subTest(cmdline=cmdline): + self.cmdline.return_value = cmdline + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.assertTrue(Path(self.vm.socket).exists()) + self.signal_pidfd.assert_not_called() + + def test_socket_record_must_be_the_exact_protected_path_for_its_vm(self) -> None: + for vm_id, api_socket in ( + (self.vm.id, "firecracker.sock"), + (self.vm.id, str(self.directory / "firecracker.sock")), + (self.vm.id, str(self.runtime) + "/../vm-test/firecracker.sock"), + (self.vm.id, str(self.runtime) + "//firecracker.sock"), + ("another-vm", self.vm.socket), + ("../vm-test", str(self.directory / "vm-test/firecracker.sock")), + (".", str(self.runtime.parent / "firecracker.sock")), + ("", str(self.runtime.parent / "firecracker.sock")), + ): + with self.subTest(vm_id=vm_id, socket=api_socket): + vm = replace(self.vm, id=vm_id, socket=api_socket) + self.assertFalse(self.manager.adopt_legacy_identity(vm)) + self.assertIsNone(vm.boot_id) + + self.cmdline.assert_not_called() + self.assertTrue(Path(self.vm.socket).exists()) + + def test_missing_regular_or_symlink_socket_is_not_adopted(self) -> None: + socket_path = Path(self.vm.socket) + for kind in ("missing", "regular", "symlink"): + with self.subTest(kind=kind): + socket_path.unlink(missing_ok=True) + if kind == "regular": + socket_path.touch() + elif kind == "symlink": + socket_path.symlink_to(self.binary) + + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.cmdline.assert_not_called() + + def test_unprotected_or_symlinked_directories_and_binary_are_not_adopted(self) -> None: + for path, mode, uid in ( + (self.directory, stat.S_IFDIR | 0o777, 0), + (self.runtime.parent, stat.S_IFDIR | 0o775, 0), + (self.runtime, stat.S_IFDIR | 0o755, 0), + (self.binary.parent, stat.S_IFDIR | 0o777, 0), + (self.runtime.parent, stat.S_IFLNK | 0o777, 0), + (self.directory, stat.S_IFDIR | 0o700, 1001), + (self.runtime, stat.S_IFDIR | 0o700, 1001), + (self.binary, stat.S_IFREG | 0o777, 0), + (self.binary, stat.S_IFREG | 0o755, 1001), + (self.binary, stat.S_IFLNK | 0o777, 0), + (Path(self.vm.socket), stat.S_IFSOCK | 0o700, 1001), + ): + with self.subTest(path=path, mode=oct(mode), uid=uid): + def untrusted_lstat(candidate: Path) -> os.stat_result: + metadata = list(self.root_lstat(candidate)) + if candidate == path: + metadata[0], metadata[4] = mode, uid + return os.stat_result(metadata) + + with patch.object(Path, "lstat", new=untrusted_lstat): + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.cmdline.assert_not_called() + + def test_unreadable_process_or_filesystem_evidence_does_not_adopt(self) -> None: + for method in ("lstat", "readlink", "samefile", "read_bytes"): + with self.subTest(method=method): + with patch.object(Path, method, side_effect=PermissionError("denied")): + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.assertEqual(self.close.call_count, 4) + self.signal_pidfd.assert_not_called() + + def test_boot_exit_and_handle_errors_leave_identity_unmodified(self) -> None: + for name, failure in ( + ("boot", patch.object(self.manager, "_boot_id", return_value=None)), + ("exited", patch.object(process_module, "_pidfd_exited", return_value=True)), + ("poll", patch.object(process_module, "_pidfd_exited", side_effect=OSError("poll failed"))), + ("close", patch.object(process_module.os, "close", side_effect=OSError("close failed"))), + ): + with self.subTest(boundary=name), failure: + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + self.assertIsNone(self.vm.boot_id) + + self.signal_pidfd.assert_not_called() + + def test_exit_during_proc_reads_is_detected_on_the_original_pidfd(self) -> None: + def read_and_exit(_path: Path) -> bytes: + self.exited.return_value = True + return self.expected_cmdline + + self.cmdline.side_effect = read_and_exit + + self.assertFalse(self.manager.adopt_legacy_identity(self.vm)) + + self.assertIsNone(self.vm.boot_id) + self.exited.assert_called_once_with(PIDFD) + self.close.assert_called_once_with(PIDFD) + + +class FirecrackerProcessBoundaryTests(unittest.TestCase): + def test_public_host_boot_id_delegates_without_caching(self) -> None: + manager = FirecrackerProcessManager(Settings()) + with patch.object(manager, "_boot_id", side_effect=[BOOT_ID, None]) as boot_id: + self.assertEqual(manager.host_boot_id(), BOOT_ID) + self.assertIsNone(manager.host_boot_id()) + self.assertEqual(boot_id.call_args_list, [call(), call()]) + + def test_proc_stat_handles_spaces_and_parentheses_in_the_command(self) -> None: + stat = f"{PID} (a tricky (name) with ) parentheses) S " + " ".join(["0"] * 18 + ["42", "0"]) + manager = FirecrackerProcessManager(Settings()) + with patch.object(Path, "read_text", return_value=stat): + self.assertEqual(manager._process_stat(PID), ("S", "42")) + self.assertEqual(manager.process_start_time(PID), "42") + + def test_unreadable_or_malformed_proc_stat_has_no_identity(self) -> None: + manager = FirecrackerProcessManager(Settings()) + for stat in ( + "", + "12345 broken", + "12345 (name) S 0", + "12345 (name) ? " + "0 " * 20, + "12345 name) S " + "0 " * 20, + "99999 (name) S " + "0 " * 20, + ): + with self.subTest(stat=stat), patch.object(Path, "read_text", return_value=stat): + self.assertIsNone(manager.process_start_time(PID)) + for error in (PermissionError(), FileNotFoundError(), UnicodeError()): + with self.subTest(error=error), patch.object(Path, "read_text", side_effect=error): + self.assertIsNone(manager.process_start_time(PID)) + + def test_proc_start_time_must_be_numeric(self) -> None: + stat = f"{PID} (name) S " + " ".join(["0"] * 18 + ["invalid"]) + with patch.object(Path, "read_text", return_value=stat): + self.assertIsNone(FirecrackerProcessManager(Settings()).process_start_time(PID)) + + def test_boot_id_is_read_from_the_kernel_and_empty_or_unreadable_is_unknown(self) -> None: + with patch.object(Path, "read_text", return_value=f"{BOOT_ID}\n") as read: + self.assertEqual(FirecrackerProcessManager._boot_id(), BOOT_ID) + read.assert_called_once_with(encoding="ascii") + with patch.object(Path, "read_text", return_value="\n"): + self.assertIsNone(FirecrackerProcessManager._boot_id()) + with patch.object(Path, "read_text", side_effect=PermissionError): + self.assertIsNone(FirecrackerProcessManager._boot_id()) + + def test_native_pidfd_bindings_are_preferred(self) -> None: + with ( + patch.object(os, "pidfd_open", return_value=PIDFD, create=True) as open_pidfd, + patch.object(signal, "pidfd_send_signal", create=True) as send_signal, + patch.object(process_module, "_libc_pidfd") as libc, + ): + self.assertEqual(process_module._open_pidfd(PID), PIDFD) + process_module._signal_pidfd(PIDFD, signal.SIGTERM) + + open_pidfd.assert_called_once_with(PID, 0) + send_signal.assert_called_once_with(PIDFD, signal.SIGTERM) + libc.assert_not_called() + + def test_libc_pidfd_bindings_cover_python_builds_without_native_bindings(self) -> None: + libc = SimpleNamespace(pidfd_open=Mock(return_value=PIDFD), pidfd_send_signal=Mock(return_value=0)) + with ( + patch.object(os, "pidfd_open", None, create=True), + patch.object(signal, "pidfd_send_signal", None, create=True), + patch.object(process_module.ctypes, "CDLL", return_value=libc), + ): + self.assertEqual(process_module._open_pidfd(PID), PIDFD) + process_module._signal_pidfd(PIDFD, signal.SIGTERM) + + libc.pidfd_open.assert_called_once_with(PID, 0) + libc.pidfd_send_signal.assert_called_once_with(PIDFD, signal.SIGTERM, None, 0) + + def test_libc_pidfd_errors_preserve_errno(self) -> None: + libc = SimpleNamespace(pidfd_open=Mock(return_value=-1)) + with ( + patch.object(os, "pidfd_open", None, create=True), + patch.object(process_module.ctypes, "CDLL", return_value=libc), + patch.object(process_module.ctypes, "get_errno", return_value=errno.ESRCH), + ): + with self.assertRaises(ProcessLookupError): + process_module._open_pidfd(PID) + + def test_missing_libc_pidfd_binding_fails_closed(self) -> None: + with ( + patch.object(os, "pidfd_open", None, create=True), + patch.object(process_module.ctypes, "CDLL", return_value=SimpleNamespace()), + ): + with self.assertRaises(OSError) as raised: + process_module._open_pidfd(PID) + self.assertEqual(raised.exception.errno, errno.ENOSYS) + + def test_invalid_pidfd_poll_events_are_errors_not_exit_confirmation(self) -> None: + poller = Mock() + poller.poll.return_value = [(PIDFD, process_module.select.POLLNVAL)] + with patch.object(process_module.select, "poll", return_value=poller): + with self.assertRaises(OSError): + process_module._pidfd_exited(PIDFD) + + +class FirecrackerOwnedChildTests(unittest.TestCase): + def setUp(self) -> None: + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self.directory = Path(temporary_directory.name) + self.children: list[subprocess.Popen] = [] + self.addCleanup(self.cleanup_children) + try: + pidfd = process_module._open_pidfd(os.getpid()) + try: + process_module._signal_pidfd(pidfd, 0) + finally: + os.close(pidfd) + except (OSError, NotImplementedError) as error: + self.skipTest(f"safe pidfd support unavailable: {error}") + self.manager = FirecrackerProcessManager( + Settings(base=self.directory, api_socket_timeout_s=1, terminate_timeout_s=0.1), + popen=self.spawn_child, + ) + self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None) + self.ignore_term = False + self.create_socket = True + + def spawn_child(self, command, **kwargs) -> subprocess.Popen: + script = ( + "import signal, socket, sys, time\n" + "if sys.argv[2] == 'ignore': signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + "if sys.argv[3] == 'socket':\n" + " listener = socket.socket(socket.AF_UNIX)\n" + " listener.bind(sys.argv[1])\n" + " listener.listen()\n" + "time.sleep(10)\n" + ) + child = subprocess.Popen( + [ + sys.executable, "-B", "-c", script, command[-1], + "ignore" if self.ignore_term else "default", + "socket" if self.create_socket else "no-socket", + ], + **kwargs, + ) + self.children.append(child) + return child + + def cleanup_children(self) -> None: + for child in self.children: + if child.poll() is None: + # These are exclusively the harmless children created by this test. + child.kill() + child.wait(timeout=5) + + def test_real_child_start_stop_and_restart_keep_the_retained_disk(self) -> None: + disk = self.directory / "rootfs.ext4" + disk.write_bytes(b"retained guest writes") + published = [] + for _ in range(2): + info = self.manager.start(self.vm, on_spawn=published.append) + self.assertEqual(info.pid, self.children[-1].pid) + self.assertEqual(info.boot_id, self.manager._boot_id()) + self.assertEqual(self.manager.observe(self.vm), "alive") + self.assertTrue(Path(self.vm.socket).is_socket()) + + self.manager.terminate(self.vm) + + self.assertEqual(self.manager.observe(self.vm), "gone") + self.assertIsNotNone(self.children[-1].returncode) + self.assertNotIn(info.pid, self.manager._children) + self.assertFalse(Path(self.vm.socket).exists()) + self.assertEqual(disk.read_bytes(), b"retained guest writes") + self.assertEqual(len(published), 2) + self.assertNotEqual(published[0].pid, published[1].pid) + + def test_real_child_is_reaped_when_persistence_fails(self) -> None: + failure = RuntimeError("save failed") + + def on_spawn(info: ProcessInfo) -> None: + self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (info.pid, info.start_time, info.boot_id)) + raise failure + + with self.assertRaises(RuntimeError) as raised: + self.manager.start(self.vm, on_spawn=on_spawn) + + self.assertIs(raised.exception, failure) + self.assertEqual(self.vm.pid, self.children[0].pid) + self.assertIsNotNone(self.vm.process_start_time) + self.assertIsNotNone(self.children[0].returncode) + self.assertEqual(self.manager.observe(self.vm), "gone") + self.assertFalse(Path(self.vm.socket).exists()) + + def test_real_popen_exec_failure_restores_a_proven_gone_record(self) -> None: + manager = FirecrackerProcessManager(self.manager._settings) + self.vm.operation = "boot" + previous = self.vm.to_dict() + on_spawn = Mock() + + with self.assertRaisesRegex(FirecrackerError, "could not start Firecracker"): + manager.start(self.vm, on_spawn=on_spawn) + + self.assertEqual(self.vm.to_dict(), previous) + self.assertEqual(manager.observe(self.vm), "gone") + self.assertEqual(manager._children, {}) + on_spawn.assert_not_called() + + def test_real_child_ignoring_term_is_killed_and_reaped_before_socket_removal(self) -> None: + self.ignore_term = True + self.manager.start(self.vm) + + self.manager.terminate(self.vm, timeout=0) + + self.assertEqual(self.children[0].returncode, -signal.SIGKILL) + self.assertFalse(Path(self.vm.socket).exists()) + self.assertEqual(self.manager.observe(self.vm), "gone") + + def test_real_socket_timeout_cleans_up_and_reaps_the_child(self) -> None: + self.create_socket = False + self.ignore_term = True + on_spawn = Mock() + + with self.assertRaisesRegex(FirecrackerError, "timed out waiting"): + self.manager.start(self.vm, on_spawn=on_spawn) + + on_spawn.assert_called_once() + self.assertIsNotNone(self.vm.pid) + self.assertEqual(self.children[0].returncode, -signal.SIGKILL) + self.assertEqual(self.manager.observe(self.vm), "gone") + + def test_real_unreaped_zombie_is_gone_without_signaling(self) -> None: + child = subprocess.Popen([sys.executable, "-B", "-c", "pass"]) + self.children.append(child) + self.vm.pid = child.pid + self.vm.process_start_time = self.manager.process_start_time(child.pid) + self.vm.boot_id = self.manager._boot_id() + os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOWAIT) + + with patch.object(process_module, "_signal_pidfd") as send_signal: + self.assertEqual(self.manager.observe(self.vm), "gone") + self.manager.terminate(self.vm) + + send_signal.assert_not_called() + child.wait(timeout=5) diff --git a/tests/test_server.py b/tests/test_server.py index 047bece..686a1ec 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -11,8 +11,10 @@ from unittest.mock import patch from uvm.cli import build_parser, main from uvm.config import Settings -from uvm.errors import ConfigurationError, UvmError -from uvm.server import create_api, run_server +from uvm.domain import PortRule, VmRecord, VmSpec +from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError +from uvm.lifecycle import ListedVm +from uvm.server import _http_status_for, create_api, run_server def fake_application(*, api_token: str | None = None): @@ -117,6 +119,20 @@ class ServerCliTests(unittest.TestCase): Settings(api_token=token) +class ServerErrorTests(unittest.TestCase): + def test_domain_errors_map_to_http_status_codes(self) -> None: + for error, expected in ( + (NotFoundError("unknown guest"), 404), + (ConflictError("ambiguous name"), 409), + (ValidationError("invalid name"), 422), + (ConfigurationError("invalid settings"), 422), + (UvmError("this operation needs root"), 403), + (UvmError("host operation failed"), 500), + ): + with self.subTest(error=error): + self.assertEqual(_http_status_for(error), expected) + + FASTAPI_AVAILABLE = ( importlib.util.find_spec("fastapi") is not None and importlib.util.find_spec("httpx") is not None @@ -125,117 +141,367 @@ FASTAPI_AVAILABLE = ( if FASTAPI_AVAILABLE: from fastapi.testclient import TestClient - from uvm.domain import VmRecord - from uvm.lifecycle import ListedVm - from uvm.server import create_api - class FakeLifecycle: - def __init__(self) -> None: - self.last_spec = None - self.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", - status="running", +class FakeLifecycle: + def __init__(self) -> None: + self.last_spec: VmSpec | None = None + self.observed_status = "running" + self.policy_status = "enforced" + self.vm = VmRecord( + id="vm-test", + name="test-vm", + 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", + password="guest-secret", + status="running", + ports=(PortRule(22),), + operation=None, + disk_ready=True, + boot_id="test-boot", + ) + + def list_vms(self) -> list[ListedVm]: + return [ListedVm( + vm=self.vm, + observed_status=self.observed_status, + policy_status=self.policy_status, + )] + + def inspect(self, identifier: str) -> ListedVm: + if identifier not in {self.vm.id, self.vm.guest_ip, self.vm.name}: + raise NotFoundError(f"VM not found: {identifier}") + return ListedVm( + vm=self.vm, + observed_status=self.observed_status, + policy_status=self.policy_status, + ) + + def create(self, spec: VmSpec) -> VmRecord: + self.last_spec = spec + self.vm.cpu = spec.cpu + self.vm.ram_mib = spec.ram_mib + if spec.guest_ip is not None: + self.vm.guest_ip = str(spec.guest_ip) + self.vm.username = spec.username + self.vm.password = spec.password + self.vm.name = spec.name + self.vm.ports = spec.ports + self.vm.status = "running" + self.vm.operation = None + self.observed_status = "running" + self.policy_status = "enforced" + return self.vm + + def stop(self, identifier: str) -> VmRecord: + self.inspect(identifier) + self.vm.status = "stopped" + self.vm.pid = None + self.vm.operation = None + self.observed_status = "stopped" + self.policy_status = "inactive" + return self.vm + + def destroy(self, identifier: str) -> VmRecord: + self.inspect(identifier) + return self.vm + + +@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests") +class ApiRouterTests(unittest.TestCase): + def setUp(self) -> None: + application = fake_application(api_token="test-token") + self.lifecycle = FakeLifecycle() + application.lifecycle = self.lifecycle + application.installer = SimpleNamespace( + install=lambda **_kwargs: ( + Path("/tmp/firecracker"), + SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")), ) + ) + application.state_store = SimpleNamespace(initialize=lambda: None) + self.headers = {"X-UVM-Token": "test-token"} + self.client = TestClient(create_api(application, host="127.0.0.1")) + self.addCleanup(self.client.close) - def list_vms(self) -> list[ListedVm]: - return [ListedVm(vm=self.vm, observed_status=self.vm.status)] + def test_health_is_available_without_credentials(self) -> None: + response = self.client.get("/health") - def create(self, spec): - self.last_spec = spec - self.vm.username = spec.username - self.vm.password = spec.password - self.vm.status = "running" - return self.vm + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok"}) - def stop(self, _vm_id: str): - self.vm.status = "stopped" - return self.vm + def test_vm_routes_require_and_accept_the_api_token(self) -> None: + for method, path in ( + ("GET", "/vms"), + ("POST", "/vms"), + ("GET", "/vms/test-vm"), + ("POST", "/vms/test-vm/stop"), + ("DELETE", "/vms/test-vm"), + ("POST", "/install"), + ): + for headers in ({}, {"X-UVM-Token": "wrong-token"}): + with self.subTest(method=method, path=path, headers=headers): + response = self.client.request(method, path, headers=headers, json={}) + self.assertEqual(response.status_code, 401) + self.assertIsNone(self.lifecycle.last_spec) + self.assertEqual(self.lifecycle.vm.status, "running") - def destroy(self, _vm_id: str): - return self.vm + authorized = self.client.get("/vms", headers=self.headers) + self.assertEqual(authorized.status_code, 200) + self.assertEqual(authorized.json()[0]["id"], "vm-test") + self.assertNotIn("password", authorized.json()[0]) - @unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed") - class ApiRouterTests(unittest.TestCase): - def setUp(self) -> None: - application = fake_application(api_token="test-token") - self.lifecycle = FakeLifecycle() - application.lifecycle = self.lifecycle - application.installer = SimpleNamespace( - install=lambda **_kwargs: ( - Path("/tmp/firecracker"), - SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")), - ) - ) - application.state_store = SimpleNamespace(initialize=lambda: None) - self.client = TestClient(create_api(application, host="127.0.0.1")) + def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None: + created = self.client.post( + "/vms", + headers=self.headers, + json={ + "cpu": 1, + "ram": "512", + "username": "root", + "password": "api-secret", + }, + ) + detail = self.client.get("/vms/vm-test", headers=self.headers) + stopped = self.client.post("/vms/vm-test/stop", headers=self.headers) + destroyed = self.client.delete("/vms/vm-test", headers=self.headers) + invalid = self.client.post("/vms", headers=self.headers, json={"forse": True}) - def test_health_is_available_without_credentials(self) -> None: - response = self.client.get("/health") + self.assertEqual(created.status_code, 201) + self.assertEqual(created.json()["username"], "root") + self.assertEqual(self.lifecycle.last_spec.password, "api-secret") + for response in (created, detail, stopped, destroyed): + self.assertNotIn("password", response.json()) + self.assertNotIn("api-secret", response.text) + self.assertEqual(detail.status_code, 200) + self.assertEqual(stopped.json()["status"], "stopped") + self.assertEqual(stopped.json()["policy_status"], "inactive") + self.assertEqual(destroyed.json(), {"id": "vm-test", "status": "terminated"}) + self.assertEqual(invalid.status_code, 422) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"status": "ok"}) + def test_vm_create_defaults_guest_credentials_to_root(self) -> None: + response = self.client.post( + "/vms", headers=self.headers, json={"cpu": 1, "ram": "512"}, + ) - def test_vm_routes_require_and_accept_the_api_token(self) -> None: - unauthorized = self.client.get("/vms") - authorized = self.client.get("/vms", headers={"X-UVM-Token": "test-token"}) + self.assertEqual(response.status_code, 201) + self.assertEqual(self.lifecycle.last_spec.username, "root") + self.assertEqual(self.lifecycle.last_spec.password, "root") + self.assertIsNone(self.lifecycle.last_spec.name) + self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),)) - self.assertEqual(unauthorized.status_code, 401) - self.assertEqual(authorized.status_code, 200) - self.assertEqual(authorized.json()[0]["id"], "vm-test") - self.assertNotIn("password", authorized.json()[0]) + def test_create_accepts_valid_names(self) -> None: + for name in (None, "a", "web-1", "a" * 63): + with self.subTest(name=name): + response = self.client.post("/vms", headers=self.headers, json={"name": name}) + self.assertEqual(response.status_code, 201) + self.assertEqual(self.lifecycle.last_spec.name, name) + self.assertEqual(response.json()["name"], name) - def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None: - headers = {"X-UVM-Token": "test-token"} - created = self.client.post( - "/vms", - headers=headers, - json={ - "cpu": 1, - "ram": "512", - "username": "root", - "password": "api-secret", - }, - ) - detail = self.client.get("/vms/vm-test", headers=headers) - stopped = self.client.post("/vms/vm-test/stop", headers=headers) - destroyed = self.client.delete("/vms/vm-test", headers=headers) - invalid = self.client.post("/vms", headers=headers, json={"forse": True}) + def test_create_rejects_invalid_names_before_lifecycle_calls(self) -> None: + for name in ("", "-web", "9web", "Web", "web_name", "web.name", "two words", "a" * 64, 1, True): + with self.subTest(name=name): + response = self.client.post("/vms", headers=self.headers, json={"name": name}) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.lifecycle.last_spec) - self.assertEqual(created.status_code, 201) - self.assertEqual(created.json()["username"], "root") - self.assertNotIn("password", created.json()) - self.assertEqual(self.lifecycle.last_spec.password, "api-secret") - self.assertEqual(detail.status_code, 200) - self.assertEqual(stopped.json()["status"], "stopped") - self.assertEqual(destroyed.json()["status"], "terminated") - self.assertEqual(invalid.status_code, 422) + def test_create_normalizes_private_ports_and_default_ssh(self) -> None: + cases = ( + ({}, (PortRule(22),)), + ({"ports": None}, (PortRule(22),)), + ({"ports": []}, (PortRule(22),)), + ( + {"ports": [{"port": 443, "protocol": "HTTPS"}, {"port": 80, "protocol": "HTTP"}]}, + (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS")), + ), + ( + {"ports": [{"port": 80}, {"port": 22}, {"port": 80, "protocol": "TCP"}]}, + (PortRule(22), PortRule(80)), + ), + ({"ports": [{"port": 22, "protocol": "HTTP"}]}, (PortRule(22, "HTTP"),)), + ({"ports": [{"port": 65535}, {"port": 1}]}, (PortRule(1), PortRule(22), PortRule(65535))), + ({"no_default_ports": True}, ()), + ({"no_default_ports": True, "ports": None}, ()), + ({"no_default_ports": True, "ports": []}, ()), + ( + {"no_default_ports": True, "ports": [{"port": 443, "protocol": "HTTPS"}]}, + (PortRule(443, "HTTPS"),), + ), + ({"no_default_ports": True, "ports": [{"port": 22}]}, (PortRule(22),)), + ) + for payload, expected in cases: + with self.subTest(payload=payload): + response = self.client.post("/vms", headers=self.headers, json=payload) + self.assertEqual(response.status_code, 201, response.text) + self.assertEqual(self.lifecycle.last_spec.ports, expected) + self.assertEqual(response.json()["ports"], [rule.to_dict() for rule in expected]) + self.assertEqual(response.json()["policy_status"], "enforced") - def test_vm_create_defaults_guest_credentials_to_root(self) -> None: - response = self.client.post( - "/vms", - headers={"X-UVM-Token": "test-token"}, - json={"cpu": 1, "ram": "512"}, - ) + def test_create_rejects_invalid_ports_before_lifecycle_calls(self) -> None: + invalid_rules = [ + {"port": value} + for value in (0, -1, 65536, True, False, 22.0, 22.5, "22", None, {}, []) + ] + [ + {"port": 80, "protocol": value} + for value in ("UDP", "http", "HTTP ", "", None, True, 1) + ] + [ + {}, + {"protocol": "TCP"}, + {"port": 80, "public_port": 8080}, + {"port": 80, "host_port": 8080}, + ] + payloads = [{"ports": [rule]} for rule in invalid_rules] + [ + {"ports": value} for value in ("22:TCP", {}, [22], ["22:TCP"], [None]) + ] + [ + {"ports": [{"port": 80}, {"port": 80, "protocol": "HTTP"}]}, + {"ports": [{"port": 443, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]}, + {"public_ports": [80]}, + ] + [ + {"no_default_ports": value} for value in (None, 0, 1, "true", "false") + ] + for payload in payloads: + with self.subTest(payload=payload): + response = self.client.post("/vms", headers=self.headers, json=payload) + self.assertEqual(response.status_code, 422, response.text) + self.assertIsNone(self.lifecycle.last_spec) - self.assertEqual(response.status_code, 201) - self.assertEqual(self.lifecycle.last_spec.username, "root") - self.assertEqual(self.lifecycle.last_spec.password, "root") + def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None: + expected_fields = { + "id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username", + "status", "observed_status", "ports", "policy_status", "operation", "pid", + "created_at", "updated_at", "last_error", + } + self.lifecycle.vm.operation = "ports" + self.lifecycle.observed_status = "dead" + for ports, policy_status in ( + (None, "unmanaged"), + ((), "enforced"), + ((PortRule(443, "HTTPS"),), "missing"), + ((PortRule(22),), "unknown"), + ((PortRule(22),), "drift"), + ): + with self.subTest(ports=ports, policy_status=policy_status): + self.lifecycle.vm.ports = ports + self.lifecycle.policy_status = policy_status + listed = self.client.get("/vms", headers=self.headers) + detail = self.client.get("/vms/test-vm", headers=self.headers) + self.assertEqual(listed.status_code, 200) + self.assertEqual(detail.status_code, 200) + self.assertIsInstance(listed.json(), list) + self.assertEqual(listed.json(), [detail.json()]) + body = detail.json() + self.assertEqual(set(body), expected_fields) + self.assertEqual(body["name"], "test-vm") + self.assertEqual(body["status"], "running") + self.assertEqual(body["observed_status"], "dead") + self.assertEqual(body["policy_status"], policy_status) + self.assertEqual(body["operation"], "ports") + self.assertEqual(body["ports"], None if ports is None else [p.to_dict() for p in ports]) + self.assertNotIn("guest-secret", detail.text) - def test_install_route_uses_the_existing_installer(self) -> None: - response = self.client.post( - "/install", - headers={"X-UVM-Token": "test-token"}, - json={"force": True}, - ) + def test_get_delegates_id_ip_and_name_resolution_to_inspect(self) -> None: + with ( + patch.object(self.lifecycle, "inspect", wraps=self.lifecycle.inspect) as inspect, + patch.object(self.lifecycle, "list_vms", side_effect=AssertionError("must use inspect")), + ): + for identifier in ("vm-test", "10.42.0.2", "test-vm"): + with self.subTest(identifier=identifier): + response = self.client.get(f"/vms/{identifier}", headers=self.headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], "vm-test") + inspect.assert_called_with(identifier) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["firecracker"], "/tmp/firecracker") + def test_stop_and_destroy_pass_identifiers_to_lifecycle(self) -> None: + with ( + patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop, + patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy, + ): + for identifier in ("vm-test", "10.42.0.2", "test-vm"): + with self.subTest(identifier=identifier): + stopped = self.client.post(f"/vms/{identifier}/stop", headers=self.headers) + self.assertEqual(stopped.status_code, 200) + self.assertEqual(stopped.json()["id"], "vm-test") + self.assertEqual(stopped.json()["policy_status"], "inactive") + self.assertEqual(stopped.json()["ports"], [{"port": 22, "protocol": "TCP"}]) + stop.assert_called_with(identifier) + destroyed = self.client.delete(f"/vms/{identifier}", headers=self.headers) + self.assertEqual(destroyed.status_code, 200) + self.assertEqual(destroyed.json()["id"], "vm-test") + destroy.assert_called_with(identifier) + + def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None: + for ports, status, operation, policy_status in ( + (None, "running", None, "unmanaged"), + ((), "running", None, "enforced"), + ((PortRule(22),), "failed", None, "unknown"), + ((PortRule(22),), "starting", "create", "unknown"), + ((PortRule(22),), "running", "ports", "unknown"), + ): + with self.subTest(ports=ports, status=status, operation=operation): + self.lifecycle.vm.ports = ports + self.lifecycle.vm.status = status + self.lifecycle.vm.operation = operation + with patch.object(self.lifecycle, "create", return_value=self.lifecycle.vm): + response = self.client.post("/vms", headers=self.headers, json={}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["policy_status"], policy_status) + + def test_lifecycle_errors_use_typed_status_codes(self) -> None: + for method, path, operation in ( + ("GET", "/vms", "list_vms"), + ("GET", "/vms/test-vm", "inspect"), + ("POST", "/vms", "create"), + ("POST", "/vms/test-vm/stop", "stop"), + ("DELETE", "/vms/test-vm", "destroy"), + ): + for error, expected in ( + (NotFoundError("unknown guest"), 404), + (ConflictError("ambiguous name"), 409), + (ValidationError("invalid request"), 422), + (ConfigurationError("invalid settings"), 422), + (UvmError("this operation needs root"), 403), + (UvmError("host operation failed"), 500), + ): + with self.subTest(operation=operation, error=error): + with patch.object(self.lifecycle, operation, side_effect=error): + response = self.client.request(method, path, headers=self.headers, json={}) + self.assertEqual(response.status_code, expected) + self.assertEqual(response.json(), {"error": {"message": str(error)}}) + + def test_missing_vm_returns_not_found(self) -> None: + response = self.client.get("/vms/missing", headers=self.headers) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}}) + + def test_api_schema_keeps_only_existing_operations(self) -> None: + schema = self.client.get("/openapi.json").json() + + self.assertEqual( + {(path, method) for path, methods in schema["paths"].items() for method in methods}, + { + ("/health", "get"), + ("/install", "post"), + ("/vms", "get"), + ("/vms", "post"), + ("/vms/{identifier}", "get"), + ("/vms/{vm_id}/stop", "post"), + ("/vms/{vm_id}", "delete"), + }, + ) + self.assertEqual( + set(schema["components"]["schemas"]["PortRuleModel"]["properties"]), + {"port", "protocol"}, + ) + + def test_install_route_uses_the_existing_installer(self) -> None: + response = self.client.post("/install", headers=self.headers, json={"force": True}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["firecracker"], "/tmp/firecracker") diff --git a/tests/test_state.py b/tests/test_state.py index bdeb7d2..6ab3df7 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -9,8 +9,8 @@ from pathlib import Path from unittest.mock import patch from uvm.config import Settings -from uvm.domain import VmRecord -from uvm.errors import StateError +from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmSpec, new_vm_id +from uvm.errors import ConflictError, NotFoundError, StateError from uvm.state import StateStore @@ -61,26 +61,103 @@ class StateStoreTests(unittest.TestCase): def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None: self.base.mkdir(parents=True) - legacy = {"vms": {"vm-test": make_vm().to_dict()}} - legacy["vms"]["vm-test"].pop("disk") - legacy["vms"]["vm-test"].pop("username") - legacy["vms"]["vm-test"].pop("password") - legacy["vms"]["vm-test"].pop("updated_at") - self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8") + for version in (None, 1): + with self.subTest(version=version): + raw_vm = make_vm().to_dict() + for field in ( + "disk", "username", "password", "updated_at", "name", "ports", + "disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id", + "spawn_pending", "network", "bridge", + ): + raw_vm.pop(field) + legacy = {"vms": {"vm-test": raw_vm}} + if version is not None: + legacy["schema_version"] = version + self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8") - loaded = self.store.load() - self.assertEqual(loaded.vms["vm-test"].disk, "") - self.assertEqual(loaded.vms["vm-test"].username, "root") - self.assertIsNone(loaded.vms["vm-test"].password) - self.assertEqual(loaded.next_mac_index, 2) + loaded = self.store.load() + vm = loaded.vms["vm-test"] + self.assertEqual(loaded.schema_version, 2) + self.assertEqual(vm.disk, "") + self.assertEqual(vm.username, "root") + self.assertIsNone(vm.password) + self.assertIsNone(vm.name) + self.assertIsNone(vm.ports) + self.assertIsNone(vm.network) + self.assertIsNone(vm.bridge) + self.assertFalse(vm.disk_ready) + self.assertFalse(vm.runtime_owned) + self.assertFalse(vm.tap_owned) + self.assertFalse(vm.spawn_pending) + self.assertEqual(vm.updated_at, vm.created_at) + self.assertEqual(loaded.next_mac_index, 2) + self.assertEqual( + json.loads(self.settings.state_path.read_text(encoding="utf-8")), legacy + ) + with self.store.transaction() as state: + state.vms["vm-test"].status = "stopped" + + persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8")) + self.assertEqual(persisted["schema_version"], 2) + self.assertEqual(persisted["next_mac_index"], 2) + self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped") + self.assertIsNone(persisted["vms"]["vm-test"]["ports"]) + + def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None: + for ports in (None, (), (PortRule(22), PortRule(8080, "http"))): + with self.subTest(ports=ports): + vm = replace( + make_vm(), name="app-server", ports=ports, disk_ready=True, + runtime_owned=True, tap_owned=True, operation="configuring", + boot_id="host-boot-id", spawn_pending=True, pid=123, + process_start_time="456789", network="10.42.0.0/24", bridge="uvm0", + last_error="interrupted boot", + ) + with self.store.transaction() as state: + state.vms[vm.id] = vm + + loaded = self.store.load() + self.assertEqual(loaded.schema_version, 2) + self.assertEqual(loaded.vms[vm.id], vm) + raw = json.loads(self.settings.state_path.read_text(encoding="utf-8")) + self.assertEqual(raw["schema_version"], 2) + self.assertEqual( + raw["vms"][vm.id]["ports"], + None if ports is None else [port.to_dict() for port in ports], + ) + for field in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"): + self.assertIs(raw["vms"][vm.id][field], True) + + def test_lookup_requires_full_case_sensitive_ids_names_or_ips(self) -> None: + vm = replace(make_vm("vm-0123456789abcdef0123456789abcdef"), name="app-server") with self.store.transaction() as state: - state.vms["vm-test"].status = "stopped" + state.vms[vm.id] = vm - persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8")) - self.assertEqual(persisted["schema_version"], 1) - self.assertEqual(persisted["next_mac_index"], 2) - self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped") + for identifier in (vm.id, vm.name, vm.guest_ip): + with self.subTest(identifier=identifier): + self.assertEqual(self.store.find_by_id_or_ip(identifier), vm) + self.assertEqual(self.store.get(vm.id), vm) + for identifier in ( + "", vm.id[:15], vm.id.upper(), "app", "APP-SERVER", " app-server ", "10.42.0", + ): + with self.subTest(identifier=identifier), self.assertRaises(NotFoundError): + self.store.find_by_id_or_ip(identifier) + for identifier in (vm.name, vm.guest_ip): + with self.subTest(get_identifier=identifier), self.assertRaises(NotFoundError): + self.store.get(identifier) + + def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None: + self.base.mkdir(parents=True) + for version in (0, 1, 2): + with self.subTest(version=version): + raw = { + "schema_version": version, + "vms": {"vm-wrong-key": make_vm("vm-record-id").to_dict()}, + } + self.settings.state_path.write_text(json.dumps(raw), encoding="utf-8") + with self.assertRaisesRegex(StateError, "ID.*registry key"): + self.store.load() def test_initialize_protects_registry_credentials(self) -> None: self.base.mkdir(parents=True) @@ -110,3 +187,135 @@ class StateStoreTests(unittest.TestCase): self.assertRaisesRegex(StateError, "Run this command with sudo"), ): self.store.load() + + +class DomainStateTests(unittest.TestCase): + def test_new_specs_default_to_ssh_without_overriding_an_explicit_empty_policy(self) -> None: + self.assertEqual(VmSpec(cpu=1, ram_mib=512).ports, (PortRule(22),)) + self.assertEqual(VmSpec(cpu=1, ram_mib=512, ports=()).ports, ()) + self.assertIsNone(make_vm().ports) + + def test_port_rule_serialization_normalizes_labels_and_defaults_to_tcp(self) -> None: + for port, protocol in ((1, "tcp"), (8080, "hTtP"), (65535, "https")): + with self.subTest(port=port, protocol=protocol): + rule = PortRule.from_dict({"port": port, "protocol": protocol}) + self.assertEqual(rule.to_dict(), {"port": port, "protocol": protocol.upper()}) + self.assertEqual(PortRule.from_dict(rule.to_dict()), rule) + self.assertEqual(PortRule.from_dict({"port": 22}), PortRule(22, "TCP")) + + def test_port_rule_deserialization_rejects_invalid_types_fields_and_values(self) -> None: + invalid = ( + None, True, 22, "22", [], (), {}, {"protocol": "TCP"}, + {"port": True}, {"port": False}, {"port": None}, {"port": "22"}, + {"port": 22.0}, {"port": 0}, {"port": 65536}, + {"port": 22, "protocol": None}, {"port": 22, "protocol": True}, + {"port": 22, "protocol": "UDP"}, {"port": 22, "protocol": " TCP"}, + {"port": 22, "type": "TCP"}, {"port": 22, "protocol": "TCP", "extra": 1}, + ) + for value in invalid: + with self.subTest(value=value), self.assertRaises(StateError): + PortRule.from_dict(value) + + def test_persisted_ports_must_be_a_json_list_or_legacy_null(self) -> None: + for ports in (True, False, 22, "22", {}, (), (PortRule(22),)): + with self.subTest(ports=ports), self.assertRaisesRegex(StateError, "ports"): + VmRecord.from_dict({**make_vm().to_dict(), "ports": ports}) + for ports in ([22], [None], ["22:TCP"], [{"port": True}]): + with self.subTest(ports=ports), self.assertRaises(StateError): + VmRecord.from_dict({**make_vm().to_dict(), "ports": ports}) + + def test_persisted_duplicate_and_conflicting_tcp_rules_are_rejected(self) -> None: + for labels in (("TCP", "TCP"), ("tcp", "TCP"), ("TCP", "HTTP"), ("HTTP", "HTTPS")): + with self.subTest(labels=labels): + raw = make_vm().to_dict() + raw["ports"] = [{"port": 80, "protocol": label} for label in labels] + with self.assertRaisesRegex(StateError, "duplicate TCP port"): + VmRecord.from_dict(raw) + + def test_ownership_and_spawn_pending_fields_require_real_booleans(self) -> None: + for field in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"): + for value in (None, 0, 1, 0.0, 1.0, "false", "true", [], {}): + with self.subTest(field=field, value=value): + raw = {**make_vm().to_dict(), field: value} + with self.assertRaisesRegex(StateError, f"{field} must be a boolean"): + VmRecord.from_dict(raw) + + def test_explicit_recovery_flags_override_legacy_inference(self) -> None: + fields = ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending") + for flag in (False, True): + for operation in (None, "spawning", "configuring"): + with self.subTest(flag=flag, operation=operation): + raw = replace(make_vm(), status="running", pid=123, operation=operation).to_dict() + raw.update(dict.fromkeys(fields, flag)) + vm = VmRecord.from_dict(raw) + for field in fields: + self.assertIs(getattr(vm, field), flag) + self.assertIs(vm.to_dict()[field], flag) + + def test_legacy_ownership_is_inferred_from_disk_status_and_pid(self) -> None: + cases = ( + ("running", "/tmp/rootfs.ext4", 123, (True, True, True)), + ("stopped", "/tmp/rootfs.ext4", None, (True, True, False)), + ("error", "/tmp/rootfs.ext4", 123, (False, True, True)), + ("starting", "/tmp/rootfs.ext4", None, (False, True, False)), + ("running", "", 123, (False, False, True)), + ("unknown", "", None, (False, False, False)), + ) + for status, disk, pid, expected in cases: + with self.subTest(status=status, disk=disk, pid=pid): + raw = replace(make_vm(), status=status, disk=disk, pid=pid).to_dict() + for field in ("disk_ready", "runtime_owned", "tap_owned", "ports"): + raw.pop(field) + vm = VmRecord.from_dict(raw) + self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected) + self.assertIsNone(vm.ports) + + def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None: + for operation in (None, "spawning", "configuring", "stopping", "policy"): + with self.subTest(operation=operation): + raw = replace(make_vm(), operation=operation).to_dict() + raw.pop("spawn_pending") + vm = VmRecord.from_dict(raw) + self.assertIs(vm.spawn_pending, operation == "spawning") + self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm) + + def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None: + self.assertEqual(State().schema_version, 2) + with self.assertRaisesRegex(StateError, "newer than"): + State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}}) + + def test_generated_ids_have_the_reserved_uuid_format(self) -> None: + first, second = new_vm_id(), new_vm_id() + self.assertNotEqual(first, second) + for vm_id in (first, second): + self.assertRegex(vm_id, r"\Avm-[0-9a-f]{32}\Z") + + def test_exact_generated_id_wins_over_a_legacy_name_collision(self) -> None: + vm = make_vm("vm-" + "a" * 32) + other = replace(make_vm("vm-" + "b" * 32), name=vm.id, guest_ip="10.42.0.3") + state = State(vms={other.id: other, vm.id: vm}) + self.assertIs(StateStore.resolve(state, vm.id), vm) + del state.vms[vm.id] + with self.assertRaises(NotFoundError): + StateStore.resolve(state, vm.id) + + def test_ambiguous_names_ips_and_legacy_ids_require_an_exact_id(self) -> None: + cases = ( + ("vm-" + "a" * 32, "app", "app", "10.42.0.3", "app"), + ("vm-" + "a" * 32, "app", "other", "10.42.0.2", "10.42.0.2"), + ("vm-" + "a" * 32, "app", "10.42.0.2", "10.42.0.3", "10.42.0.2"), + ("vm-legacy", "app", "vm-legacy", "10.42.0.3", "vm-legacy"), + ) + for vm_id, name, other_name, other_ip, identifier in cases: + with self.subTest(identifier=identifier, other_name=other_name): + vm = replace(make_vm(vm_id), name=name) + other = replace(make_vm("vm-" + "b" * 32), name=other_name, guest_ip=other_ip) + for records in ((vm, other), (other, vm)): + state = State(vms={record.id: record for record in records}) + with self.assertRaisesRegex(ConflictError, "ambiguous.*exact VM ID"): + StateStore.resolve(state, identifier) + self.assertIs(StateStore.resolve(state, other.id), other) + + def test_multiple_matching_fields_on_one_vm_are_not_ambiguous(self) -> None: + vm = replace(make_vm("vm-legacy"), name="vm-legacy") + self.assertIs(StateStore.resolve(State(vms={vm.id: vm}), "vm-legacy"), vm) diff --git a/tests/test_system.py b/tests/test_system.py index d646e0f..18eab8c 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -1,5 +1,7 @@ from __future__ import annotations +import io +import subprocess import unittest from types import SimpleNamespace from unittest.mock import patch @@ -9,9 +11,41 @@ from uvm.system import CommandRunner class CommandRunnerTests(unittest.TestCase): + def test_uncaptured_child_stdout_is_redirected_to_current_stderr(self) -> None: + stderr = io.StringIO() + completed = SimpleNamespace(returncode=0, stdout=None, stderr=None) + + with ( + patch("uvm.system.sys.stderr", stderr), + patch("uvm.system.subprocess.run", return_value=completed) as run, + ): + result = CommandRunner(emit=None).run(("host-tool", "--check"), timeout=2.5) + + run.assert_called_once_with( + ("host-tool", "--check"), check=False, text=True, input=None, + stdout=stderr, stderr=None, timeout=2.5, + ) + self.assertEqual(result.args, ("host-tool", "--check")) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, "") + + def test_captured_stdout_and_stderr_still_use_separate_pipes(self) -> None: + completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="diagnostic\n") + + with patch("uvm.system.subprocess.run", return_value=completed) as run: + result = CommandRunner(emit=None).run(("host-tool",), capture=True) + + self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE) + self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE) + self.assertEqual(result.stdout, "result\n") + self.assertEqual(result.stderr, "diagnostic\n") + def test_passes_sensitive_input_over_stdin_without_logging_it(self) -> None: emitted: list[str] = [] - completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="") + completed = SimpleNamespace( + returncode=0, stdout="result secret-value\n", stderr="warning secret-value\n", + ) with patch("uvm.system.subprocess.run", return_value=completed) as run: result = CommandRunner(emit=emitted.append).run( @@ -21,26 +55,45 @@ class CommandRunnerTests(unittest.TestCase): sensitive=True, ) - self.assertEqual(result.stdout, "result\n") + self.assertEqual(result.stdout, "result secret-value\n") + self.assertEqual(result.stderr, "warning secret-value\n") + self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE) + self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE) self.assertEqual(run.call_args.kwargs["input"], "secret-value\n") - self.assertNotIn("secret-value", emitted[0]) + self.assertEqual(emitted, ["+ credential-tool --stdin"]) def test_redacts_sensitive_command_output_from_errors(self) -> None: + for stdout, stderr in ( + ("failure mentioning secret-value", ""), + ("", "failure mentioning secret-value"), + ("secret-value", "failure mentioning secret-value"), + ): + with self.subTest(stdout=stdout, stderr=stderr): + completed = SimpleNamespace(returncode=1, stdout=stdout, stderr=stderr) + emitted: list[str] = [] + with ( + patch("uvm.system.subprocess.run", return_value=completed), + self.assertRaises(CommandError) as raised, + ): + CommandRunner(emit=emitted.append).run( + ("credential-tool", "--stdin"), capture=True, + input_text="secret-value\n", sensitive=True, + ) + + self.assertNotIn("secret-value", str(raised.exception)) + self.assertEqual(emitted, ["+ credential-tool --stdin"]) + + def test_unchecked_sensitive_failure_preserves_captured_output_for_the_caller(self) -> None: completed = SimpleNamespace( - returncode=1, - stdout="", - stderr="failure mentioning secret-value", + returncode=1, stdout="secret-value\n", stderr="failure mentioning secret-value", ) - with ( - patch("uvm.system.subprocess.run", return_value=completed), - self.assertRaises(CommandError) as raised, - ): - CommandRunner(emit=None).run( - ("credential-tool", "--stdin"), - capture=True, - input_text="secret-value\n", - sensitive=True, + with patch("uvm.system.subprocess.run", return_value=completed): + result = CommandRunner(emit=None).run( + ("credential-tool", "--stdin"), check=False, capture=True, + input_text="secret-value\n", sensitive=True, ) - self.assertNotIn("secret-value", str(raised.exception)) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, completed.stdout) + self.assertEqual(result.stderr, completed.stderr) diff --git a/tests/test_validation.py b/tests/test_validation.py index e7bf8c5..197eb06 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2,8 +2,11 @@ from __future__ import annotations import unittest +from uvm.domain import PortRule from uvm.errors import ValidationError -from uvm.validation import parse_cpu, parse_password, parse_ram, parse_username +from uvm.validation import ( + parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_timeout, parse_username, +) class ValidationTests(unittest.TestCase): @@ -43,3 +46,104 @@ class ValidationTests(unittest.TestCase): with self.subTest(value=value): with self.assertRaises(ValidationError): parse_password(value) + + def test_names_accept_full_lowercase_names_up_to_63_characters(self) -> None: + for name in ("a", "app-server", "worker2", "a" * 63, "vm-test", "vm-" + "g" * 32): + with self.subTest(name=name): + self.assertEqual(parse_name(name), name) + + def test_names_reject_invalid_format_case_and_length(self) -> None: + for name in ( + "", "App", "APP", "9app", "-app", "app_name", "app.name", "app/name", + " app", "app ", "app\n", "a" * 64, "caf\u00e9", "app;id", + ): + with self.subTest(name=name), self.assertRaises(ValidationError): + parse_name(name) + + def test_names_cannot_impersonate_a_generated_vm_id(self) -> None: + for suffix in ("0" * 32, "0123456789abcdef" * 2, "f" * 32): + with self.subTest(suffix=suffix): + with self.assertRaisesRegex(ValidationError, "impersonate generated VM IDs"): + parse_name("vm-" + suffix) + for suffix in ("f" * 31, "f" * 33): + self.assertEqual(parse_name("vm-" + suffix), "vm-" + suffix) + + def test_ports_accept_repeated_comma_lists_whitespace_and_case_insensitive_labels(self) -> None: + values = ("443:https,22", " 80:HtTp ,\t8080:tcp ", "65535, 1") + expected = ( + PortRule(1), PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"), + PortRule(8080), PortRule(65535), + ) + self.assertEqual(parse_ports(values), expected) + self.assertEqual(parse_ports(list(values)), expected) + + def test_bare_ports_are_tcp_and_empty_sequences_do_not_add_defaults(self) -> None: + self.assertEqual(parse_ports(("80,443",)), (PortRule(80, "TCP"), PortRule(443, "TCP"))) + self.assertEqual(parse_ports([]), ()) + self.assertEqual(parse_ports(()), ()) + + def test_identical_port_labels_are_deduplicated_across_options_and_comma_lists(self) -> None: + self.assertEqual( + parse_ports(("22", "22:tcp", "80:HTTP,80:hTtP", "443:HTTPS", "443:https")), + (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS")), + ) + + def test_conflicting_tcp_service_labels_are_rejected(self) -> None: + for values in ( + ("80", "80:HTTP"), ("80:HTTP,80:TCP",), + ("80:HTTP", "80:HTTPS"), ("80:https,80:http",), + ): + with self.subTest(values=values): + with self.assertRaisesRegex(ValidationError, "conflicting labels for TCP port 80"): + parse_ports(values) + + def test_ports_require_strict_port_colon_label_syntax(self) -> None: + for value in ( + "", ",", "22,", ",22", "22,,80", "22, ,80", "TCP", "HTTP", "HTTPS", + "TCP:80", "80/tcp", ":HTTP", "80:", "80::HTTP", "80 :HTTP", "80: HTTP", + "80:HTTP S", "80:UDP", "80-81", "80.0", "+80", "0x50", "22 80", "80;id", + ): + with self.subTest(value=value), self.assertRaises(ValidationError): + parse_ports((value,)) + + def test_parsed_ports_reject_out_of_range_numbers_and_non_integer_text(self) -> None: + for value in ("-1", "0", "65536", "999999999999", "True", "False", "None", "1.5"): + with self.subTest(value=value), self.assertRaises(ValidationError): + parse_ports((value,)) + + def test_ports_reject_scalar_strings_instead_of_splitting_them_into_digits(self) -> None: + for values in ("22", "", b"22"): + with self.subTest(values=values), self.assertRaises(ValidationError): + parse_ports(values) + + def test_ports_require_a_sequence_not_a_scalar_mapping_or_set(self) -> None: + for values in (None, True, 22, {"22": "HTTP"}, {"22"}): + with self.subTest(values=values), self.assertRaises(ValidationError): + parse_ports(values) + + def test_ports_require_string_sequence_members(self) -> None: + for value in (None, True, False, 22, 22.0, b"22", [], {}, PortRule(22)): + with self.subTest(value=value), self.assertRaises(ValidationError): + parse_ports(("80:HTTP", value)) + + def test_port_rules_require_real_integers_within_tcp_port_bounds(self) -> None: + self.assertEqual(PortRule(1).protocol, "TCP") + self.assertEqual(PortRule(65535, "https").protocol, "HTTPS") + for port in (None, True, False, 0, -1, 65536, 22.0, "22", b"22", [], {}): + with self.subTest(port=port), self.assertRaises(ValidationError): + PortRule(port) + + def test_port_rule_labels_are_normalized_but_not_coerced_or_trimmed(self) -> None: + self.assertEqual(PortRule(8080, "hTtP"), PortRule(8080, "HTTP")) + for protocol in (None, True, 1, "", "UDP", "SCTP", " TCP", "TCP ", "tcp\n", [], {}): + with self.subTest(protocol=protocol), self.assertRaises(ValidationError): + PortRule(22, protocol) + + def test_timeout_accepts_zero_and_fractional_seconds(self) -> None: + self.assertEqual(parse_timeout("0"), 0) + self.assertEqual(parse_timeout("0.5"), 0.5) + + def test_timeout_rejects_negative_non_finite_or_invalid_values(self) -> None: + for value in ("-1", "nan", "inf", "-inf", "", "seconds", None): + with self.subTest(value=value), self.assertRaises(ValidationError): + parse_timeout(value) diff --git a/uvm/api_models.py b/uvm/api_models.py index caaba09..896787c 100644 --- a/uvm/api_models.py +++ b/uvm/api_models.py @@ -3,8 +3,9 @@ from __future__ import annotations from ipaddress import IPv4Address +from typing import Literal -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr from .domain import VmRecord @@ -14,10 +15,9 @@ class HealthResponse(BaseModel): class InstallRequest(BaseModel): - force: bool = False + model_config = ConfigDict(extra="forbid") - class Config: - extra = "forbid" + force: bool = False class InstallResponse(BaseModel): @@ -26,19 +26,31 @@ class InstallResponse(BaseModel): rootfs: str +class PortRuleModel(BaseModel): + """A private guest TCP port; HTTP and HTTPS are descriptive labels only.""" + + model_config = ConfigDict(extra="forbid") + + port: int = Field(strict=True, ge=1, le=65535) + protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP" + + class VmCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + cpu: float = Field(default=1, gt=0) ram: str | int | float = "512" guest_ip: IPv4Address | None = None username: str = "root" password: SecretStr = SecretStr("root") - - class Config: - extra = "forbid" + name: str | None = None + ports: list[PortRuleModel] | None = None + no_default_ports: bool = Field(default=False, strict=True) class VmResponse(BaseModel): id: str + name: str | None cpu: float ram_mib: int guest_ip: str @@ -47,6 +59,9 @@ class VmResponse(BaseModel): username: str status: str observed_status: str + ports: list[PortRuleModel] | None + policy_status: str + operation: str | None pid: int | None created_at: int updated_at: int @@ -58,9 +73,15 @@ class DestroyResponse(BaseModel): status: str = "terminated" -def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse: +def vm_response( + vm: VmRecord, + *, + observed_status: str | None = None, + policy_status: str = "unknown", +) -> VmResponse: return VmResponse( id=vm.id, + name=vm.name, cpu=vm.cpu, ram_mib=vm.ram_mib, guest_ip=vm.guest_ip, @@ -68,7 +89,14 @@ def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmRespon mac=vm.mac, username=vm.username, status=vm.status, - observed_status=observed_status or vm.status, + observed_status=observed_status if observed_status is not None else vm.status, + ports=( + [PortRuleModel(port=rule.port, protocol=rule.protocol) for rule in vm.ports] + if vm.ports is not None + else None + ), + policy_status=policy_status, + operation=vm.operation, pid=vm.pid, created_at=vm.created_at, updated_at=vm.updated_at, diff --git a/uvm/cli.py b/uvm/cli.py index a538019..413aa3b 100644 --- a/uvm/cli.py +++ b/uvm/cli.py @@ -3,22 +3,54 @@ from __future__ import annotations import argparse +import codecs +import json import os import sys +import time from collections.abc import Sequence from ipaddress import IPv4Address +from pathlib import Path from .app import Application, build_application -from .domain import VmSpec +from .domain import PortRule, VmRecord, VmSpec from .errors import UvmError, ValidationError from .firecracker.config import vcpu_count -from .validation import parse_cpu, parse_password, parse_ram, parse_username +from .validation import ( + parse_cpu, + parse_name, + parse_password, + parse_ports, + parse_ram, + parse_timeout, + parse_username, +) + + +class _CommandParser(argparse.ArgumentParser): + def __init__(self, *args, ssh_command: bool = False, **kwargs) -> None: + super().__init__(*args, allow_abbrev=False, **kwargs) + self._ssh_command = ssh_command + + def parse_known_args(self, args=None, namespace=None): + if not self._ssh_command: + return super().parse_known_args(args, namespace) + values = list(sys.argv[1:] if args is None else args) + remote_command = [] + if "--" in values: + separator = values.index("--") + values, remote_command = values[:separator], values[separator + 1 :] + # REMAINDER would swallow SSH options placed after the VM identifier. + parsed, unknown = super().parse_known_args(values, namespace) + parsed.remote_command = remote_command + return parsed, unknown def build_parser(application: Application) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog=application.settings.app_name, description="Tiny Firecracker microVM CLI for Ubuntu.", + allow_abbrev=False, ) parser.add_argument( "--serve", @@ -36,7 +68,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser: default=8000, help="server bind port used with --serve; defaults to 8000", ) - subcommands = parser.add_subparsers(dest="command") + subcommands = parser.add_subparsers(dest="command", parser_class=_CommandParser) install = subcommands.add_parser( "install", @@ -53,7 +85,8 @@ def build_parser(application: Application) -> argparse.ArgumentParser: help="redownload Firecracker, kernel, and rootfs even when local assets already exist", ) - create = subcommands.add_parser("create", help="create and boot a microVM") + create = subcommands.add_parser("create", help="create and boot a microVM detached") + create.add_argument("--name", help="unique local VM name") create.add_argument( "--cpu", default=str(application.settings.default_vcpu), @@ -65,6 +98,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser: help="RAM, e.g. 512, 1G, 512M", ) create.add_argument( + "--guest-ip", "--host-ip", dest="host_ip", help="guest IP to assign; otherwise uvm allocates one", @@ -74,36 +108,133 @@ def build_parser(application: Application) -> argparse.ArgumentParser: default=application.settings.default_ssh_user, help="existing guest account to configure; defaults to root", ) - create.add_argument( + credentials = create.add_mutually_exclusive_group() + credentials.add_argument( "--password", default="root", help="guest login password; defaults to root", ) + credentials.add_argument( + "--password-stdin", + action="store_true", + help="read the guest password from one line of stdin", + ) + port_help = ( + "private guest PORT[:TYPE], e.g. 22,80:HTTP,443:HTTPS; " + "repeat or separate with commas (TYPE: HTTP, HTTPS, TCP; default: TCP); " + "no host-public ports, forwarding, or proxy" + ) + create.add_argument("--ports", action="append", default=[], help=port_help) + create.add_argument( + "--no-default-ports", action="store_true", help="do not add the default 22:TCP rule" + ) + create.add_argument("--json", action="store_true") - 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)") + output = listing.add_mutually_exclusive_group() + output.add_argument("--json", action="store_true") + output.add_argument("-q", "--quiet", action="store_true", help="print only VM IDs") - ssh = subcommands.add_parser("ssh", help="SSH into a VM") - ssh.add_argument("vm", help="VM ID or guest IP") + inspect = subcommands.add_parser("inspect", help="inspect one VM") + inspect.add_argument("vm", help="VM ID, name, or guest IP") + inspect.add_argument("--json", action="store_true") + + for action in ("start", "stop", "restart", "delete"): + command = subcommands.add_parser( + action, + aliases=["destroy"] if action == "delete" else [], + help=f"{action} selected VMs", + ) + targets = command.add_mutually_exclusive_group(required=True) + targets.add_argument( + "vms", nargs="*", default=[], metavar="VM", help="VM IDs, names, or guest IPs" + ) + targets.add_argument("--all", dest="all_vms", action="store_true", help="select all VMs") + command.add_argument("--yes", "-y", action="store_true", help="skip confirmation") + command.add_argument("--dry-run", action="store_true", help="show targets without changes") + command.add_argument("--json", action="store_true") + if action != "start": + command.add_argument( + "--timeout", + help=( + "seconds to wait after SIGTERM before SIGKILL " + "(defaults to the configured timeout, normally 5s); " + "does not coordinate guest shutdown" + ), + ) + + ssh = subcommands.add_parser( + "ssh", + ssh_command=True, + help="SSH into a VM", + usage="%(prog)s [options] VM [-- COMMAND ...]", + epilog="Append a remote command after a literal --, e.g. ssh VM -- uname -a.", + ) + ssh.add_argument("vm", help="VM ID, name, or guest IP") ssh.add_argument("--user", help="override the username stored for the VM") ssh.add_argument("--key") + ssh.add_argument("--port", type=_port_number, default=22, help="guest SSH port (default: 22)") ssh.add_argument( "--insecure-host-key", action="store_true", help="disable SSH host-key verification for this connection", ) - stop = subcommands.add_parser("stop", help="stop a VM") - stop.add_argument("vm") + logs = subcommands.add_parser("logs", help="read a VM's local Firecracker log") + logs.add_argument("vm", help="VM ID, name, or guest IP") + logs.add_argument("--tail", type=_line_count, default=100, metavar="N") + logs.add_argument("-f", "--follow", action="store_true") - destroy = subcommands.add_parser("destroy", help="stop and remove a VM") - destroy.add_argument("vm") + ports = subcommands.add_parser( + "ports", + help="manage enforced private-IP guest ports (not public ports)", + description=( + "Manage private-IP TCP allowlists. HTTP/HTTPS are TCP labels, not proxies " + "or TLS configuration. No host-public ports or NAT rules are created." + ), + ) + port_commands = ports.add_subparsers(dest="ports_command", required=True) + for action in ("list", "open", "close", "set", "sync"): + command = port_commands.add_parser(action, help=f"{action} private-IP port rules") + command.add_argument("--json", action="store_true") + if action in {"list", "sync"}: + targets = command.add_mutually_exclusive_group(required=True) + targets.add_argument( + "vms" if action == "sync" else "vm", + nargs="*" if action == "sync" else "?", + default=[] if action == "sync" else None, + metavar="VM", + help="VM ID, name, or guest IP", + ) + targets.add_argument("--all", dest="all_vms", action="store_true") + else: + command.add_argument("vm", help="VM ID, name, or guest IP") + if action == "open": + command.add_argument("--ports", action="append", required=True, help=port_help) + else: + selection = command.add_mutually_exclusive_group(required=True) + selection.add_argument("--ports", action="append", default=[], help=port_help) + if action == "close": + selection.add_argument( + "--all", dest="all_ports", action="store_true", help="close all ports of this VM" + ) + else: + selection.add_argument("--none", action="store_true", help="deny all inbound ports") + if action != "list": + command.add_argument("--dry-run", action="store_true", help="show changes without applying") + if action in {"close", "set", "sync"}: + command.add_argument("--yes", "-y", action="store_true", help="skip confirmation") + + doctor = subcommands.add_parser("doctor", help="check local host readiness and policy health") + doctor.add_argument("--json", action="store_true") return parser def main(argv: Sequence[str] | None = None) -> int: try: - application = build_application() + application = build_application(emit=lambda message: print(message, file=sys.stderr)) parser = build_parser(application) args = parser.parse_args(argv) if args.serve: @@ -113,9 +244,15 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command is None: parser.error("a command is required unless --serve is used") return _run_command(application, args) + except ValidationError as error: + print(f"uvm: error: {error}", file=sys.stderr) + return 2 except UvmError as error: print(f"uvm: error: {error}", file=sys.stderr) return error.exit_code + except OSError as error: + print(f"uvm: error: {error}", file=sys.stderr) + return 1 except KeyboardInterrupt: print("uvm: interrupted", file=sys.stderr) return 130 @@ -146,54 +283,125 @@ def _run_command(application: Application, args: argparse.Namespace) -> int: if args.command == "create": guest_ip = _parse_guest_ip(args.host_ip) + ports = parse_ports(args.ports) + if not args.no_default_ports and not any(rule.port == 22 for rule in ports): + ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port)) + password = args.password + if args.password_stdin: + password = sys.stdin.readline(131).removesuffix("\n").removesuffix("\r") vm = application.lifecycle.create( VmSpec( cpu=parse_cpu(args.cpu), ram_mib=parse_ram(args.ram), guest_ip=guest_ip, username=parse_username(args.username), - password=parse_password(args.password), + password=parse_password(password), + name=parse_name(args.name) if args.name is not None else None, + ports=ports, ) ) if vm.cpu < 1.0: print( - f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU;" + f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; " + "fractional CPU enforcement is not yet applied.", + file=sys.stderr, ) - print(" fractional CPU enforcement is not yet applied by this MVP.") - print() - print(f"VM created: {vm.id}") - print(f" IP: {vm.guest_ip}") - print(f" RAM: {vm.ram_mib} MiB") - print(f" CPU: {vm.cpu}") - print(f" TAP: {vm.tap}") - print(f" Username: {vm.username}") - print(f" SSH: ssh {vm.username}@{vm.guest_ip}") - print() - if vm.password == "root": - print("WARNING: the guest is using the default password 'root'. Change it promptly.") + if password == "root": + print( + "WARNING: the guest is using the default password 'root'. Change it promptly.", + file=sys.stderr, + ) + if args.json: + _print_json(_vm_output( + vm, policy_status="unmanaged" if vm.ports is None else "enforced" + )) + else: + print(f"VM created: {vm.id}") + if vm.name is not None: + print(f" Name: {vm.name}") + print(f" IP: {vm.guest_ip}") + print(f" RAM: {vm.ram_mib} MiB") + print(f" CPU: {vm.cpu}") + print(f" TAP: {vm.tap}") + print(f" Username: {vm.username}") + print(f" Private ports: {_format_ports(vm.ports)}") + if any(rule.port == 22 for rule in vm.ports or ()): + print(f" SSH: ssh {vm.username}@{vm.guest_ip}") return 0 if args.command == "list": - vms = application.lifecycle.list_vms() + vms = [ + listed for listed in application.lifecycle.list_vms() + if not args.status or listed.observed_status in args.status + ] + if args.json: + _print_json([ + _vm_output(listed.vm, listed.observed_status, listed.policy_status) + for listed in vms + ]) + return 0 + if args.quiet: + for listed in vms: + print(listed.vm.id) + return 0 if not vms: print("No VMs.") return 0 print( - f"{'ID':<37} {'IP':<16} {'USER':<16} {'CPU':<7} " - f"{'RAM':<8} {'STATUS':<10} PID" + f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} " + f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID" ) for listed in vms: vm = listed.vm pid = vm.pid if vm.pid is not None else "" print( - f"{vm.id:<37} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} " - f"{vm.ram_mib:<8} {listed.observed_status:<10} {pid}" + 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}" ) return 0 + if args.command == "inspect": + listed = application.lifecycle.inspect(args.vm) + data = _vm_output(listed.vm, listed.observed_status, listed.policy_status) + if args.json: + _print_json(data) + else: + for key, value in data.items(): + if key == "ports": + value = _format_ports(listed.vm.ports) + print(f"{key}: {value if value is not None else '-'}") + return 0 + + if args.command in {"start", "stop", "restart", "delete", "destroy"} or ( + args.command == "ports" and args.ports_command == "sync" + ): + action = "ports-sync" if args.command == "ports" else args.command + if action == "destroy": + action = "delete" + timeout = None + if action in {"stop", "restart", "delete"}: + timeout = parse_timeout( + args.timeout if args.timeout is not None else str(application.settings.terminate_timeout_s) + ) + targets = application.lifecycle.resolve_targets(args.vms, all_vms=args.all_vms) + vm_ids = tuple(vm.id for vm in targets) + if args.dry_run: + _print_preview(action, targets, as_json=args.json) + return 0 + if not targets: + return _print_results([], as_json=args.json) + if action == "delete" or args.all_vms: + if not _confirm(f"{action} VMs: {', '.join(vm_ids)}?", yes=args.yes): + if args.json: + _print_json([]) + return 0 + results = application.lifecycle.run_many(action, vm_ids, timeout=timeout) + return _print_results(results, as_json=args.json) + if args.command == "ssh": + user = parse_username(args.user) if args.user is not None else None vm = application.lifecycle.find_for_ssh(args.vm) - command = ["ssh"] + command = ["ssh", "-p", str(args.port)] if args.key: command.extend(("-i", args.key)) if args.insecure_host_key: @@ -214,23 +422,266 @@ def _run_command(application: Application, args: argparse.Namespace) -> int: "StrictHostKeyChecking=accept-new", ) ) - command.append(f"{args.user or vm.username}@{vm.guest_ip}") + command.append(f"{user or vm.username}@{vm.guest_ip}") + command.extend(args.remote_command) os.execvp(command[0], command) return 0 - if args.command == "stop": - vm = application.lifecycle.stop(args.vm) - print(f"Stopped {vm.id}") + if args.command == "logs": + _show_logs(application.lifecycle.log_path(args.vm), args.tail, follow=args.follow) return 0 - if args.command == "destroy": - vm = application.lifecycle.destroy(args.vm) - print(f"Destroyed {vm.id}") + if args.command == "ports": + mode = args.ports_command + if mode == "list": + listed_vms = ( + application.lifecycle.list_vms() if args.all_vms + else [application.lifecycle.inspect(args.vm)] + ) + if args.json: + _print_json([ + _vm_output(listed.vm, listed.observed_status, listed.policy_status) + for listed in listed_vms + ]) + elif not listed_vms: + print("No VMs.") + else: + print(f"{'ID':<37} {'NAME':<20} {'IP':<16} {'POLICY':<12} PRIVATE-IP PORTS") + for listed in listed_vms: + vm = listed.vm + print( + f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} " + f"{listed.policy_status:<12} {_format_ports(vm.ports)}" + ) + return 0 + + requested = parse_ports(args.ports) + listed = application.lifecycle.inspect(args.vm) + vm = listed.vm + vm_id = vm.id + before = vm.ports + if mode in {"open", "close"} and before is None: + raise UvmError( + f"{vm_id} has an unmanaged port policy; use ports set on a stopped VM first" + ) + service_mode = mode + if mode == "close" and args.all_ports: + service_mode = "set" + requested = () + if service_mode == "set": + after = requested + elif service_mode == "open": + merged = {rule.port: rule for rule in before} + for rule in requested: + merged.setdefault(rule.port, rule) + after = tuple(merged[port] for port in sorted(merged)) + else: + removed = {rule.port for rule in requested} + after = tuple(rule for rule in before if rule.port not in removed) + if args.dry_run: + _print_preview( + f"ports-{mode}", [vm], as_json=args.json, + changes={"ports": {"before": _ports_output(before), "after": _ports_output(after)}}, + ) + return 0 + if ( + before == after == () + and vm.operation is None + and (vm.status, listed.observed_status, listed.policy_status) in { + ("running", "running", "enforced"), + ("stopped", "stopped", "inactive"), + } + ): + if args.json: + _print_json(_vm_output(vm, listed.observed_status, listed.policy_status)) + else: + print(f"{vm_id}: no port changes.") + return 0 + removes_ssh = ( + any(rule.port == 22 for rule in before or ()) + and not any(rule.port == 22 for rule in after) + ) + if service_mode == "set" or removes_ssh: + if not _confirm( + f"{mode} private-IP ports for {vm_id}: " + f"{_format_ports(before)} -> {_format_ports(after)}?", + yes=args.yes, + ): + if args.json: + _print_json([]) + return 0 + updated = application.lifecycle.set_ports(vm_id, requested, mode=service_mode) + if args.json: + _print_json(_vm_output( + updated, policy_status="enforced" if updated.status == "running" else "inactive" + )) + else: + print(f"{updated.id} private-IP ports: {_format_ports(updated.ports)}") return 0 + if args.command == "doctor": + checks = [ + {"name": check["name"], "ok": check["ok"], "message": check["message"]} + for check in application.lifecycle.diagnostics() + ] + if args.json: + _print_json(checks) + else: + for check in checks: + print(f"{'OK' if check['ok'] else 'FAIL'} {check['name']}: {check['message']}") + return 0 if all(check["ok"] for check in checks) else 1 + raise UvmError(f"unsupported command: {args.command}") +def _vm_output( + vm: VmRecord, observed_status: str | None = None, policy_status: str = "unknown" +) -> dict: + # This is an output whitelist, not the persisted record (which contains credentials). + return { + "id": vm.id, + "name": vm.name, + "cpu": vm.cpu, + "ram_mib": vm.ram_mib, + "guest_ip": vm.guest_ip, + "gateway": vm.gateway, + "tap": vm.tap, + "mac": vm.mac, + "username": vm.username, + "status": vm.status, + "observed_status": observed_status if observed_status is not None else vm.status, + "policy_status": policy_status, + "ports": _ports_output(vm.ports), + "pid": vm.pid, + "disk": vm.disk, + "disk_ready": vm.disk_ready, + "socket": vm.socket, + "config": vm.config, + "log": vm.log, + "operation": vm.operation, + "boot_id": vm.boot_id, + "created_at": vm.created_at, + "updated_at": vm.updated_at, + "last_error": vm.last_error, + } + + +def _ports_output(ports: tuple[PortRule, ...] | None) -> list[dict] | None: + return None if ports is None else [rule.to_dict() for rule in ports] + + +def _format_ports(ports: tuple[PortRule, ...] | None) -> str: + if ports is None: + return "unmanaged" + return ",".join(f"{rule.port}:{rule.protocol}" for rule in ports) or "(none)" + + +def _print_json(value: object) -> None: + print(json.dumps(value, indent=2)) + + +def _print_preview( + action: str, targets: Sequence[VmRecord], *, as_json: bool, changes: dict | None = None +) -> None: + if as_json: + data = {"dry_run": True, "action": action, "targets": [_vm_output(vm) for vm in targets]} + if changes is not None: + data["changes"] = changes + _print_json(data) + else: + print(f"Dry run: {action}") + if not targets: + print("No VMs.") + for vm in targets: + print(f" {vm.id} {vm.name or '-'} {vm.guest_ip}") + if changes is not None: + for field, change in changes.items(): + print(f" {field}: {json.dumps(change['before'])} -> {json.dumps(change['after'])}") + + +def _confirm(message: str, *, yes: bool) -> bool: + if yes: + return True + if not sys.stdin.isatty(): + raise UvmError("confirmation requires a TTY; use --yes or preview with --dry-run") + print(f"{message} [y/N] ", end="", file=sys.stderr, flush=True) + if sys.stdin.readline().strip().lower() in {"y", "yes"}: + return True + print("Cancelled.", file=sys.stderr) + return False + + +def _print_results(results: Sequence, *, as_json: bool) -> int: + if as_json: + _print_json([ + {"id": result.id, "action": result.action, "outcome": result.outcome, "error": result.error} + for result in results + ]) + elif not results: + print("No VMs.") + else: + for result in results: + detail = f": {result.error}" if result.error else "" + print(f"{result.id} {result.action}: {result.outcome}{detail}") + if any(result.outcome == "interrupted" for result in results): + return 130 + return 1 if any(result.outcome == "failed" for result in results) else 0 + + +def _show_logs(path: Path, tail: int, *, follow: bool) -> None: + with path.open("rb") as stream: + end = stream.seek(0, os.SEEK_END) + position = end + remaining = tail + # Locate the starting line backwards without loading or retaining the whole log. + while position > 0 and remaining: + size = min(position, 65536) + position -= size + stream.seek(position) + block = stream.read(size) + if position + size == end and block.endswith(b"\n"): + block = block[:-1] + newlines = block.count(b"\n") + if newlines >= remaining: + offset = len(block) + for _ in range(remaining): + offset = block.rfind(b"\n", 0, offset) + position += offset + 1 + break + remaining -= newlines + stream.seek(position) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + while stream.tell() < end: + block = stream.read(min(65536, end - stream.tell())) + if not block: + break + sys.stdout.write(decoder.decode(block)) + if not follow: + sys.stdout.write(decoder.decode(b"", final=True)) + return + sys.stdout.flush() + while True: + block = stream.read(65536) + if block: + sys.stdout.write(decoder.decode(block)) + sys.stdout.flush() + else: + if os.fstat(stream.fileno()).st_size < stream.tell(): + stream.seek(0) + decoder.reset() + time.sleep(0.2) + + +def _line_count(value: str) -> int: + try: + count = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("tail must be a nonnegative integer") from error + if count < 0: + raise argparse.ArgumentTypeError("tail must be a nonnegative integer") + return count + + def _parse_guest_ip(value: str | None) -> IPv4Address | None: if value is None: return None diff --git a/uvm/domain.py b/uvm/domain.py index a64bf98..40ac231 100644 --- a/uvm/domain.py +++ b/uvm/domain.py @@ -8,10 +8,37 @@ from dataclasses import dataclass, field from ipaddress import IPv4Address from typing import Any -from .errors import StateError +from .errors import StateError, ValidationError -STATE_SCHEMA_VERSION = 1 +STATE_SCHEMA_VERSION = 2 + + +@dataclass(frozen=True, slots=True) +class PortRule: + """One private guest TCP port; HTTP/HTTPS are descriptive service labels.""" + + port: int + protocol: str = "TCP" + + def __post_init__(self) -> None: + if type(self.port) is not int or not 1 <= self.port <= 65535: + raise ValidationError("port must be an integer between 1 and 65535") + if not isinstance(self.protocol, str) or self.protocol.upper() not in {"TCP", "HTTP", "HTTPS"}: + raise ValidationError("port type must be TCP, HTTP, or HTTPS") + object.__setattr__(self, "protocol", self.protocol.upper()) + + def to_dict(self) -> dict[str, Any]: + return {"port": self.port, "protocol": self.protocol} + + @classmethod + def from_dict(cls, value: object) -> "PortRule": + if not isinstance(value, dict) or set(value) - {"port", "protocol"}: + raise StateError("invalid port rule") + try: + return cls(port=value["port"], protocol=value.get("protocol", "TCP")) + except (KeyError, ValidationError) as error: + raise StateError(f"invalid port rule: {error}") from error @dataclass(frozen=True, slots=True) @@ -23,6 +50,8 @@ class VmSpec: guest_ip: IPv4Address | None = None username: str = "root" password: str = field(default="root", repr=False) + name: str | None = None + ports: tuple[PortRule, ...] = (PortRule(22),) @dataclass(slots=True) @@ -48,6 +77,16 @@ class VmRecord: created_at: int = field(default_factory=lambda: int(time.time())) updated_at: int = field(default_factory=lambda: int(time.time())) last_error: str | None = None + name: str | None = None + ports: tuple[PortRule, ...] | None = None + disk_ready: bool = False + runtime_owned: bool = False + tap_owned: bool = False + operation: str | None = None + boot_id: str | None = None + spawn_pending: bool = False + network: str | None = None + bridge: str | None = None def to_dict(self) -> dict[str, Any]: data: dict[str, Any] = { @@ -67,6 +106,16 @@ class VmRecord: "status": self.status, "created_at": self.created_at, "updated_at": self.updated_at, + "name": self.name, + "ports": [port.to_dict() for port in self.ports] if self.ports is not None else None, + "disk_ready": self.disk_ready, + "runtime_owned": self.runtime_owned, + "tap_owned": self.tap_owned, + "operation": self.operation, + "boot_id": self.boot_id, + "spawn_pending": self.spawn_pending, + "network": self.network, + "bridge": self.bridge, } if self.pid is not None: data["pid"] = self.pid @@ -99,6 +148,16 @@ class VmRecord: try: pid_value = value.get("pid") + raw_ports = value.get("ports") + if raw_ports is not None and not isinstance(raw_ports, list): + raise StateError("VM ports must be a list or null for a legacy VM") + ports = tuple(PortRule.from_dict(port) for port in raw_ports) if raw_ports is not None else None + if ports is not None and len({port.port for port in ports}) != len(ports): + raise StateError("VM has duplicate TCP port rules") + legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"} + for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"): + if name in value and type(value[name]) is not bool: + raise StateError(f"VM {name} must be a boolean") return cls( id=str(value["id"]), cpu=float(value["cpu"]), @@ -131,6 +190,16 @@ class VmRecord: if value.get("last_error") is not None else None ), + name=str(value["name"]) if value.get("name") is not None else None, + ports=ports, + disk_ready=value.get("disk_ready", legacy_ready), + runtime_owned=value.get("runtime_owned", bool(value.get("disk"))), + tap_owned=value.get("tap_owned", bool(value.get("pid"))), + operation=str(value["operation"]) if value.get("operation") is not None else None, + boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None, + spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"), + network=str(value["network"]) if value.get("network") is not None else None, + bridge=str(value["bridge"]) if value.get("bridge") is not None else None, ) except (TypeError, ValueError) as error: raise StateError(f"invalid VM record: {error}") from error @@ -170,6 +239,8 @@ class State: raise StateError("state file has an invalid vms collection") vms = {str(vm_id): VmRecord.from_dict(vm) for vm_id, vm in raw_vms.items()} + if any(vm_id != vm.id for vm_id, vm in vms.items()): + raise StateError("VM record ID does not match its registry key") next_mac_index = value.get("next_mac_index") if next_mac_index is None: next_mac_index = _next_mac_index(vms.values()) diff --git a/uvm/errors.py b/uvm/errors.py index 0a2df8e..41c1569 100644 --- a/uvm/errors.py +++ b/uvm/errors.py @@ -23,6 +23,14 @@ class StateError(UvmError): """Raised when persisted VM state cannot be safely used.""" +class NotFoundError(UvmError): + """A requested registry entry does not exist.""" + + +class ConflictError(UvmError): + """An allocation or pending operation prevents the requested action.""" + + class CommandError(UvmError): """Raised when a required host command cannot be executed successfully.""" diff --git a/uvm/firecracker/process.py b/uvm/firecracker/process.py index e877c36..8264a2c 100644 --- a/uvm/firecracker/process.py +++ b/uvm/firecracker/process.py @@ -2,8 +2,13 @@ from __future__ import annotations +import ctypes +import errno +import math import os +import select import signal +import stat import subprocess import time from collections.abc import Callable @@ -26,6 +31,50 @@ class _PopenLike(Protocol): class ProcessInfo: pid: int start_time: str + boot_id: str | None = None + + +def _libc_pidfd(name: str, *arguments: int | None) -> int: + # Some Linux Python builds omit pidfd bindings even when libc supports them. + try: + function = getattr(ctypes.CDLL(None, use_errno=True), name) + except (AttributeError, OSError) as error: + raise OSError(errno.ENOSYS, f"{name} is unavailable") from error + function.argtypes = ( + [ctypes.c_int, ctypes.c_uint] + if name == "pidfd_open" + else [ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint] + ) + function.restype = ctypes.c_int + result = function(*arguments) + if result == -1: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + return result + + +def _open_pidfd(pid: int) -> int: + native = getattr(os, "pidfd_open", None) + if native is not None: + return native(pid, 0) + return _libc_pidfd("pidfd_open", pid, 0) + + +def _signal_pidfd(pidfd: int, signal_number: int) -> None: + native = getattr(signal, "pidfd_send_signal", None) + if native is not None: + native(pidfd, signal_number) + else: + _libc_pidfd("pidfd_send_signal", pidfd, signal_number, None, 0) + + +def _pidfd_exited(pidfd: int) -> bool: + poller = select.poll() + poller.register(pidfd, select.POLLIN) + events = poller.poll(0) + if any(event & (select.POLLERR | select.POLLNVAL) for _, event in events): + raise OSError(errno.EIO, "could not observe the Firecracker pidfd") + return any(event & (select.POLLIN | select.POLLHUP) for _, event in events) class FirecrackerProcessManager: @@ -43,103 +92,334 @@ class FirecrackerProcessManager: self._popen = popen self._sleep = sleep self._monotonic = monotonic + self._children: dict[int, _PopenLike] = {} - def start(self, vm: VmRecord) -> ProcessInfo: - socket_path = Path(vm.socket) - if socket_path.exists(): + def start( + self, + vm: VmRecord, + *, + on_spawn: Callable[[ProcessInfo], None] | None = None, + ) -> ProcessInfo: + """Publish identity before readiness; never replace an ambiguous process.""" + + observation = self.observe(vm) + if observation != "gone": + raise FirecrackerError( + f"refusing to start Firecracker: existing process is {observation}" + ) + boot_id = self._boot_id() + if boot_id is None: + raise FirecrackerError("could not establish the current host boot identity") + + # Check support before creating a child that we might be unable to stop safely. + try: + pidfd = _open_pidfd(os.getpid()) try: - socket_path.unlink() - except OSError as error: - raise FirecrackerError( - f"could not remove stale Firecracker socket {socket_path}: {error}" - ) from error + _signal_pidfd(pidfd, 0) + finally: + os.close(pidfd) + except (OSError, NotImplementedError) as error: + raise FirecrackerError( + f"safe pidfd support is required to start Firecracker: {error}" + ) from error + + socket_path = Path(vm.socket) + self._remove_socket(socket_path) log_path = Path(vm.log) - log_path.parent.mkdir(parents=True, exist_ok=True) process: _PopenLike | None = None try: + log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("ab", buffering=0) as log_file: - process = self._popen( - [str(self._settings.firecracker_binary), "--api-sock", str(socket_path)], - stdout=log_file, - stderr=log_file, - start_new_session=True, + previous_identity = ( + vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending, ) + vm.pid = None + vm.process_start_time = None + vm.boot_id = boot_id + vm.operation = "spawning" + vm.spawn_pending = True + try: + process = self._popen( + [str(self._settings.firecracker_binary), "--api-sock", str(socket_path)], + stdout=log_file, + stderr=log_file, + start_new_session=True, + ) + except OSError as error: + # Popen cleans up an exec/fork failure, but an interrupt is uncertain. + if not isinstance(error, InterruptedError): + ( + vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending, + ) = previous_identity + raise + vm.pid = process.pid + self._children[process.pid] = process + vm.process_start_time = self.process_start_time(process.pid) + if vm.process_start_time is None: + raise FirecrackerError( + f"could not establish a safe process identity for Firecracker process {process.pid}" + ) + info = ProcessInfo(process.pid, vm.process_start_time, boot_id) + if on_spawn is not None: + on_spawn(info) + else: + vm.spawn_pending = False self._wait_for_socket(process, socket_path, log_path) - start_time = self.process_start_time(process.pid) - if start_time is None: - raise FirecrackerError( - f"could not establish a safe process identity for Firecracker process {process.pid}" - ) - return ProcessInfo(pid=process.pid, start_time=start_time) - except OSError as error: + return info + except BaseException as error: if process is not None: - self._cleanup_started_process(process, socket_path) - raise FirecrackerError(f"could not start Firecracker: {error}") from error - except BaseException: - if process is not None: - self._cleanup_started_process(process, socket_path) + try: + self.terminate(vm) + except BaseException as cleanup_error: + raise FirecrackerError( + f"Firecracker startup failed ({type(error).__name__}: {error}); " + f"cleanup incomplete for PID {vm.pid}: {cleanup_error}" + ) from error + if isinstance(error, OSError): + raise FirecrackerError(f"could not start Firecracker: {error}") from error raise - def is_alive(self, vm: VmRecord) -> bool: - if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None: + def adopt_legacy_identity(self, vm: VmRecord) -> bool: + """Validate a v1 process; the caller must lock and persist the adopted boot ID.""" + + pid, start_time = vm.pid, vm.process_start_time + if ( + vm.boot_id is not None + or type(pid) is not int + or not 0 < pid < 2**31 + or not isinstance(start_time, str) + or not start_time.isascii() + or not start_time.isdigit() + ): return False + try: - os.kill(vm.pid, 0) - except ProcessLookupError: + pidfd = _open_pidfd(pid) + try: + process_stat = self._process_stat(pid) + if ( + process_stat is None + or process_stat[1] != start_time + or process_stat[0] in {"Z", "X", "x"} + ): + return False + + binary_path = self._settings.firecracker_binary + socket_path = self._settings.vm_dir(vm.id) / "firecracker.sock" + if ( + not binary_path.is_absolute() + or ".." in binary_path.parts + or not vm.id + or vm.id in {".", ".."} + or Path(vm.id).name != vm.id + or vm.socket != str(socket_path) + ): + return False + + # Sticky ancestors are safe only because every existing entry is root-owned. + for directory in set(binary_path.parents) | set(socket_path.parents): + metadata = directory.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != 0 + or (directory == socket_path.parent and metadata.st_mode & 0o077) + or (metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX) + ): + return False + binary_stat = binary_path.lstat() + socket_stat = socket_path.lstat() + if ( + not stat.S_ISREG(binary_stat.st_mode) + or binary_stat.st_uid != 0 + or binary_stat.st_mode & 0o022 + or not stat.S_ISSOCK(socket_stat.st_mode) + or socket_stat.st_uid != 0 + ): + return False + + proc_path = Path(f"/proc/{pid}") + executable = proc_path / "exe" + if executable.readlink() != binary_path or not executable.samefile(binary_path): + return False + expected_cmdline = b"\0".join( + (os.fsencode(binary_path), b"--api-sock", os.fsencode(socket_path), b"") + ) + if (proc_path / "cmdline").read_bytes() != expected_cmdline: + return False + boot_id = self._boot_id() + if boot_id is None or _pidfd_exited(pidfd): + return False + finally: + os.close(pidfd) + except (OSError, ValueError, NotImplementedError): return False - except PermissionError: - return True - return self.process_start_time(vm.pid) == vm.process_start_time + if (vm.pid, vm.process_start_time, vm.boot_id) != (pid, start_time, None): + return False + vm.boot_id = boot_id + return True - def terminate(self, vm: VmRecord) -> None: + def observe(self, vm: VmRecord) -> str: + """Distinguish proven exit from missing or unreadable process identity.""" + + if vm.pid is not None and vm.pid <= 0: + return "unknown" + try: + self._reap_child(vm.pid) + except OSError: + return "unknown" + boot_id = self._boot_id() + if vm.boot_id and boot_id and vm.boot_id != boot_id: + return "gone" if vm.pid is None: - self._remove_socket(Path(vm.socket)) - return - if vm.pid <= 0: + if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None: + return "unknown" + try: + Path(vm.socket).lstat() + except FileNotFoundError: + return "gone" + except OSError: + return "unknown" + # An unowned socket may belong to a child whose identity was never saved. + return "unknown" + + stat = self._process_stat(vm.pid) + if stat is None: + return "unknown" if self._pid_exists(vm.pid) else "gone" + state, start_time = stat + has_start_time = ( + isinstance(vm.process_start_time, str) + and vm.process_start_time.isascii() + and vm.process_start_time.isdigit() + ) + if has_start_time and vm.process_start_time != start_time: + return "gone" + if state in {"Z", "X", "x"}: + # A zombie thread-group leader can still have live worker threads. + try: + pidfd = _open_pidfd(vm.pid) + try: + return "gone" if _pidfd_exited(pidfd) else "unknown" + finally: + os.close(pidfd) + except ProcessLookupError: + return "gone" + except (OSError, NotImplementedError): + return "unknown" + if not has_start_time or not vm.boot_id or not boot_id: + return "unknown" + return "alive" + + def is_alive(self, vm: VmRecord) -> bool: + """Conservative compatibility query: unknown processes may still be alive.""" + + return self.observe(vm) != "gone" + + def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None: + """Wait for exit before unlinking; timeout controls the SIGTERM grace period.""" + + if vm.pid is not None and vm.pid <= 0: raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}") - if vm.process_start_time is None: - if not self._pid_exists(vm.pid): - self._remove_socket(Path(vm.socket)) - return + grace_timeout = self._settings.terminate_timeout_s if timeout is None else timeout + if not math.isfinite(grace_timeout) or grace_timeout < 0: + raise FirecrackerError("Firecracker termination timeout must be finite and non-negative") + observation = self.observe(vm) + if observation == "unknown": raise FirecrackerError( - f"refusing to signal Firecracker PID {vm.pid} without a process identity token" + f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous" ) - if not self.is_alive(vm): + if observation == "gone": self._remove_socket(Path(vm.socket)) return try: - os.kill(vm.pid, signal.SIGTERM) + pidfd = _open_pidfd(vm.pid) except ProcessLookupError: + self._reap_child(vm.pid) self._remove_socket(Path(vm.socket)) return - except OSError as error: - raise FirecrackerError(f"could not stop Firecracker process {vm.pid}: {error}") from error + except (OSError, NotImplementedError) as error: + raise FirecrackerError( + f"could not safely open a pidfd for Firecracker process {vm.pid}: {error}" + ) from error - deadline = self._monotonic() + self._settings.terminate_timeout_s - while self.is_alive(vm) and self._monotonic() < deadline: - self._sleep(0.05) - if self.is_alive(vm): - self._terminate_process_id(vm.pid, force=True) - self._remove_socket(Path(vm.socket)) + try: + if not _pidfd_exited(pidfd): + # Open the handle BEFORE rechecking identity; never signal by bare PID. + observation = self.observe(vm) + if observation == "unknown": + raise FirecrackerError( + f"refusing to stop Firecracker PID {vm.pid}: process identity became ambiguous" + ) + if observation == "alive": + try: + _signal_pidfd(pidfd, signal.SIGTERM) + except ProcessLookupError: + pass + if not self._wait_for_exit(pidfd, grace_timeout): + try: + _signal_pidfd(pidfd, signal.SIGKILL) + except ProcessLookupError: + pass + # Even an immediate forced stop needs time to confirm kernel exit. + if not self._wait_for_exit(pidfd, max(1.0, grace_timeout)): + raise FirecrackerError( + f"could not confirm exit of Firecracker process {vm.pid} after SIGKILL; " + "process identity and socket retained" + ) + self._reap_child(vm.pid) + self._remove_socket(Path(vm.socket)) + except (OSError, NotImplementedError) as error: + raise FirecrackerError( + f"could not safely stop Firecracker process {vm.pid}: {error}" + ) from error + finally: + os.close(pidfd) def process_start_time(self, pid: int) -> str | None: """Read Linux proc start time so a reused PID is not mistaken for a VM.""" + stat = self._process_stat(pid) + return stat[1] if stat is not None else None + + def host_boot_id(self) -> str | None: + """Return the current host boot identity, or None when it is unreadable.""" + + return self._boot_id() + + @staticmethod + def _boot_id() -> str | None: try: - stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") - except OSError: + return Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip() or None + except (OSError, UnicodeError): return None + + @staticmethod + def _process_stat(pid: int) -> tuple[str, str] | None: + if pid <= 0: + return None + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace") + except (OSError, UnicodeError): + return None + opening_parenthesis = stat.find("(") closing_parenthesis = stat.rfind(")") - if closing_parenthesis < 0: + if ( + opening_parenthesis < 0 + or closing_parenthesis < opening_parenthesis + or stat[:opening_parenthesis].strip() != str(pid) + ): return None - fields = stat[closing_parenthesis + 2 :].split() - try: - return fields[19] - except IndexError: + fields = stat[closing_parenthesis + 1 :].split() + if ( + len(fields) < 20 + or fields[0] not in {"R", "S", "D", "Z", "T", "t", "X", "x", "K", "W", "P", "I"} + or not fields[19].isascii() + or not fields[19].isdigit() + ): return None + return fields[0], fields[19] def _wait_for_socket( self, @@ -148,13 +428,32 @@ class FirecrackerProcessManager: log_path: Path, ) -> None: deadline = self._monotonic() + self._settings.api_socket_timeout_s - while self._monotonic() < deadline: - if socket_path.exists(): - return + while True: if process.poll() is not None: raise FirecrackerError(f"Firecracker exited early; see {log_path}") - self._sleep(0.05) - raise FirecrackerError("timed out waiting for Firecracker API socket") + if socket_path.is_socket(): + return + remaining = deadline - self._monotonic() + if remaining <= 0: + raise FirecrackerError("timed out waiting for Firecracker API socket") + self._sleep(min(0.05, remaining)) + + def _wait_for_exit(self, pidfd: int, timeout: float) -> bool: + deadline = self._monotonic() + timeout + while True: + if _pidfd_exited(pidfd): + return True + remaining = deadline - self._monotonic() + if remaining <= 0: + return False + self._sleep(min(0.05, remaining)) + + def _reap_child(self, pid: int | None) -> None: + if pid is None: + return + process = self._children.get(pid) + if process is not None and process.poll() is not None: + self._children.pop(pid, None) @staticmethod def _remove_socket(socket_path: Path) -> None: @@ -162,9 +461,10 @@ class FirecrackerProcessManager: socket_path.unlink() except FileNotFoundError: pass - except OSError: - # A stale socket is cleaned on the next launch; never hide a successful stop. - pass + except OSError as error: + raise FirecrackerError( + f"could not remove Firecracker socket {socket_path}: {error}" + ) from error @staticmethod def _pid_exists(pid: int) -> bool: @@ -172,34 +472,6 @@ class FirecrackerProcessManager: os.kill(pid, 0) except ProcessLookupError: return False - except PermissionError: + except (OSError, OverflowError): return True return True - - def _cleanup_started_process(self, process: _PopenLike, socket_path: Path) -> None: - try: - self._terminate_process_id(process.pid) - except FirecrackerError: - pass - deadline = self._monotonic() + self._settings.terminate_timeout_s - while process.poll() is None and self._monotonic() < deadline: - self._sleep(0.05) - if process.poll() is None: - try: - self._terminate_process_id(process.pid, force=True) - except FirecrackerError: - pass - self._remove_socket(socket_path) - - @staticmethod - def _terminate_process_id(pid: int, *, force: bool = False) -> None: - if pid <= 0: - raise FirecrackerError(f"invalid Firecracker PID: {pid}") - signal_to_send = signal.SIGKILL if force else signal.SIGTERM - try: - os.kill(pid, signal_to_send) - except ProcessLookupError: - pass - except OSError as error: - action = "force-stop" if force else "stop" - raise FirecrackerError(f"could not {action} Firecracker process {pid}: {error}") from error diff --git a/uvm/firewall.py b/uvm/firewall.py new file mode 100644 index 0000000..7dca5fc --- /dev/null +++ b/uvm/firewall.py @@ -0,0 +1,496 @@ +"""Private IPv4 service filtering, not host-port forwarding or publishing. + +The lifecycle owner must serialize mutations, keep a live VM's identity immutable, +install the policy before starting its guest, and quarantine before teardown. +Only bridge/TAP traffic is covered; do not offload it around the bridge hooks. +Before making a TAP usable, attach it to an STP-disabled software bridge and set +the TAP's bridge_slave group_fwd_mask bit 0x4000. Otherwise Linux can deliver LLDP +(and STP) before *any* bridge hook and learn spoofed MACs. Network/lifecycle owns +these link settings; this manager only verifies them when the TAP exists. +No br_netfilter sysctls, NAT, persistent applied flags, or live conntrack flushes +are used. Native bridge conntrack (Linux >= 5.3) and nft JSON support are required. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from ipaddress import IPv4Address, IPv4Network +from types import SimpleNamespace +from typing import Any + +from .config import Settings +from .domain import PortRule, VmRecord +from .errors import UvmError, ValidationError +from .system import CommandResult, CommandRunner + + +_COMMAND_TIMEOUT_S = 5.0 +_PRIVATE_NETWORKS = tuple(IPv4Network(value) for value in ( + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", +)) + + +def _match(left: dict[str, Any], right: Any, op: str = "==") -> dict[str, Any]: + return {"match": {"op": op, "left": left, "right": right}} + + +def _payload(protocol: str, field: str) -> dict[str, Any]: + return {"payload": {"protocol": protocol, "field": field}} + + +def _ct(key: str, direction: str | None = None) -> dict[str, Any]: + # nft 1.0.9 parser_json.c/ct.c use the literal keys "ip saddr"/"ip daddr". + # Its CT parser ignores the "family" property advertised by the JSON manual. + return {"ct": {"key": key, **({"dir": direction} if direction else {})}} + + +class FirewallManager: + """Own one table per (resolved settings.base, VM id) in the current netns. + + ``ports=None`` is legacy/unmanaged, not an empty allowlist. Normal apply + refuses it; quarantine works even without a port policy or valid IP/MAC. + Errors leave existing rules in place (or the committed replacement on a + readback/timeout failure). Callers must not start/release a VM on an error. + """ + + def __init__(self, settings: Settings, runner: CommandRunner) -> None: + self._settings = settings + self._runner = runner + + def apply(self, vm: VmRecord, *, quarantine: bool = False) -> None: + """Atomically install/replace only this VM's owned table; retain CT state.""" + expected = self._policy(vm, quarantine=quarantine) + if not self._bridge_paths_safe(vm): + raise UvmError( + "firewall needs an STP-disabled software bridge and TAP bridge_slave " + "group_fwd_mask bit 0x4000 (LLDP forwarding) before enforcement" + ) + table = expected[0]["table"] + current = self._read_table(table["name"]) + commands = [] + if current is not None: + commands.append(self._delete_owned(current, table)) + # Exclusive create plus deletion by handle also protects against a table + # being replaced by a foreign owner between inspection and commit. + commands.append({"create": expected[0]}) + commands.extend({"add": item} for item in expected[1:]) + self._transaction(commands) + installed = self._read_table(table["name"]) + if installed is None or _normalize(installed) != _normalize(expected): + raise UvmError("firewall readback differs from the requested policy; keep VM isolated") + + def remove(self, vm: VmRecord) -> None: + """Remove an owned table only after the TAP is gone; absence is harmless.""" + self._require_offline(vm) + table = self._table_identity(vm) + current = self._read_table(table["name"]) + if current is not None: + self._transaction([self._delete_owned(current, table)]) + + def status(self, vm: VmRecord) -> str: + """Inspect actual hooks, ordered rules, identity and complete port set. + + Missing legacy tables are unmanaged. Foreign or altered tables are + drift; failed inspection/invalid metadata is unknown. A legacy VM can + report quarantined. This is a ruleset snapshot, not a reachability test. + """ + try: + table = self._table_identity(vm) + current = self._read_table(table["name"]) + if current is None: + return "unmanaged" if getattr(vm, "ports", None) is None else "missing" + if _normalize(current) == _normalize(self._policy(vm, quarantine=True)): + return "quarantined" if self._bridge_paths_safe(vm) else "drift" + if getattr(vm, "ports", None) is not None: + if _normalize(current) == _normalize(self._policy(vm, quarantine=False)): + return "enforced" if self._bridge_paths_safe(vm) else "drift" + return "drift" + except (UvmError, ValueError, TypeError, KeyError): + return "unknown" + + def check_available(self) -> None: + """Check cleanup tooling, kernel and a full non-committing nft policy.""" + self._run(("conntrack", "--version")) + release = self._run(("uname", "-r")).stdout.strip() + version = re.match(r"^(\d+)\.(\d+)", release) + if version is None or tuple(map(int, version.groups())) < (5, 3): + raise UvmError("private ports require Linux >= 5.3 with native bridge conntrack") + probe = SimpleNamespace( + id=f"probe-{uuid.uuid4().hex}", tap=f"uvm{uuid.uuid4().hex[:11]}", + guest_ip="10.0.0.2", gateway="10.0.0.1", + network="10.0.0.0/24", mac="02:00:00:00:00:02", + ports=(PortRule(443),), + ) + policy = self._policy(probe, quarantine=False) # type: ignore[arg-type] + commands = [{"create": policy[0]}, *({"add": item} for item in policy[1:])] + try: + self._transaction(commands, check_only=True) + except UvmError as error: + raise UvmError(f"nft bridge/conntrack policy is unavailable: {error}") from error + + def cleanup_connections(self, vm: VmRecord) -> None: + """Offline, IP-scoped CT deletion before address reuse, never for close. + + All four tuple addresses are checked, including NAT reply tuples. A + failed deletion is tolerated only if a successful list proves absence. + The caller must retain the address reservation until this succeeds. + """ + self._require_offline(vm) + address = self._guest_ip(vm) + for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): + selector = ("--family", "ipv4", field, address) + self._run(("conntrack", "--delete", *selector), check=False) + remaining = self._run(("conntrack", "--list", *selector)) + if remaining.stdout.strip(): + raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP") + + def _run(self, command: tuple[str, ...], **kwargs: Any) -> CommandResult: + return self._runner.run( + command, capture=True, timeout=_COMMAND_TIMEOUT_S, **kwargs, + ) + + def _transaction(self, commands: list[dict[str, Any]], *, check_only: bool = False) -> None: + args = ("nft", "--json", *(("--check",) if check_only else ()), "-f", "-") + self._run(args, input_text=json.dumps({"nftables": commands})) + + def _table_identity(self, vm: VmRecord) -> dict[str, Any]: + if not isinstance(vm.id, str) or not vm.id: + raise ValidationError("firewall requires a nonempty VM ID") + identity = json.dumps([str(self._settings.base.resolve()), vm.id]).encode("utf-8") + digest = hashlib.sha256(identity).hexdigest() + return {"family": "bridge", "name": f"uvm_fw_{digest[:24]}", + "comment": f"uvm-private-ports:v1:{digest}"} + + def _tap(self, vm: VmRecord) -> str: + if not isinstance(vm.tap, str) or re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}", vm.tap) is None: + raise ValidationError("firewall requires an exact, safe TAP name (1-15 characters)") + if vm.tap in {self._settings.bridge, getattr(vm, "bridge", None)}: + raise ValidationError("firewall TAP must not be the bridge itself") + return vm.tap + + def _guest_ip(self, vm: VmRecord) -> str: + try: + address = IPv4Address(vm.guest_ip) + network = IPv4Network(getattr(vm, "network", None) or self._settings.network) + except (ValueError, TypeError) as error: + raise ValidationError("firewall requires a valid guest IPv4 address/network") from error + gateway = str(getattr(vm, "gateway", None) or self._settings.gateway) + if (not any(address in private for private in _PRIVATE_NETWORKS) + or address not in network + or address in {network.network_address, network.broadcast_address} + or str(address) == gateway): + raise ValidationError("firewall requires a usable private guest IP inside its network") + return str(address) + + def _links(self) -> list[dict[str, Any]]: + try: + links = json.loads(self._run(("ip", "-j", "-d", "link", "show")).stdout) + if not isinstance(links, list) or any( + not isinstance(link, dict) or not isinstance(link.get("ifname"), str) + for link in links + ): + raise ValueError("invalid link listing") + except (ValueError, TypeError) as error: + raise UvmError("cannot inspect network interfaces; retaining firewall/conntrack state") from error + return links + + def _bridge_paths_safe(self, vm: VmRecord) -> bool: + links = self._links() + name = self._tap(vm) + taps = [link for link in links if link["ifname"] == name] + if not taps: + return True # Policy may precede TAP creation; main must prepare it before bringing it up. + if len(taps) != 1: + return False + tap = taps[0] + bridges = [link for link in links if tap.get("master") in (link["ifname"], link.get("ifindex"))] + if len(bridges) != 1: + return False + try: + port = tap["linkinfo"] + bridge = bridges[0]["linkinfo"] + mask = int(str(port["info_slave_data"]["group_fwd_mask"]), 0) + return (port["info_slave_kind"] == "bridge" and bridge["info_kind"] == "bridge" + and bridge["info_data"]["stp_state"] == 0 + and 0 <= mask <= 65535 and bool(mask & 0x4000)) + except (KeyError, TypeError, ValueError): + return False + + def _require_offline(self, vm: VmRecord) -> None: + tap = self._tap(vm) + if any(link["ifname"] == tap for link in self._links()): + raise UvmError(f"TAP {tap} still exists; quarantine it and remove the TAP first") + + def _read_table(self, name: str) -> list[dict[str, Any]] | None: + # A successful table inventory, not a localized 'not found' error, + # distinguishes missing policy from missing privileges/tools. + def objects(command: tuple[str, ...]) -> list[dict[str, Any]]: + try: + data = json.loads(self._run(command).stdout)["nftables"] + if not isinstance(data, list) or any( + not isinstance(item, dict) or len(item) != 1 + or not isinstance(next(iter(item.values())), dict) for item in data + ): + raise ValueError("invalid nft object list") + return [item for item in data if "metainfo" not in item] + except (ValueError, KeyError, TypeError) as error: + raise UvmError("could not inspect nft bridge tables") from error + + tables = objects(("nft", "--json", "list", "tables", "bridge")) + if any("table" not in item or item["table"].get("family") != "bridge" + or not isinstance(item["table"].get("name"), str) for item in tables): + raise UvmError("invalid nft bridge table inventory") + matches = [item for item in tables if item["table"].get("name") == name] + if not matches: + return None + if len(matches) != 1: + raise UvmError("ambiguous nft bridge table inventory") + data = objects(("nft", "--json", "--numeric-protocol", "list", "table", "bridge", name)) + headers = [item["table"] for item in data if "table" in item] + if len(headers) != 1 or headers[0].get("name") != name or headers[0].get("family") != "bridge": + raise UvmError("invalid nft bridge table snapshot") + return data + + @staticmethod + def _delete_owned(current: list[dict[str, Any]], table: dict[str, Any]) -> dict[str, Any]: + header = next(item["table"] for item in current if "table" in item) + if any(header.get(key) != value for key, value in table.items()): + raise UvmError(f"refusing to touch foreign nft table {table['name']}") + handle = header.get("handle") + if type(handle) is not int or handle <= 0: + raise UvmError("owned nft table has no valid handle; refusing unsafe deletion") + return {"delete": {"table": {"family": "bridge", "handle": handle}}} + + def _policy(self, vm: VmRecord, *, quarantine: bool) -> list[dict[str, Any]]: + ports = getattr(vm, "ports", None) + if ports is None and not quarantine: + raise ValidationError("legacy VM has no managed port policy; only quarantine is allowed") + table = self._table_identity(vm) + tap = self._tap(vm) + scope = {"family": "bridge", "table": table["name"]} + policy: list[dict[str, Any]] = [{"table": table}] + + def chain(name: str, hook: str | None = None, priority: int = 0) -> None: + definition = {**scope, "name": name} + if hook is not None: + definition.update(type="filter", hook=hook, prio=priority, policy="accept") + policy.append({"chain": definition}) + + def rule(name: str, *expressions: dict[str, Any]) -> None: + policy.append({"rule": {**scope, "chain": name, "expr": list(expressions)}}) + + for hook in ("prerouting", "input", "forward", "output"): + chain(hook, hook, -310 if hook == "prerouting" else 0) + if not quarantine: + for name in ("identity", "from_vm", "to_vm"): + chain(name) + for hook, interface, target in ( + ("prerouting", "iifname", "identity"), + ("input", "iifname", "from_vm"), + ("forward", "iifname", "from_vm"), + ("forward", "oifname", "to_vm"), + ("output", "oifname", "to_vm"), + ): + rule(hook, _match({"meta": {"key": interface}}, tap), + {"drop": None} if quarantine else {"jump": {"target": target}}) + if quarantine: + return policy + + address = self._guest_ip(vm) + mac = vm.mac.lower() if isinstance(vm.mac, str) else "" + if (re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", mac) is None + or int(mac[:2], 16) & 1 or mac == "00:00:00:00:00:00"): + raise ValidationError("firewall requires a nonzero unicast guest MAC") + if not isinstance(ports, tuple): + raise ValidationError("managed ports must be a tuple of TCP/HTTP/HTTPS port rules") + allowed = set() + for port_rule in ports: + port = getattr(port_rule, "port", None) + if (type(port) is not int or not 1 <= port <= 65535 + or getattr(port_rule, "protocol", None) not in ("TCP", "HTTP", "HTTPS")): + raise ValidationError("private ports must be TCP/HTTP/HTTPS integers from 1 to 65535") + allowed.add(port) + # json_parse_set_expr() in nft 1.0.9 rejects [], even for an empty named + # set. Omit elem, as set_print_json() does when listing an empty set. + policy.insert(1, {"set": {**scope, "name": "tcp_ports", "type": "inet_service", + "flags": ["constant"], + **({"elem": sorted(allowed)} if allowed else {})}}) + + drop, success = {"drop": None}, {"return": None} + ipv4 = _match(_payload("ether", "type"), "ip") + arp = _match(_payload("ether", "type"), "arp") + arp_header = [arp, _match(_payload("arp", "htype"), 1), + _match(_payload("arp", "ptype"), "ip"), + _match(_payload("arp", "hlen"), 6), _match(_payload("arp", "plen"), 4)] + # Before conntrack (-200) and FDB learning: no MAC/IP/ARP spoofing, + # IPv6, VLAN encapsulation or other EtherTypes from a managed port. + rule("identity", _match(_payload("ether", "saddr"), mac, "!="), drop) + rule("identity", ipv4, _match(_payload("ip", "saddr"), address), success) + rule("identity", *arp_header, + _match(_payload("arp", "operation"), {"set": ["request", "reply"]}), + _match(_payload("arp", "saddr ether"), mac), + _match(_payload("arp", "saddr ip"), address), success) + # RFC 5227 address probes may use zero, but only probe this VM's IP. + rule("identity", *arp_header, _match(_payload("arp", "operation"), "request"), + _match(_payload("arp", "saddr ether"), mac), + _match(_payload("arp", "saddr ip"), "0.0.0.0"), + _match(_payload("arp", "daddr ip"), address), success) + rule("identity", drop) + + # Some link-local bridge frames take input without prerouting. Recheck + # identity there too, before its local-finish callback can learn a MAC. + rule("from_vm", {"jump": {"target": "identity"}}) + rule("from_vm", arp, success) + rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address), + _match(_payload("arp", "operation"), "request"), success) + rule("to_vm", *arp_header, _match(_payload("arp", "daddr ip"), address), + _match(_payload("arp", "daddr ether"), mac), + _match(_payload("arp", "operation"), "reply"), success) + + for name, outbound in (("from_vm", True), ("to_vm", False)): + rule(name, _match(_payload("ether", "type"), "ip", "!="), drop) + if not outbound: + rule(name, ipv4, _match(_payload("ip", "daddr"), address, "!="), drop) + rule(name, _match(_payload("ether", "daddr"), mac, "!="), drop) + # Loose TCP tracking must not reclassify a server's midstream data + # as a newly guest-initiated connection after tracking loss. + rule(name, ipv4, _match(_payload("ip", "protocol"), "tcp"), + _match(_ct("state"), "new", "in"), + _match({"&": [_payload("tcp", "flags"), 23]}, 2, "!="), drop) + + for client in (True, False): + original = outbound == client + flow = [_match(_ct("direction"), "original" if original else "reply"), + _match(_ct("ip saddr" if client else "ip daddr", "original"), address)] + states = ["new", "established"] if original else ["established"] + state = _match(_ct("state"), states[0] if len(states) == 1 else states, "in") + for protocol in (("tcp", "udp", "icmp") if client else ("tcp", "icmp")): + parent = [*flow, _match(_ct("protocol"), protocol)] + if not client and protocol == "tcp": + parent.append(_match(_ct("proto-dst", "original"), "@tcp_ports")) + packet = [ipv4, _match(_payload("ip", "protocol"), protocol)] + if protocol == "icmp": + packet.append(_match(_payload("icmp", "type"), + "echo-request" if original else "echo-reply")) + rule(name, *packet, *parent, state, success) + # Related is restricted to essential ICMP errors and the + # same authorized parent tuple, never TCP/UDP helper flows. + rule(name, ipv4, _match(_payload("ip", "protocol"), "icmp"), + _match(_payload("icmp", "type"), {"set": [ + "destination-unreachable", "time-exceeded", "parameter-problem", + ]}), *parent, _match(_ct("state"), "related", "in"), success) + rule(name, drop) + return policy + + +def _normalize(objects: list[dict[str, Any]]) -> str: + """Canonical nft JSON, retaining every effective object, rule and verdict. + + Ignore only handles, empty set presentation and nft's redundant protocol + dependencies. Rule order is significant; pure conjunctions and sets are not. + Unknown expressions/properties remain different rather than being ignored. + """ + def canonical(value: Any) -> Any: + if isinstance(value, list): + return [canonical(item) for item in value] + if not isinstance(value, dict): + return value + value = {key: canonical(item) for key, item in value.items()} + if (set(value) == {"|"} and isinstance(value["|"], list) + and all(type(item) is int for item in value["|"])): + result = 0 + for item in value["|"]: + result |= item + return result + if set(value) == {"set"} and isinstance(value["set"], list): + value["set"] = sorted(value["set"], key=lambda item: json.dumps(item, sort_keys=True)) + return value + + def bitmask(value: Any, names: dict[str, int]) -> Any: + if isinstance(value, str): + return names.get(value, value) + if isinstance(value, dict) and set(value) == {"|"}: + value = value["|"] + if isinstance(value, list): + bits = [bitmask(item, names) for item in value] + if all(type(item) is int for item in bits): + result = 0 + for item in bits: + result |= item + return result + return value + + definitions, rules = [], {} + for item in objects: + kind, raw = next(iter(item.items())) + if kind == "metainfo": + continue + obj = {key: canonical(value) for key, value in raw.items() if key != "handle"} + if kind == "set": + elements = obj.get("elem", []) + if not isinstance(elements, list): + elements = [elements] + obj["elem"] = sorted(elements, key=lambda value: json.dumps(value, sort_keys=True)) + if obj.get("flags") == []: + del obj["flags"] + elif isinstance(obj.get("flags"), str): + obj["flags"] = [obj["flags"]] + if kind != "rule": + definitions.append({kind: obj}) + continue + + expressions = obj.get("expr", []) + if not isinstance(expressions, list) or not isinstance(obj.get("chain"), str): + raise UvmError("invalid nft rule snapshot") + tcp_flags = {"fin": 1, "syn": 2, "rst": 4, "psh": 8, "ack": 16, + "urg": 32, "ecn": 64, "cwr": 128} + for expression in expressions: + if not isinstance(expression, dict) or not isinstance(expression.get("match", {}), dict): + continue + match = expression.get("match", {}) + left = match.get("left") + if match and not {"left", "right", "op"}.issubset(match): + raise UvmError("invalid nft match snapshot") + right = match.get("right") + if left in (_payload("ip", "protocol"), _ct("protocol")) and type(right) is int: + match["right"] = {1: "icmp", 6: "tcp", 17: "udp"}.get(right, right) + if match.get("op") == "in" and ( + (isinstance(right, str) and right.startswith("@")) + or (isinstance(right, dict) and set(right) == {"set"}) + ): + match["op"] = "==" + if left == _ct("state"): + match["right"] = bitmask(match["right"], { + "invalid": 1, "established": 2, "related": 4, "new": 8, "untracked": 64, + }) + if (isinstance(left, dict) and isinstance(left.get("&"), list) + and len(left["&"]) == 2 and left["&"][0] == _payload("tcp", "flags")): + left["&"][1] = bitmask(left["&"][1], tcp_flags) + match["right"] = bitmask(match["right"], tcp_flags) + + # nft's replayable JSON omits dependencies implied by named payloads. + encoded = json.dumps(expressions, sort_keys=True) + has_tcp = '"protocol": "tcp"' in encoded + has_icmp = '"protocol": "icmp"' in encoded + expressions = [expr for expr in expressions if not ( + (has_tcp and expr == _match(_payload("ip", "protocol"), "tcp")) + or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp")) + )] + encoded = json.dumps(expressions, sort_keys=True) + expressions = [expr for expr in expressions if not ( + ('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp")) + or (('"protocol": "ip"' in encoded or has_icmp) + and expr == _match(_payload("ether", "type"), "ip")) + )] + count = len(expressions) + if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}): + count -= 1 + if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]): + expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:] + obj["expr"] = expressions + rules.setdefault(obj.get("chain", ""), []).append(obj) + definitions.sort(key=lambda item: json.dumps(item, sort_keys=True)) + return json.dumps([definitions, rules], sort_keys=True, separators=(",", ":")) diff --git a/uvm/images.py b/uvm/images.py index f31801d..edff748 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -58,23 +58,14 @@ class ImageStore: self._runner = runner or CommandRunner() def installed_assets(self) -> GuestAssets: - kernel = self._settings.kernel_image + kernel = self.installed_kernel() rootfs = self._settings.rootfs_image if not kernel.exists() or not rootfs.exists(): raise UvmError("guest assets missing. Run: sudo uvm install") manifest = load_manifest(self._settings.integrity_manifest_path) - kernel_checksum = self._settings.kernel_sha256 rootfs_checksum = self._settings.rootfs_sha256 if manifest.verified or self._settings.allow_unverified_downloads: - kernel_checksum = kernel_checksum or manifest.checksums.get("kernel") rootfs_checksum = rootfs_checksum or manifest.checksums.get("rootfs") - verify_file( - kernel, - kernel_checksum, - "guest kernel", - "UVM_KERNEL_SHA256", - allow_unverified=self._settings.allow_unverified_downloads, - ) verify_file( rootfs, rootfs_checksum, @@ -84,6 +75,19 @@ class ImageStore: ) return GuestAssets(kernel=kernel, rootfs=rootfs) + def installed_kernel(self) -> Path: + """A retained private disk does not depend on the original rootfs template.""" + kernel = self._settings.kernel_image + if not kernel.is_file(): + raise UvmError("guest kernel missing. Run: sudo uvm install") + manifest = load_manifest(self._settings.integrity_manifest_path) + checksum = self._settings.kernel_sha256 + if manifest.verified or self._settings.allow_unverified_downloads: + checksum = checksum or manifest.checksums.get("kernel") + verify_file(kernel, checksum, "guest kernel", "UVM_KERNEL_SHA256", + allow_unverified=self._settings.allow_unverified_downloads) + return kernel + def create_vm_disk(self, source: Path, destination: Path) -> Path: """Copy the template before Firecracker opens it read-write for a VM.""" diff --git a/uvm/install.py b/uvm/install.py index e381208..3378df6 100644 --- a/uvm/install.py +++ b/uvm/install.py @@ -110,6 +110,8 @@ class Installer: "jq", "iproute2", "iptables", + "nftables", + "conntrack", "e2fsprogs", "util-linux", "ca-certificates", diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index bcf2444..2215070 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -1,43 +1,49 @@ -"""Ordered VM lifecycle operations and compensation for partial failures.""" +"""Serialized lifecycle and private-port policy changes with durable intent.""" from __future__ import annotations +import re import shutil import time -from collections.abc import Callable -from dataclasses import dataclass +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace from pathlib import Path from .config import Settings -from .domain import VmRecord, VmSpec, new_vm_id -from .errors import TapCreationError, UvmError +from .domain import PortRule, VmRecord, VmSpec, new_vm_id +from .errors import ConflictError, NotFoundError, UvmError, ValidationError from .firecracker.api import FirecrackerClient from .firecracker.config import build_config, write_config -from .firecracker.process import FirecrackerProcessManager +from .firecracker.process import FirecrackerProcessManager, ProcessInfo from .images import ImageStore from .integrity import load_manifest, verify_file from .network import NetworkManager from .state import StateStore from .system import check_kvm, ensure_data_directories, require_root +from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username @dataclass(frozen=True, slots=True) class ListedVm: vm: VmRecord observed_status: str + policy_status: str = "unknown" + + +@dataclass(frozen=True, slots=True) +class OperationResult: + id: str + action: str + outcome: str + error: str | None = None class LifecycleService: - """The only module allowed to coordinate multiple VM host resources.""" + """All mutating entry points use operation lock -> short state transaction.""" def __init__( - self, - settings: Settings, - state_store: StateStore, - images: ImageStore, - network: NetworkManager, - process: FirecrackerProcessManager, - *, + self, settings: Settings, state_store: StateStore, images: ImageStore, + network: NetworkManager, process: FirecrackerProcessManager, *, client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient, clock: Callable[[], float] = time.time, ) -> None: @@ -50,235 +56,452 @@ class LifecycleService: self._clock = clock def create(self, spec: VmSpec) -> VmRecord: - """Reserve identity first, then create all VM resources in dependency order.""" - require_root() + parse_cpu(str(spec.cpu)) + if type(spec.ram_mib) is not int or spec.ram_mib < 128: + raise ValidationError("RAM must be an integer of at least 128 MiB") + parse_username(spec.username) + parse_password(spec.password) + if spec.name is not None: + parse_name(spec.name) + ports = self._validated_ports(spec.ports) ensure_data_directories(self._settings) - check_kvm() - if not self._settings.firecracker_binary.exists(): - raise UvmError("Firecracker is not installed. Run: sudo uvm install") with self._state_store.operation_lock(): - manifest = load_manifest(self._settings.integrity_manifest_path) - firecracker_checksum = self._settings.firecracker_binary_sha256 - if manifest.verified or self._settings.allow_unverified_downloads: - firecracker_checksum = firecracker_checksum or manifest.checksums.get("firecracker") - verify_file( - self._settings.firecracker_binary, - firecracker_checksum, - "Firecracker binary", - "UVM_FIRECRACKER_BINARY_SHA256", - allow_unverified=self._settings.allow_unverified_downloads, - ) - return self._create_locked(spec) - - def _create_locked(self, spec: VmSpec) -> VmRecord: - assets = self._images.installed_assets() - vm = self._reserve_vm(spec) - - tap_created = False - try: - runtime_dir = self._settings.vm_dir(vm.id) - runtime_dir.mkdir(parents=True, exist_ok=False) - runtime_dir.chmod(0o700) - disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk)) - self._images.provision_credentials(disk, vm.username, vm.password) - self._network.ensure_bridge() + self._check_boot() + assets = self._images.installed_assets() + vm = self._reserve_vm(replace(spec, ports=ports)) try: - self._network.create_tap(vm.tap) - except TapCreationError as error: - tap_created = error.tap_created - raise - else: - tap_created = True + runtime = self._settings.vm_dir(vm.id) + runtime.mkdir(mode=0o700, exist_ok=False) + vm.runtime_owned = True + self._save(vm) + disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk)) + self._images.provision_credentials(disk, vm.username, vm.password) + vm.disk_ready = True + self._save(vm) + self._boot(vm, assets.kernel) + return vm + except BaseException as error: + cleanup_error = None + try: + if vm.operation != "preparing": + self._cleanup_runtime(vm) + self._remove_runtime(vm) + self._remove_record(vm.id) + except BaseException as cleanup: + cleanup_error = cleanup + message = f"failed to create {vm.id}: {error}" + if cleanup_error is not None: + message += f"; cleanup incomplete: {cleanup_error}" + self._record_failure(vm, message) + if isinstance(error, (KeyboardInterrupt, SystemExit)): + raise + raise UvmError(message) from error - config = build_config(self._settings, vm, assets.kernel, disk) - write_config(Path(vm.config), config) + def _check_boot(self) -> None: + check_kvm() + manifest = load_manifest(self._settings.integrity_manifest_path) + checksum = self._settings.firecracker_binary_sha256 + if manifest.verified or self._settings.allow_unverified_downloads: + checksum = checksum or manifest.checksums.get("firecracker") + verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary", + "UVM_FIRECRACKER_BINARY_SHA256", + allow_unverified=self._settings.allow_unverified_downloads) + self._network.firewall.check_available() - process_info = self._process.start(vm) - vm.pid = process_info.pid - vm.process_start_time = process_info.start_time - vm.updated_at = self._now() - self._replace_vm(vm) + def _boot(self, vm: VmRecord, kernel: Path) -> None: + vm.status, vm.operation = "starting", "network" + vm.pid = vm.process_start_time = None + vm.spawn_pending = False + self._save(vm) + self._network.ensure_bridge() + self._network.prepare_tap(vm) + self._save(vm) + self._network.firewall.apply(vm) + self._network.activate_tap(vm) + config = build_config(self._settings, vm, kernel, Path(vm.disk)) + write_config(Path(vm.config), config) - client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s) - client.configure_and_start(config) + # Persist the crash window before spawning, but give start() the already + # verified process-free record. A restored pending spawn is never guessed gone. + vm.operation = "boot" + vm.boot_id = self._process.host_boot_id() + self._save(replace(vm, operation="spawning", spawn_pending=True)) + + def spawned(info: ProcessInfo) -> None: + vm.pid, vm.process_start_time, vm.boot_id = info.pid, info.start_time, info.boot_id + vm.spawn_pending = False + vm.operation = "configuring" + self._save(vm) + + self._process.start(vm, on_spawn=spawned) + client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s) + client.configure_and_start(config) + if self._network.firewall.status(vm) != "enforced": + raise UvmError("private-port enforcement could not be confirmed after boot") + vm.status, vm.operation, vm.last_error = "running", None, None + self._save(vm) + + def start(self, identifier: str) -> VmRecord: + require_root() + with self._state_store.operation_lock(): + return self._start_locked(self._load_for_operation(identifier)) + + def _start_locked(self, vm: VmRecord) -> VmRecord: + self._not_deleting(vm) + if vm.ports is None: + raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start") + observation = self._process.observe(vm) + if observation == "unknown": + raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying") + if observation == "alive": + if vm.status != "running" or vm.operation not in {None, "policy"}: + raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting") + return self._sync_ports_locked(vm) + if not vm.disk_ready: + raise ConflictError(f"{vm.id} has no completely provisioned private disk") + disk = Path(vm.disk) + if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1: + raise UvmError(f"private VM disk is missing or unsafe: {disk}") + if (vm.gateway != str(self._settings.gateway) + or vm.network not in {None, str(self._settings.network)} + or vm.bridge not in {None, self._settings.bridge}): + raise ConflictError("configured network differs from the VM's retained network") + self._check_boot() + kernel = self._images.installed_kernel() + try: + self._cleanup_runtime(vm) + vm.network, vm.bridge = str(self._settings.network), self._settings.bridge + self._boot(vm, kernel) + return vm except BaseException as error: - cleanup_errors = self._rollback_create(vm, tap_created) - message = f"failed to create {vm.id}: {error}" - if cleanup_errors: - message = f"{message}; cleanup failed: {'; '.join(cleanup_errors)}" - vm.status = "failed" - vm.last_error = message - vm.updated_at = self._now() - self._replace_vm_if_present(vm) - else: - self._remove_vm_if_present(vm.id) + message = f"failed to start {vm.id}: {error}" + try: + self._cleanup_runtime(vm) + except BaseException as cleanup: + message += f"; cleanup incomplete: {cleanup}" + self._record_failure(vm, message) if isinstance(error, (KeyboardInterrupt, SystemExit)): raise - if isinstance(error, UvmError): - raise error raise UvmError(message) from error - vm.status = "running" - vm.last_error = None - vm.updated_at = self._now() - self._replace_vm(vm) - return vm + def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord: + require_root() + with self._state_store.operation_lock(): + return self._stop_locked(self._load_for_operation(identifier), timeout=timeout) + + def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord: + deleting = vm.operation == "delete" or vm.status == "terminating" + vm.status, vm.operation = "stopping", "delete" if deleting else "stop" + self._save(vm) + try: + self._cleanup_runtime(vm, timeout=timeout) + vm.status = "stopped" + vm.operation = "delete" if deleting else None + vm.last_error = None + self._save(vm) + return vm + except BaseException as error: + self._record_failure(vm, f"failed to stop {vm.id}: {error}") + if isinstance(error, (KeyboardInterrupt, SystemExit)): + raise + raise UvmError(vm.last_error) from error + + def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord: + require_root() + with self._state_store.operation_lock(): + vm = self._load_for_operation(identifier) + self._not_deleting(vm) + self._stop_locked(vm, timeout=timeout) + return self._start_locked(vm) + + def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord: + require_root() + with self._state_store.operation_lock(): + return self._delete_locked(self._load_for_operation(identifier), timeout=timeout) + + def _delete_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord: + vm.status, vm.operation = "terminating", "delete" + self._save(vm) + try: + self._cleanup_runtime(vm, timeout=timeout) + self._remove_runtime(vm) + self._remove_record(vm.id) + return vm + except BaseException as error: + self._record_failure(vm, f"failed to delete {vm.id}: {error}") + if isinstance(error, (KeyboardInterrupt, SystemExit)): + raise + raise UvmError(vm.last_error) from error + + def _cleanup_runtime(self, vm: VmRecord, *, timeout: float | None = None) -> None: + containment_errors = [] + try: + if self._network.has_tap(vm): + try: + self._network.firewall.apply(vm, quarantine=True) + finally: + self._network.deactivate_tap(vm) + except UvmError as error: + containment_errors.append(str(error)) + try: + self._process.terminate(vm, timeout=timeout) + except UvmError as error: + detail = f"; containment: {'; '.join(containment_errors)}" if containment_errors else "" + raise UvmError(f"process exit unconfirmed: {error}{detail}") from error + vm.pid = vm.process_start_time = None + vm.spawn_pending = False + if vm.operation == "spawning": + vm.operation = "boot" + self._network.delete_vm_tap(vm) + self._network.firewall.cleanup_connections(vm) + self._network.firewall.remove(vm) + + def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord: + require_root() + ports = self._validated_ports(ports) + if mode not in {"open", "close", "set"}: + raise ValidationError(f"invalid port operation: {mode}") + with self._state_store.operation_lock(): + vm = self._load_for_operation(identifier) + self._not_deleting(vm) + observation = self._process.observe(vm) + if observation == "unknown": + raise ConflictError("port changes require a known process identity") + if vm.ports is None and (mode != "set" or observation != "gone" or vm.status != "stopped"): + raise ConflictError("legacy VM policy must be explicitly set while stopped") + if vm.operation not in {None, "policy"}: + raise ConflictError(f"finish the pending {vm.operation} operation before editing ports") + current = {port.port: port for port in vm.ports or ()} + if mode == "set": + current = {port.port: port for port in ports} + elif mode == "open": + for port in ports: + current.setdefault(port.port, port) + else: + for port in ports: + current.pop(port.port, None) + vm.ports = tuple(current[number] for number in sorted(current)) + vm.operation = "policy" + self._save(vm) + return self._sync_ports_locked(vm) + + def sync_ports(self, identifier: str) -> VmRecord: + require_root() + with self._state_store.operation_lock(): + return self._sync_ports_locked(self._load_for_operation(identifier)) + + def _sync_ports_locked(self, vm: VmRecord) -> VmRecord: + self._not_deleting(vm) + if vm.ports is None: + raise ConflictError("legacy VM has no managed policy; stop it and use ports set") + if vm.operation not in {None, "policy"}: + raise ConflictError(f"finish the pending {vm.operation} operation before syncing ports") + observation = self._process.observe(vm) + if observation == "unknown": + raise ConflictError("cannot enforce policy for a VM with unknown process identity") + vm.operation = "policy" + self._save(vm) + try: + if observation == "alive": + if not self._network.has_tap(vm): + raise UvmError("running VM has no owned network attachment") + self._network.firewall.apply(vm) + self._network.activate_tap(vm) + vm.status = "running" + else: + if vm.status != "stopped" or vm.tap_owned: + self._cleanup_runtime(vm) + vm.status = "stopped" + vm.operation, vm.last_error = None, None + self._save(vm) + return vm + except BaseException as error: + # Never restore an older, more permissive policy after a failed close. + containment_errors = [] + for contain in ( + lambda: self._network.deactivate_tap(vm), + lambda: self._network.firewall.apply(vm, quarantine=True), + ): + try: + contain() + except UvmError as containment_error: + containment_errors.append(str(containment_error)) + detail = f"; containment errors: {'; '.join(containment_errors)}" if containment_errors else "" + self._record_failure(vm, f"policy pending for {vm.id}: {error}{detail}") + if isinstance(error, (KeyboardInterrupt, SystemExit)): + raise + raise UvmError(vm.last_error) from error + + def resolve_targets(self, identifiers: Sequence[str], *, all_vms: bool = False) -> list[VmRecord]: + if all_vms and identifiers: + raise ValidationError("--all cannot be combined with explicit VM targets") + state = self._state_store.load() + if all_vms: + return list(state.vms.values()) + if not identifiers: + raise ValidationError("specify a VM or --all") + resolved = [self._state_store.resolve(state, identifier) for identifier in identifiers] + return list({vm.id: vm for vm in resolved}.values()) + + def run_many(self, action: str, vm_ids: Sequence[str], *, timeout: float | None = None) -> list[OperationResult]: + require_root() + if action not in {"start", "stop", "restart", "delete", "ports-sync"}: + raise ValidationError(f"invalid lifecycle action: {action}") + results = [] + with self._state_store.operation_lock(): + for index, vm_id in enumerate(vm_ids): + try: + vm = self._load_for_operation(vm_id, exact_id=True) + if action == "start": + self._start_locked(vm) + elif action == "stop": + self._stop_locked(vm, timeout=timeout) + elif action == "delete": + self._delete_locked(vm, timeout=timeout) + elif action == "ports-sync": + self._sync_ports_locked(vm) + else: + self._not_deleting(vm) + self._stop_locked(vm, timeout=timeout) + self._start_locked(vm) + results.append(OperationResult(vm_id, action, "succeeded")) + except NotFoundError: + results.append(OperationResult(vm_id, action, "skipped", "already absent")) + except KeyboardInterrupt: + results.append(OperationResult(vm_id, action, "interrupted", "interrupted")) + results.extend(OperationResult(remaining, action, "skipped", "not attempted after interruption") + for remaining in vm_ids[index + 1:]) + break + except (UvmError, OSError) as error: + results.append(OperationResult(vm_id, action, "failed", str(error))) + return results def list_vms(self) -> list[ListedVm]: - state = self._state_store.load() - listed: list[ListedVm] = [] - for vm in state.vms.values(): - observed_status = vm.status - if vm.status in {"starting", "running", "stopping", "terminating"}: - if not self._process.is_alive(vm): - observed_status = "dead" - listed.append(ListedVm(vm=vm, observed_status=observed_status)) - return listed + return [self._listed(vm) for vm in self._state_store.load().vms.values()] + + def inspect(self, identifier: str) -> ListedVm: + return self._listed(self._state_store.find_by_id_or_ip(identifier)) + + def _listed(self, vm: VmRecord) -> ListedVm: + observation = self._process.observe(vm) + status = vm.status + if observation == "unknown": + status = "unknown" + elif observation == "alive" and status == "stopped": + status = "running" + elif observation == "gone" and status == "running": + status = "dead" + policy = ("inactive" if observation == "gone" and vm.status == "stopped" and vm.operation is None + else self._network.firewall.status(vm)) + if vm.ports is None and policy == "inactive": + policy = "unmanaged" + return ListedVm(vm, status, policy) def find_for_ssh(self, identifier: str) -> VmRecord: return self._state_store.find_by_id_or_ip(identifier) - def stop(self, vm_id: str) -> VmRecord: - """Stop one VM while retaining its private writable disk for future use.""" + def log_path(self, identifier: str) -> Path: + vm = self._state_store.find_by_id_or_ip(identifier) + self._validate_paths(vm) + return Path(vm.log) - require_root() - with self._state_store.operation_lock(): - return self._stop_locked(vm_id) - - def _stop_locked(self, vm_id: str) -> VmRecord: - vm = self._begin_transition(vm_id, "stopping") - try: - self._process.terminate(vm) - self._network.delete_tap(vm.tap) - except BaseException as error: - vm.status = "failed" - vm.last_error = f"failed to stop VM: {error}" - vm.updated_at = self._now() - self._replace_vm_if_present(vm) - if isinstance(error, (KeyboardInterrupt, SystemExit)): - raise - if isinstance(error, UvmError): - raise error - raise UvmError(vm.last_error) from error - - vm.status = "stopped" - vm.pid = None - vm.process_start_time = None - vm.last_error = None - vm.updated_at = self._now() - self._replace_vm(vm) - return vm - - def destroy(self, vm_id: str) -> VmRecord: - """Stop a VM, remove its private resources, then release its allocation.""" - - require_root() - with self._state_store.operation_lock(): - return self._destroy_locked(vm_id) - - def _destroy_locked(self, vm_id: str) -> VmRecord: - vm = self._begin_transition(vm_id, "terminating") - try: - self._process.terminate(vm) - self._network.delete_tap(vm.tap) + def diagnostics(self) -> list[dict]: + checks = [("root", require_root), ("kvm", check_kvm), + ("guest assets", self._images.installed_assets), + ("private firewall", self._network.firewall.check_available)] + results = [] + for name, check in checks: try: - shutil.rmtree(self._settings.vm_dir(vm.id)) - except FileNotFoundError: - pass - except BaseException as error: - vm.status = "failed" - vm.last_error = f"failed to destroy VM: {error}" - vm.updated_at = self._now() - self._replace_vm_if_present(vm) - if isinstance(error, (KeyboardInterrupt, SystemExit)): - raise - if isinstance(error, UvmError): - raise error - raise UvmError(vm.last_error) from error + check() + results.append({"name": name, "ok": True, "message": "available"}) + except (UvmError, OSError) as error: + results.append({"name": name, "ok": False, "message": str(error)}) + for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"): + path = shutil.which(command) + results.append({"name": command, "ok": path is not None, "message": path or "not installed"}) + return results - self._remove_vm_if_present(vm.id) + def _load_for_operation(self, identifier: str, *, exact_id: bool = False) -> VmRecord: + vm = (self._state_store.get(identifier) if exact_id + else self._state_store.find_by_id_or_ip(identifier)) + self._validate_paths(vm) + if self._process.adopt_legacy_identity(vm): + self._save(vm) return vm + def _validate_paths(self, vm: VmRecord) -> None: + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None: + raise ConflictError("invalid persisted VM ID; refusing resource access") + directory = self._settings.vm_dir(vm.id).absolute() + if directory.is_symlink(): + raise ConflictError("VM directory must not be a symbolic link") + for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"), + ("log", "firecracker.log"), ("disk", "rootfs.ext4")): + path = getattr(vm, field) + if field == "disk" and not path: + continue # Legacy records may have used a shared template, never delete it. + if Path(path).absolute() != directory / filename or Path(path).is_symlink(): + raise ConflictError(f"unsafe persisted VM {field} path") + + @staticmethod + def _not_deleting(vm: VmRecord) -> None: + if vm.operation == "delete" or vm.status == "terminating": + raise ConflictError(f"deletion is pending for {vm.id}; retry delete instead") + + @staticmethod + def _validated_ports(ports: tuple[PortRule, ...]) -> tuple[PortRule, ...]: + if not isinstance(ports, tuple) or any(not isinstance(port, PortRule) for port in ports): + raise ValidationError("ports must be a tuple of private TCP port rules") + return parse_ports([f"{port.port}:{port.protocol}" for port in ports]) + def _reserve_vm(self, spec: VmSpec) -> VmRecord: with self._state_store.transaction() as state: + if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()): + raise ConflictError(f"VM name already exists: {spec.name}") vm_id = new_vm_id() - used_taps = {existing_vm.tap for existing_vm in state.vms.values()} - while vm_id in state.vms or self._network.tap_name(vm_id) in used_taps: + used_taps = {vm.tap for vm in state.vms.values()} + while vm_id in state.vms or any(vm.name == vm_id for vm in state.vms.values()) or self._network.tap_name(vm_id) in used_taps: vm_id = new_vm_id() guest_ip = self._network.allocate_ip(state.vms.values(), spec.guest_ip) mac = self._network.mac_for(state.next_mac_index) state.next_mac_index += 1 - now = self._now() - runtime_dir = self._settings.vm_dir(vm_id) + directory = self._settings.vm_dir(vm_id) vm = VmRecord( - id=vm_id, - cpu=spec.cpu, - ram_mib=spec.ram_mib, - guest_ip=str(guest_ip), - gateway=str(self._settings.gateway), - tap=self._network.tap_name(vm_id), - mac=mac, - socket=str(runtime_dir / "firecracker.sock"), - config=str(runtime_dir / "config.json"), - log=str(runtime_dir / "firecracker.log"), - disk=str(runtime_dir / "rootfs.ext4"), - username=spec.username, - password=spec.password, - status="starting", - created_at=now, - updated_at=now, + id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, + guest_ip=str(guest_ip), gateway=str(self._settings.gateway), + network=str(self._settings.network), bridge=self._settings.bridge, + tap=self._network.tap_name(vm_id), mac=mac, + socket=str(directory / "firecracker.sock"), config=str(directory / "config.json"), + log=str(directory / "firecracker.log"), disk=str(directory / "rootfs.ext4"), + username=spec.username, password=spec.password, ports=spec.ports, + status="starting", operation="preparing", created_at=int(self._clock()), updated_at=int(self._clock()), ) state.vms[vm.id] = vm return vm - def _begin_transition(self, vm_id: str, target_status: str) -> VmRecord: - with self._state_store.transaction() as state: - try: - vm = state.vms[vm_id] - except KeyError as error: - raise UvmError(f"VM not found: {vm_id}") from error - if vm.status in {"stopping", "terminating"}: - raise UvmError(f"VM operation is already in progress: {vm_id}") - vm.status = target_status - vm.updated_at = self._now() - state.vms[vm_id] = vm - return vm + def _remove_runtime(self, vm: VmRecord) -> None: + self._validate_paths(vm) + directory = self._settings.vm_dir(vm.id) + if directory.exists(): + if not vm.runtime_owned: + raise ConflictError(f"refusing to remove unowned VM directory {directory}") + if self._process.observe(vm) != "gone": + raise ConflictError("refusing to remove disk without confirmed process exit") + shutil.rmtree(directory) + vm.runtime_owned = False - def _replace_vm(self, vm: VmRecord) -> None: + def _save(self, vm: VmRecord) -> None: + vm.updated_at = int(self._clock()) with self._state_store.transaction() as state: if vm.id not in state.vms: - raise UvmError(f"VM not found: {vm.id}") + raise NotFoundError(f"VM not found: {vm.id}") state.vms[vm.id] = vm - def _replace_vm_if_present(self, vm: VmRecord) -> None: - with self._state_store.transaction() as state: - if vm.id in state.vms: - state.vms[vm.id] = vm + def _record_failure(self, vm: VmRecord, message: str) -> None: + vm.status, vm.last_error = "failed", message + self._save(vm) - def _remove_vm_if_present(self, vm_id: str) -> None: + def _remove_record(self, vm_id: str) -> None: with self._state_store.transaction() as state: state.vms.pop(vm_id, None) - - def _rollback_create(self, vm: VmRecord, tap_created: bool) -> list[str]: - errors: list[str] = [] - if vm.pid is not None: - try: - self._process.terminate(vm) - except Exception as error: - errors.append(f"process: {error}") - if tap_created: - try: - self._network.delete_tap(vm.tap) - except Exception as error: - errors.append(f"network: {error}") - try: - shutil.rmtree(self._settings.vm_dir(vm.id)) - except FileNotFoundError: - pass - except OSError as error: - errors.append(f"files: {error}") - return errors - - def _now(self) -> int: - return int(self._clock()) diff --git a/uvm/network.py b/uvm/network.py index ea14571..0e5f833 100644 --- a/uvm/network.py +++ b/uvm/network.py @@ -9,7 +9,8 @@ from ipaddress import IPv4Address from .config import Settings from .domain import VmRecord -from .errors import TapCreationError, UvmError, ValidationError +from .errors import ConflictError, TapCreationError, UvmError, ValidationError +from .firewall import FirewallManager from .system import CommandRunner @@ -19,6 +20,7 @@ class NetworkManager: def __init__(self, settings: Settings, runner: CommandRunner) -> None: self._settings = settings self._runner = runner + self.firewall = FirewallManager(settings, runner) def allocate_ip( self, @@ -95,6 +97,69 @@ class NetworkManager: raise UvmError(f"could not determine whether existing interface {bridge} is a bridge") from error if kind != "bridge": raise UvmError(f"configured bridge {bridge} exists but is not a Linux bridge") + if links[0]["linkinfo"].get("info_data", {}).get("stp_state", 0) != 0: + raise UvmError("private ports require an STP-disabled UVM bridge") + + def prepare_tap(self, vm: VmRecord) -> None: + """Create an owned, attached TAP down; policy must precede activation.""" + if self._link(vm.tap) is not None: + raise ConflictError(f"TAP device already exists: {vm.tap}") + try: + self._runner.run(("ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"), capture=True) + vm.tap_owned = True + self._runner.run(("ip", "link", "set", "dev", vm.tap, "alias", f"uvm:{vm.id}"), capture=True) + self._runner.run(("ip", "link", "set", vm.tap, "master", vm.bridge or self._settings.bridge), capture=True) + self._runner.run(("ip", "link", "set", "dev", vm.tap, "type", "bridge_slave", + "group_fwd_mask", "16384"), capture=True) + except BaseException: + if vm.tap_owned: + try: + self.delete_tap(vm.tap) + vm.tap_owned = False + except UvmError: + pass + raise + + def activate_tap(self, vm: VmRecord) -> None: + if self._owned_link(vm) is None: + raise UvmError(f"TAP device missing: {vm.tap}") + self._runner.run(("ip", "link", "set", vm.tap, "up"), capture=True) + + def deactivate_tap(self, vm: VmRecord) -> None: + if self._owned_link(vm) is not None: + self._runner.run(("ip", "link", "set", vm.tap, "down"), capture=True) + + def delete_vm_tap(self, vm: VmRecord) -> None: + if self._owned_link(vm) is not None: + self.delete_tap(vm.tap) + vm.tap_owned = False + + def has_tap(self, vm: VmRecord) -> bool: + return self._owned_link(vm) is not None + + def _link(self, name: str) -> dict | None: + result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True) + try: + links = json.loads(result.stdout) + if not isinstance(links, list) or any(not isinstance(link, dict) for link in links): + raise ValueError("invalid interface inventory") + matches = [link for link in links if link.get("ifname") == name] + if len(matches) > 1: + raise ValueError("ambiguous interface inventory") + return matches[0] if matches else None + except (ValueError, TypeError) as error: + raise UvmError(f"cannot inspect network interfaces: {error}") from error + + def _owned_link(self, vm: VmRecord) -> dict | None: + link = self._link(vm.tap) + if link is None: + return None + info = link.get("linkinfo", {}) + is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap" + legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias") + if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy): + raise ConflictError(f"refusing to modify unowned TAP {vm.tap}") + return link def create_tap(self, name: str) -> None: existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True) diff --git a/uvm/routers/vms.py b/uvm/routers/vms.py index 9d6b510..6f93344 100644 --- a/uvm/routers/vms.py +++ b/uvm/routers/vms.py @@ -2,12 +2,12 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response from ..app import Application -from ..domain import VmSpec -from ..validation import parse_cpu, parse_password, parse_ram, parse_username +from ..domain import PortRule, VmSpec +from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username from .dependencies import get_authorized_application @@ -19,7 +19,11 @@ def list_vms( application: Application = Depends(get_authorized_application), ) -> list[VmResponse]: return [ - vm_response(listed.vm, observed_status=listed.observed_status) + vm_response( + listed.vm, + observed_status=listed.observed_status, + policy_status=listed.policy_status, + ) for listed in application.lifecycle.list_vms() ] @@ -29,6 +33,9 @@ def create_vm( request: VmCreateRequest, application: Application = Depends(get_authorized_application), ) -> VmResponse: + ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports or ()]) + if not request.no_default_ports and not any(rule.port == 22 for rule in ports): + ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port)) vm = application.lifecycle.create( VmSpec( cpu=parse_cpu(str(request.cpu)), @@ -36,9 +43,14 @@ def create_vm( guest_ip=request.guest_ip, username=parse_username(request.username), password=parse_password(request.password.get_secret_value()), + name=parse_name(request.name) if request.name is not None else None, + ports=ports, ) ) - return vm_response(vm) + policy_status = "unmanaged" if vm.ports is None else "unknown" + if vm.ports is not None and vm.status == "running" and vm.operation is None: + policy_status = "enforced" + return vm_response(vm, policy_status=policy_status) @router.post("/{vm_id}/stop", response_model=VmResponse) @@ -46,7 +58,7 @@ def stop_vm( vm_id: str, application: Application = Depends(get_authorized_application), ) -> VmResponse: - return vm_response(application.lifecycle.stop(vm_id)) + return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive") @router.delete("/{vm_id}", response_model=DestroyResponse) @@ -63,8 +75,9 @@ def get_vm( identifier: str, application: Application = Depends(get_authorized_application), ) -> VmResponse: - for listed in application.lifecycle.list_vms(): - vm = listed.vm - if identifier in {vm.id, vm.guest_ip}: - return vm_response(vm, observed_status=listed.observed_status) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="VM not found") + listed = application.lifecycle.inspect(identifier) + return vm_response( + listed.vm, + observed_status=listed.observed_status, + policy_status=listed.policy_status, + ) diff --git a/uvm/server.py b/uvm/server.py index effb3fe..fa2cd7e 100644 --- a/uvm/server.py +++ b/uvm/server.py @@ -8,7 +8,7 @@ import ssl from typing import Any from .app import Application, build_application -from .errors import ConfigurationError, UvmError, ValidationError +from .errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError def create_api(application: Application | None = None, *, host: str | None = None) -> Any: @@ -115,13 +115,12 @@ def _validate_server_settings( def _http_status_for(error: UvmError) -> int: + if isinstance(error, NotFoundError): + return 404 + if isinstance(error, ConflictError): + return 409 if isinstance(error, (ConfigurationError, ValidationError)): return 422 - message = str(error) - if message.startswith("VM not found:"): - return 404 - if "operation is already in progress" in message: - return 409 - if "needs root" in message: + if "needs root" in str(error): return 403 return 500 diff --git a/uvm/state.py b/uvm/state.py index ccea924..18b4353 100644 --- a/uvm/state.py +++ b/uvm/state.py @@ -5,6 +5,7 @@ from __future__ import annotations import fcntl import json import os +import re import tempfile from collections.abc import Iterator from contextlib import contextmanager @@ -12,7 +13,7 @@ from pathlib import Path from .config import Settings from .domain import State, VmRecord -from .errors import StateError, UvmError +from .errors import ConflictError, NotFoundError, StateError from .system import ensure_data_directories @@ -63,16 +64,24 @@ class StateStore: try: return state.vms[vm_id] except KeyError as error: - raise UvmError(f"VM not found: {vm_id}") from error + raise NotFoundError(f"VM not found: {vm_id}") from error def find_by_id_or_ip(self, identifier: str) -> VmRecord: - state = self.load() - if identifier in state.vms: - return state.vms[identifier] - for vm in state.vms.values(): - if vm.guest_ip == identifier: - return vm - raise UvmError(f"VM not found: {identifier}") + return self.resolve(self.load(), identifier) + + @staticmethod + def resolve(state: State, identifier: str) -> VmRecord: + if re.fullmatch(r"vm-[0-9a-f]{32}", identifier): + try: + return state.vms[identifier] + except KeyError as error: + raise NotFoundError(f"VM not found: {identifier}") from error + matches = [vm for vm in state.vms.values() if identifier in {vm.id, vm.name, vm.guest_ip}] + if len(matches) > 1: + raise ConflictError(f"ambiguous VM identifier: {identifier}; use an exact VM ID") + if not matches: + raise NotFoundError(f"VM not found: {identifier}") + return matches[0] @contextmanager def _locked(self) -> Iterator[None]: diff --git a/uvm/system.py b/uvm/system.py index a585e19..cdbe5f2 100644 --- a/uvm/system.py +++ b/uvm/system.py @@ -5,6 +5,7 @@ from __future__ import annotations import os import shlex import subprocess +import sys from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path @@ -47,7 +48,7 @@ class CommandRunner: check=False, text=True, input=input_text, - stdout=subprocess.PIPE if capture else None, + stdout=subprocess.PIPE if capture else sys.stderr, stderr=subprocess.PIPE if capture else None, timeout=timeout, ) diff --git a/uvm/validation.py b/uvm/validation.py index 8eb55e5..3d28652 100644 --- a/uvm/validation.py +++ b/uvm/validation.py @@ -4,9 +4,11 @@ from __future__ import annotations import math import re +from collections.abc import Sequence from decimal import Decimal, InvalidOperation from .errors import ValidationError +from .domain import PortRule _RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE) @@ -81,3 +83,35 @@ def parse_password(value: str) -> str: if any(ord(character) < 32 or ord(character) == 127 for character in value): raise ValidationError("password must not contain control characters") return value + + +def parse_name(value: str) -> str: + if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None: + raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter") + if re.fullmatch(r"vm-[0-9a-f]{32}", value): + raise ValidationError("VM names must not impersonate generated VM IDs") + return value + + +def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]: + ports: dict[int, PortRule] = {} + for value in values: + for item in value.split(","): + match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE) + if match is None: + raise ValidationError(f"invalid private port: {item!r}; use PORT[:TCP|HTTP|HTTPS]") + rule = PortRule(int(match.group(1)), match.group(2) or "TCP") + if rule.port in ports and ports[rule.port] != rule: + raise ValidationError(f"conflicting labels for TCP port {rule.port}") + ports[rule.port] = rule + return tuple(ports[number] for number in sorted(ports)) + + +def parse_timeout(value: str) -> float: + try: + timeout = float(value) + except (ValueError, TypeError) as error: + raise ValidationError("timeout must be a finite non-negative number of seconds") from error + if not math.isfinite(timeout) or timeout < 0: + raise ValidationError("timeout must be a finite non-negative number of seconds") + return timeout -- 2.54.0 From ee26962902046b64181613b80204369e9be53c09 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 9 Sep 2026 17:47:50 +0530 Subject: [PATCH 2/5] Refactor documentation structure and improve validation checks; update VM state handling and error management. --- DEVELOPER.md | 18 ++++++++++-------- README.md | 6 +++++- USAGE.md | 29 +++++++++++++++++------------ tests/test_network.py | 2 +- tests/test_state.py | 29 ++++++++++++++++++++++++++--- uvm/domain.py | 2 +- uvm/lifecycle.py | 16 ++++++++++++++-- uvm/network.py | 9 +++++++-- uvm/validation.py | 6 +++++- 9 files changed, 86 insertions(+), 31 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index c4bead8..d0b8124 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -58,7 +58,8 @@ Packet integration tests must remain inside disposable isolated namespaces. ```text ./ ├── DEVELOPER.md # This document -├── README.md # Operator-facing usage guide +├── README.md # Project overview +├── USAGE.md # Operator command/deployment guide ├── pyproject.toml # Package metadata and dependencies ├── uvm.py # Executable source-tree launcher ├── uvm/ @@ -345,12 +346,12 @@ processes, and allocation reuse. ### Start and Restart -Start requires managed ports, a proven-gone process (or an already identified -running VM), a complete safe private disk, and compatible retained gateway, -network, and bridge settings. It verifies the VMM and installed **kernel**; -the original rootfs template is not needed. It never recopies the rootfs, -reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC, -ports, and guest disk changes survive stop/start; guest RAM does not. +To boot a stopped/proven-gone VM, start requires managed ports, a complete safe +private disk, and compatible retained gateway, network, and bridge settings. +It verifies the VMM and installed **kernel**; the original rootfs template is +not needed. It never recopies the rootfs, reprovisions credentials, or +regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes +survive stop/start; guest RAM does not. Start on a healthy running VM synchronizes policy without spawning another VMM. An ambiguous process or interrupted live boot/stop is not adopted as a @@ -779,6 +780,7 @@ The packet test skipped in this workspace because namespace creation was denied (`Operation not permitted`). Document that permission limitation, not an assertion that real packets or a real host were validated. Unit tests and nft JSON parser checks are not substitutes for kernel packet-path execution. + ### Isolated Host Validation No current test automatically boots a real microVM. Before a release, validate @@ -841,7 +843,7 @@ The code is intentionally honest about current limits: - Firecracker is launched directly, even though the Jailer binary is downloaded. - No cgroup CPU, memory, I/O, or process limits are applied. -- Fractional CPU is represented by rounding vCPUs up and printing an advisory. +- Fractional CPU requests round vCPUs up without host quota enforcement. - Guest health is inferred from VMM process state, not application readiness. - No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy. - No guest snapshots, extra disks, production image builder, or SSH-key injection. diff --git a/README.md b/README.md index 56d2143..34ed9bd 100644 --- a/README.md +++ b/README.md @@ -386,9 +386,13 @@ Host and API settings can be overridden through environment variables: ## Project Layout +Selected files: + ```text ./ -├── README.md, USAGE.md, DEVELOPER.md +├── README.md +├── USAGE.md +├── DEVELOPER.md ├── pyproject.toml ├── uvm.py # Executable compatibility launcher ├── uvm/ # Private application implementation diff --git a/USAGE.md b/USAGE.md index 87f3b42..17274a3 100644 --- a/USAGE.md +++ b/USAGE.md @@ -97,6 +97,8 @@ SSH to a known guest IP does not require root. The following is syntax notation, not a shell script. `VM` means an exact VM ID, local name, or guest IPv4 address. Flags are command-specific; in particular, create has no `--dry-run`, and start has no `--timeout`. +Examples elsewhere are separate scenarios; substitute your VM identifiers +and do not run every destructive example as a single script. ```text uvm install [--force] @@ -235,7 +237,7 @@ literal secret in the command line or history. Do not enable shell tracing: ```bash sudo -v -read -r -s -p 'Guest password: ' guest_password +IFS= read -r -s -p 'Guest password: ' guest_password printf '\n' printf '%s\n' "$guest_password" | sudo ./uvm.py create --name app \ --cpu 1 --ram 1G --guest-ip 10.42.0.10 \ @@ -346,8 +348,9 @@ adopt a legacy process, or reconcile a VM. Policy status is a separate snapshot: | `unknown` | Inspection failed or metadata could not establish policy status. | `enforced` is not a service-connectivity test or a lasting guarantee against -external rule changes. Mutation JSON may report policy `unknown` without a -fresh observation; use inspect or ports list for a current readback. +external rule changes. Dry-run target snapshots may show policy `unknown` +because a preview does not verify enforcement. Use inspect or ports list for +a current readback. ### Logs and Doctor @@ -441,11 +444,12 @@ support, and compatible network settings are still required. Removing the original rootfs template does not prevent retained-disk start, although doctor checks templates and can report it missing. -Start on an already running, identified VM synchronizes its managed port -policy without rebooting. Restart is stop followed by start under the same -operation lock. Unknown process identity, an incomplete/unsafe disk, or pending -deletion blocks a new boot; inspect the error rather than editing registry -flags. An interrupted boot with a live VMM must be stopped before start. +Start on a VM recorded as running with a verified process identity synchronizes +its managed port policy without rebooting. Restart is stop followed by start +under the same operation lock. Unknown process identity, an incomplete/unsafe +disk, or pending deletion blocks a new boot; inspect the error rather than +editing registry flags. An interrupted boot with a live VMM must be stopped +before start. ### Scenario: Permanently Remove a VM @@ -508,10 +512,11 @@ or NAT can change reachability; a private IP is not by itself an exposure guarantee, and UVM does not integrate with external firewall managers yet. UDP and IPv6 service policies are not yet supported. Unsolicited inbound UDP -is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4 TCP/UDP (including -DNS queries), replies to that egress, ARP, ICMP echo, and required related ICMP -errors remain allowed. An empty TCP allowlist is not a complete network -disconnect. Other VMs' policies still apply to guest-to-guest egress. +is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4 +TCP/UDP (including DNS queries), replies to that egress, ARP, ICMP echo, and +required related ICMP errors remain allowed. An empty TCP allowlist is not a +complete network disconnect. Other VMs' policies still apply to guest-to-guest +egress. ### List, Open, Close, and Set diff --git a/tests/test_network.py b/tests/test_network.py index 9987395..fa583f2 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -396,7 +396,7 @@ class NetworkManagerTests(unittest.TestCase): self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}} self.runner.calls.clear() self.vm.tap_owned = True - with self.assertRaises(UvmError): + with self.assertRaises(ConflictError): getattr(self.network, operation)(self.vm) self.assertEqual(self.runner.calls, [LINK_INVENTORY]) self.assertIs(self.vm.tap_owned, True) diff --git a/tests/test_state.py b/tests/test_state.py index 6ab3df7..a217cf0 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -86,7 +86,7 @@ class StateStoreTests(unittest.TestCase): self.assertIsNone(vm.network) self.assertIsNone(vm.bridge) self.assertFalse(vm.disk_ready) - self.assertFalse(vm.runtime_owned) + self.assertTrue(vm.runtime_owned) self.assertFalse(vm.tap_owned) self.assertFalse(vm.spawn_pending) self.assertEqual(vm.updated_at, vm.created_at) @@ -103,6 +103,8 @@ class StateStoreTests(unittest.TestCase): self.assertEqual(persisted["next_mac_index"], 2) self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped") self.assertIsNone(persisted["vms"]["vm-test"]["ports"]) + self.assertIs(persisted["vms"]["vm-test"]["runtime_owned"], True) + self.assertIs(persisted["vms"]["vm-test"]["disk_ready"], False) def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None: for ports in (None, (), (PortRule(22), PortRule(8080, "http"))): @@ -258,8 +260,8 @@ class DomainStateTests(unittest.TestCase): ("stopped", "/tmp/rootfs.ext4", None, (True, True, False)), ("error", "/tmp/rootfs.ext4", 123, (False, True, True)), ("starting", "/tmp/rootfs.ext4", None, (False, True, False)), - ("running", "", 123, (False, False, True)), - ("unknown", "", None, (False, False, False)), + ("running", "", 123, (False, True, True)), + ("unknown", "", None, (False, True, False)), ) for status, disk, pid, expected in cases: with self.subTest(status=status, disk=disk, pid=pid): @@ -270,6 +272,27 @@ class DomainStateTests(unittest.TestCase): self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected) self.assertIsNone(vm.ports) + def test_empty_disk_legacy_runtime_is_owned_but_explicit_false_is_respected(self) -> None: + for disk_fields in ({}, {"disk": ""}): + with self.subTest(disk_fields=disk_fields): + raw = replace(make_vm(), status="stopped").to_dict() + for field in ("disk", "ports", "disk_ready", "runtime_owned"): + raw.pop(field) + raw.update(disk_fields) + + legacy = VmRecord.from_dict(raw) + self.assertEqual(legacy.disk, "") + self.assertIs(legacy.runtime_owned, True) + self.assertIs(legacy.disk_ready, False) + self.assertIsNone(legacy.ports) + self.assertEqual(VmRecord.from_dict(legacy.to_dict()), legacy) + + new = VmRecord.from_dict({**raw, "ports": [], "runtime_owned": False}) + self.assertIs(new.runtime_owned, False) + self.assertIs(new.disk_ready, False) + self.assertEqual(new.ports, ()) + self.assertEqual(VmRecord.from_dict(new.to_dict()), new) + def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None: for operation in (None, "spawning", "configuring", "stopping", "policy"): with self.subTest(operation=operation): diff --git a/uvm/domain.py b/uvm/domain.py index 40ac231..8d55afc 100644 --- a/uvm/domain.py +++ b/uvm/domain.py @@ -193,7 +193,7 @@ class VmRecord: name=str(value["name"]) if value.get("name") is not None else None, ports=ports, disk_ready=value.get("disk_ready", legacy_ready), - runtime_owned=value.get("runtime_owned", bool(value.get("disk"))), + runtime_owned=value.get("runtime_owned", True), tap_owned=value.get("tap_owned", bool(value.get("pid"))), operation=str(value["operation"]) if value.get("operation") is not None else None, boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None, diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index 2215070..4dd7484 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -7,6 +7,7 @@ import shutil import time from collections.abc import Callable, Sequence from dataclasses import dataclass, replace +from ipaddress import IPv4Network from pathlib import Path from .config import Settings @@ -99,6 +100,9 @@ class LifecycleService: raise UvmError(message) from error def _check_boot(self) -> None: + if not any(self._settings.network.subnet_of(IPv4Network(network)) + for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")): + raise ValidationError("private ports require a network inside an RFC 1918 IPv4 range") check_kvm() manifest = load_manifest(self._settings.integrity_manifest_path) checksum = self._settings.firecracker_binary_sha256 @@ -258,12 +262,13 @@ class LifecycleService: self._network.firewall.remove(vm) def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord: + """Mutate a frozen registry ID; the CLI resolves names before confirmation.""" require_root() ports = self._validated_ports(ports) if mode not in {"open", "close", "set"}: raise ValidationError(f"invalid port operation: {mode}") with self._state_store.operation_lock(): - vm = self._load_for_operation(identifier) + vm = self._load_for_operation(identifier, exact_id=True) self._not_deleting(vm) observation = self._process.observe(vm) if observation == "unknown": @@ -321,7 +326,8 @@ class LifecycleService: containment_errors = [] for contain in ( lambda: self._network.deactivate_tap(vm), - lambda: self._network.firewall.apply(vm, quarantine=True), + lambda: (self._network.firewall.apply(vm, quarantine=True) + if self._network.has_tap(vm) else None), ): try: contain() @@ -457,6 +463,12 @@ class LifecycleService: def _reserve_vm(self, spec: VmSpec) -> VmRecord: with self._state_store.transaction() as state: + for existing in state.vms.values(): + if (existing.guest_ip == str(self._settings.gateway) + or existing.gateway != str(self._settings.gateway) + or existing.network not in {None, str(self._settings.network)} + or existing.bridge not in {None, self._settings.bridge}): + raise ConflictError("configured topology conflicts with retained VMs; keep their network settings") if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()): raise ConflictError(f"VM name already exists: {spec.name}") vm_id = new_vm_id() diff --git a/uvm/network.py b/uvm/network.py index 0e5f833..4a9e393 100644 --- a/uvm/network.py +++ b/uvm/network.py @@ -141,7 +141,10 @@ class NetworkManager: result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True) try: links = json.loads(result.stdout) - if not isinstance(links, list) or any(not isinstance(link, dict) for link in links): + if not isinstance(links, list) or any( + not isinstance(link, dict) or not isinstance(link.get("ifname"), str) + or not link["ifname"] for link in links + ): raise ValueError("invalid interface inventory") matches = [link for link in links if link.get("ifname") == name] if len(matches) > 1: @@ -155,7 +158,9 @@ class NetworkManager: if link is None: return None info = link.get("linkinfo", {}) - is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap" + is_tap = (isinstance(info, dict) and info.get("info_kind") == "tun" + and isinstance(info.get("info_data"), dict) + and info["info_data"].get("type") == "tap") legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias") if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy): raise ConflictError(f"refusing to modify unowned TAP {vm.tap}") diff --git a/uvm/validation.py b/uvm/validation.py index 3d28652..f0bcc46 100644 --- a/uvm/validation.py +++ b/uvm/validation.py @@ -86,7 +86,7 @@ def parse_password(value: str) -> str: def parse_name(value: str) -> str: - if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None: + if not isinstance(value, str) or re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None: raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter") if re.fullmatch(r"vm-[0-9a-f]{32}", value): raise ValidationError("VM names must not impersonate generated VM IDs") @@ -94,8 +94,12 @@ def parse_name(value: str) -> str: def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]: + if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): + raise ValidationError("ports must be a sequence of PORT[:TYPE] strings") ports: dict[int, PortRule] = {} for value in values: + if not isinstance(value, str): + raise ValidationError("each port specification must be a string") for item in value.split(","): match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE) if match is None: -- 2.54.0 From 3d7af4adb80b1b3db1c51a146df761daa11a9e09 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Wed, 9 Sep 2026 19:21:00 +0530 Subject: [PATCH 3/5] Enhance documentation and validation for VM lifecycle and network management; add tests for legacy cleanup and address validation. --- DEVELOPER.md | 6 +- README.md | 6 ++ pyproject.toml | 1 + tests/test_firewall.py | 74 +++++++++++++++++ tests/test_lifecycle.py | 130 ++++++++++++++++++++++++++++-- tests/test_lifecycle_commands.py | 134 +++++++++++++++++++++++++++++-- uvm/firewall.py | 21 ++++- uvm/lifecycle.py | 6 +- uvm/network.py | 23 +----- 9 files changed, 366 insertions(+), 35 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index d0b8124..69c8607 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -272,6 +272,9 @@ mode. There is no persisted boolean asserting that a policy is enforced. - Missing `disk` and process start time remain absent. Legacy `disk_ready` is inferred only for running/stopped records with a recorded private disk; start still validates the path, file, and provisioning state. +- Legacy records own their canonical runtime directory independently of whether + they had a private disk. Deletion never removes the shared rootfs template; + explicit `runtime_owned=False` on a new interrupted creation stays authoritative. - Records created before credential provisioning default to username `root` and password `null`; UVM does not apply a password retroactively. - `next_mac_index` is reconstructed from older locally administered MACs when @@ -384,7 +387,8 @@ merely because its PID was never persisted or its socket has not appeared. ### Policy and Bulk Operations -`set_ports()` persists the new allowlist with `operation=policy` before +`set_ports()` takes a frozen registry ID, not a reusable name/IP; the CLI resolves +the user's identifier before confirmation. It persists the new allowlist with `operation=policy` before applying it. Live updates apply atomically and retain conntrack; cleanly stopped edits are metadata-only. On apply/readback failure, preserve the new intent, attempt link-down/quarantine, and leave a failed pending record. Never diff --git a/README.md b/README.md index 34ed9bd..491a5da 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,9 @@ forward rules for return traffic. Guest IPs, gateway, bridge name, and guest boot netmask derive from the configured CIDR. The existing `iptables` egress NAT/forwarding behavior is unchanged. Private +VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`, +`172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible +gateway, subnet, or bridge changes within the same registry. Private port enforcement uses separate per-VM native nftables `bridge` tables, not DNAT or host listeners. Policy is installed and read back before an owned TAP is activated and before the guest starts. The software bridge must have STP @@ -325,6 +328,9 @@ There is no autostart service or daemon reconciliation. After externally lost or changed UVM rules, inspect policy status and explicitly run `ports sync`. Read-only commands do not repair rules; sync does not boot stopped VMs. +Use one authoritative registry per bridge/subnet. Independent `UVM_BASE` +directories do not coordinate IP or MAC allocation with each other. + ## Persistent State The default data directory is `/var/lib/uvm`: diff --git a/pyproject.toml b/pyproject.toml index e9d1c7b..0a5784c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Local Firecracker microVM command-line manager" requires-python = ">=3.12" dependencies = [ "fastapi>=0.115,<1", + "pydantic>=2,<3", "uvicorn>=0.30,<1", ] diff --git a/tests/test_firewall.py b/tests/test_firewall.py index 723c306..d84e5a3 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -442,6 +442,80 @@ class FirewallManagerTests(unittest.TestCase): with self.assertRaises(CommandError): self.manager.cleanup_connections(self.vm) + def test_legacy_cleanup_outside_current_network_uses_only_recorded_ip(self): + record = dict(id="vm-legacy", cpu=1, ram_mib=128, guest_ip="203.0.113.7", + gateway="203.0.113.1", tap="tap-legacy", mac=self.vm.mac, + socket="", config="", log="", status="stopped") + for stored_network in (None, "203.0.113.0/24"): + with self.subTest(network=stored_network): + vm = VmRecord.from_dict({**record, **({"network": stored_network} if stored_network else {})}) + self.assertIsNone(vm.ports) + self.runner.calls.clear() + self.manager.cleanup_connections(vm) + expected = [("ip", "-j", "-d", "link", "show")] + expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7") + for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst") + for command in ("--delete", "--list")) + # No nft policy, publishing, or unrelated address/conntrack flush. + self.assertEqual([args for args, _ in self.runner.calls], expected) + + def test_cleanup_host_boundaries_use_stored_network_not_current_settings(self): + cases = ( + ("10.77.8.2", "10.77.0.0/16"), + ("100.64.3.4", "100.64.0.0/16"), + ("10.42.0.1", None), # Current gateway is not a cleanup constraint. + ("10.42.0.0", None), # Do not guess the old prefix from current settings. + ("10.42.0.255", None), + ("203.0.113.0", "203.0.0.0/16"), + ("203.0.113.255", "203.0.0.0/16"), + ("203.0.113.8", "203.0.113.8/31"), + ("203.0.113.9", "203.0.113.8/31"), + ("203.0.113.8", "203.0.113.8/32"), + ) + for address, network in cases: + with self.subTest(address=address, network=network): + self.runner.calls.clear() + self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) + deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")] + self.assertEqual(len(deletes), 4) + self.assertTrue(all(args[-1] == address for args in deletes)) + + def test_cleanup_rejects_invalid_or_non_host_persisted_addresses(self): + cases = [(address, None) for address in ( + "not-an-ip", "203.0.113.999", "203.0.113.7/24", "2001:db8::7", + "::ffff:203.0.113.7", None, True, 3405803783, + "0.0.0.0", "127.0.0.1", "127.12.2.3", "169.254.1.2", + "224.0.0.1", "239.255.255.255", "255.255.255.255", + )] + cases.extend(( + ("203.0.113.0", "203.0.113.0/24"), + ("203.0.113.255", "203.0.113.0/24"), + ("203.0.113.7", "203.0.114.0/24"), + ("203.0.113.7", "not-a-network"), + ("203.0.113.7", ""), + ("203.0.113.7", "2001:db8::/64"), + )) + for address, network in cases: + with self.subTest(address=address, network=network): + self.runner.calls.clear() + with self.assertRaises(ValidationError): + self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None)) + self.assertEqual([args for args, _ in self.runner.calls], [("ip", "-j", "-d", "link", "show")]) + + def test_legacy_public_cleanup_still_requires_tap_absence(self): + self.runner.links = [{"ifname": "tap-legacy"}] + vm = vm_record(tap="tap-legacy", guest_ip="203.0.113.7", ports=None) + with self.assertRaisesRegex(UvmError, "still exists"): + self.manager.cleanup_connections(vm) + self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls)) + + def test_cleanup_compatibility_does_not_allow_a_public_managed_policy(self): + vm = vm_record(guest_ip="203.0.113.7", network="203.0.113.0/24", ports=()) + with self.assertRaisesRegex(ValidationError, "private guest IP"): + self.manager.apply(vm) + self.assertEqual(self.runner.calls, []) + self.manager.cleanup_connections(vm) + def test_availability_checks_kernel_and_full_policy_without_committing(self): self.manager.check_available() self.assertEqual(self.runner.tables, {}) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b5b6eca..d3cd710 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1,11 +1,12 @@ from __future__ import annotations +import json import tempfile import unittest from collections.abc import Callable, Iterable, Iterator from contextlib import contextmanager from dataclasses import replace -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from pathlib import Path from unittest.mock import patch @@ -485,6 +486,81 @@ class LifecycleTests(LifecycleTestCase): self.provision.assert_not_called() self.copy_disk.assert_not_called() + def test_create_rejects_retained_topology_changes_before_allocation_or_bridge_work(self) -> None: + vm = self.create_vm() + self.copy_disk.reset_mock() + self.provision.reset_mock() + changes = ( + {"gateway": IPv4Address(vm.guest_ip)}, + {"gateway": IPv4Address("10.42.0.10")}, + {"network": IPv4Network("10.42.0.0/25")}, + {"bridge": "uvm-other"}, + {"gateway": IPv4Address(vm.guest_ip), "network": IPv4Network("10.42.0.0/25")}, + ) + for status in ("running", "stopped"): + if status == "stopped": + self.service.stop(vm.id) + before = self.settings.state_path.read_bytes() + before_directories = set(self.settings.vms_dir.iterdir()) + before_taps = self.network.taps.copy() + before_active = self.network.active_taps.copy() + before_tables = self.network.firewall.tables.copy() + for changed in changes: + with self.subTest(status=status, changed=changed): + settings = replace(self.settings, **changed) + service = LifecycleService( + settings, self.store, self.images, self.network, self.process, + client_factory=self.make_client, + ) + self.host.events.clear() + with ( + patch.object(self.network, "_settings", settings), + patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate, + self.assertRaisesRegex(ConflictError, "topology conflicts with retained VMs"), + ): + service.create(VmSpec(cpu=1, ram_mib=512)) + allocate.assert_not_called() + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(set(self.settings.vms_dir.iterdir()), before_directories) + self.assertEqual(self.network.taps, before_taps) + self.assertEqual(self.network.active_taps, before_active) + self.assertEqual(self.network.firewall.tables, before_tables) + self.assertEqual(self.host.events, [("firewall.check_available", None)]) + self.copy_disk.assert_not_called() + self.provision.assert_not_called() + self.assertEqual(len(self.process.started), 1) + self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs") + + def test_non_rfc1918_network_fails_before_kvm_assets_or_registry_reservation(self) -> None: + for network, gateway in ( + ("203.0.113.0/24", "203.0.113.1"), + ("100.64.0.0/24", "100.64.0.1"), + ("8.8.8.0/24", "8.8.8.1"), + ("10.0.0.0/7", "10.0.0.1"), + ): + with self.subTest(network=network): + settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway)) + service = LifecycleService( + settings, self.store, self.images, self.network, self.process, + client_factory=self.make_client, + ) + with ( + patch.object(self.network, "_settings", settings), + patch.object(self.images, "installed_assets", wraps=self.images.installed_assets) as assets, + patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate, + self.assertRaisesRegex(ValidationError, "RFC 1918"), + ): + service.create(VmSpec(cpu=1, ram_mib=512)) + assets.assert_not_called() + allocate.assert_not_called() + self.check_kvm.assert_not_called() + self.assertFalse(self.settings.state_path.exists()) + self.assertEqual(self.store.load().vms, {}) + self.assertEqual(list(self.settings.vms_dir.iterdir()), []) + self.assertEqual(self.host.events, []) + self.provision.assert_not_called() + self.copy_disk.assert_not_called() + def test_create_cleans_up_if_policy_readback_is_not_enforced_after_boot(self) -> None: with patch.object(self.network.firewall, "status", return_value="drift"): with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"): @@ -605,10 +681,54 @@ class LifecycleTests(LifecycleTestCase): self.copy_disk.assert_not_called() self.assertEqual(self.network.created_taps, []) self.assertEqual(self.process.started, []) - retained = self.store.load().vms.get("vm-existing") - if retained is not None: - self.assertFalse(retained.runtime_owned) - self.assertFalse(retained.disk_ready) + retained = self.store.get("vm-existing") + self.assertFalse(retained.runtime_owned) + self.assertFalse(retained.disk_ready) + with self.assertRaisesRegex(UvmError, "refusing to remove unowned VM directory"): + self.service.destroy(retained.id) + self.assertFalse(self.store.get(retained.id).runtime_owned) + self.assertEqual({path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()}, before) + + def test_delete_legacy_runtime_without_private_disk_preserves_shared_rootfs(self) -> None: + shared_rootfs = self.settings.rootfs_image + before = (shared_rootfs.stat().st_ino, shared_rootfs.read_bytes()) + for disk_field in ({}, {"disk": ""}): + with self.subTest(disk_field=disk_field): + directory = self.settings.vm_dir("vm-legacy") + directory.mkdir(parents=True, mode=0o700) + raw = { + "id": "vm-legacy", "cpu": 1, "ram_mib": 512, + "guest_ip": "10.42.0.2", "gateway": "10.42.0.1", + "tap": self.network.tap_name("vm-legacy"), "mac": self.network.mac_for(1), + "socket": str(directory / "firecracker.sock"), + "config": str(directory / "config.json"), + "log": str(directory / "firecracker.log"), + "status": "running", "pid": 12345, "process_start_time": "42", + **disk_field, + } + Path(raw["socket"]).touch() + Path(raw["log"]).write_bytes(b"legacy boot log") + Path(raw["config"]).write_text(json.dumps({ + "drives": [{"path_on_host": str(shared_rootfs), "is_read_only": False}], + }), encoding="utf-8") + self.settings.state_path.write_text(json.dumps({ + "schema_version": 1, "vms": {raw["id"]: raw}, + }), encoding="utf-8") + self.settings.state_path.chmod(0o600) + vm = self.store.get(raw["id"]) + self.assertTrue(vm.runtime_owned) + self.assertEqual(vm.disk, "") + self.assertFalse(vm.disk_ready) + self.assertEqual(self.process.observe(vm), "gone") + + self.service.destroy(vm.id) + + self.assertEqual(self.store.load().vms, {}) + self.assertFalse(directory.exists()) + self.assertEqual((shared_rootfs.stat().st_ino, shared_rootfs.read_bytes()), before) + self.copy_disk.assert_not_called() + self.provision.assert_not_called() + self.check_kvm.assert_not_called() def test_stale_stopping_and_deleting_records_can_finish_cleanup(self) -> None: for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")): diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py index e5eed4a..f7b6cde 100644 --- a/tests/test_lifecycle_commands.py +++ b/tests/test_lifecycle_commands.py @@ -43,7 +43,7 @@ class LifecycleLookupTests(LifecycleTestCase): before = self.settings.state_path.read_bytes() for kwargs, message in ( ({"name": vm.name}, "VM name already exists"), - ({"name": vm.id}, "VM name already exists"), + ({"name": vm.id}, "must not impersonate generated VM IDs"), ({"guest_ip": IPv4Address(vm.guest_ip)}, "IP already allocated"), ): with self.subTest(kwargs=kwargs), self.assertRaisesRegex(UvmError, message): @@ -53,6 +53,16 @@ class LifecycleLookupTests(LifecycleTestCase): self.assertEqual(self.copy_disk.call_count, 1) self.assertEqual(len(self.process.started), 1) + def test_generated_id_names_are_rejected_before_state_or_host_work(self) -> None: + with self.assertRaisesRegex(ValidationError, "must not impersonate generated VM IDs"): + self.create_vm(name="vm-0123456789abcdef0123456789abcdef") + self.assertFalse(self.settings.state_path.exists()) + self.assertEqual(self.store.operation_locks, 0) + self.assertEqual(self.host.events, []) + self.check_kvm.assert_not_called() + self.provision.assert_not_called() + self.copy_disk.assert_not_called() + def test_generated_id_retries_id_name_and_tap_collisions(self) -> None: with patch("uvm.lifecycle.new_vm_id", return_value="vm-first-same1234567"): first = self.create_vm(name="reserved-name") @@ -68,8 +78,9 @@ class LifecycleLookupTests(LifecycleTestCase): self.assertNotEqual(second.guest_ip, first.guest_ip) self.assertEqual(self.network.taps, {first.tap: first.id, second.tap: second.id}) - def test_ambiguous_persisted_name_ip_or_id_never_selects_an_arbitrary_vm(self) -> None: - first = self.create_vm(name="first") + def test_ambiguous_persisted_name_ip_or_legacy_id_never_selects_an_arbitrary_vm(self) -> None: + with patch("uvm.lifecycle.new_vm_id", return_value="vm-legacy"): + first = self.create_vm(name="first") second = self.create_vm(name="second") for identifier in (first.guest_ip, first.id, first.name): with self.subTest(identifier=identifier): @@ -85,6 +96,34 @@ class LifecycleLookupTests(LifecycleTestCase): self.assertEqual(self.settings.state_path.read_bytes(), before) self.assertEqual(self.host.events, []) + def test_exact_generated_id_wins_over_a_malformed_legacy_name(self) -> None: + first = self.create_vm(name="first") + second = self.create_vm(name="second") + self.persist(second, name=first.id) + before = self.settings.state_path.read_bytes() + second_record = self.store.get(second.id).to_dict() + self.host.events.clear() + self.assertEqual(self.service.inspect(first.id).vm.id, first.id) + self.assertEqual(self.service.find_for_ssh(first.id).id, first.id) + self.assertEqual(self.service.log_path(first.id), Path(first.log)) + self.assertEqual([vm.id for vm in self.service.resolve_targets([first.id])], [first.id]) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.service.stop(first.id).id, first.id) + self.service.destroy(first.id) + self.assertEqual(self.store.get(second.id).to_dict(), second_record) + self.assertEqual(self.host.actions(second.id), []) + + before = self.settings.state_path.read_bytes() + self.host.events.clear() + for method in (self.service.inspect, self.service.find_for_ssh, + self.service.log_path, self.service.stop): + with self.assertRaises(NotFoundError): + method(first.id) + with self.assertRaises(NotFoundError): + self.service.resolve_targets([first.id]) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.host.events, []) + def test_target_resolution_rejects_invalid_or_missing_targets_without_mutation(self) -> None: vm = self.create_vm(name="web") before = self.settings.state_path.read_bytes() @@ -102,6 +141,36 @@ class LifecycleLookupTests(LifecycleTestCase): class LifecyclePortTests(LifecycleTestCase): + def test_set_ports_requires_an_exact_registry_id_not_a_name_or_ip(self) -> None: + vm = self.create_vm(name="web") + before = self.settings.state_path.read_bytes() + self.host.events.clear() + for identifier in (vm.name, vm.guest_ip): + with self.subTest(identifier=identifier), self.assertRaises(NotFoundError): + self.service.set_ports(identifier, ()) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.host.events, []) + self.assertEqual(self.service.set_ports(vm.id, ()).ports, ()) + + def test_frozen_legacy_id_cannot_retarget_policy_after_name_reuse(self) -> None: + with patch("uvm.lifecycle.new_vm_id", return_value="vm-legacy"): + original = self.create_vm(name="original") + selected = self.service.inspect(original.name).vm.id + self.service.destroy(original.id) + replacement = self.create_vm(name=original.id, ports=(PortRule(22), PortRule(80))) + self.assertEqual(self.service.inspect(selected).vm.id, replacement.id) + before = self.settings.state_path.read_bytes() + before_tables = self.network.firewall.tables.copy() + self.host.events.clear() + for mode, ports in (("open", (PortRule(443),)), ("close", (PortRule(80),)), ("set", ())): + with self.subTest(mode=mode), self.assertRaises(NotFoundError): + self.service.set_ports(selected, ports, mode=mode) + self.assertEqual(self.settings.state_path.read_bytes(), before) + self.assertEqual(self.network.firewall.tables, before_tables) + self.assertEqual(self.host.events, []) + self.assertIn(replacement.tap, self.network.active_taps) + self.assertEqual(self.process.observe(replacement), "alive") + def test_stopped_port_edits_and_sync_are_metadata_only(self) -> None: vm = self.create_vm() stopped = self.service.stop(vm.id) @@ -195,8 +264,61 @@ class LifecyclePortTests(LifecycleTestCase): pending = self.store.get(vm.id) self.assertEqual(pending.ports, (PortRule(22),)) self.assertEqual(pending.operation, "policy") + self.assertIn("link-down failed", pending.last_error) + self.assertTrue(self.network.has_tap(pending)) + self.assertIn(vm.tap, self.network.active_taps) self.assertEqual(self.network.firewall.status(pending), "quarantined") - self.assertIn("firewall.quarantine", self.host.actions(vm.id)) + self.assert_call_order(vm, "firewall.apply", "network.deactivate_tap", "firewall.quarantine") + + def test_policy_failure_never_quarantines_a_missing_or_foreign_tap(self) -> None: + for owner in (None, "operator-owned"): + with self.subTest(owner=owner): + vm = self.create_vm(ports=(PortRule(22), PortRule(80))) + if owner is None: + del self.network.taps[vm.tap] + self.network.active_taps.discard(vm.tap) + else: + self.network.taps[vm.tap] = owner + with self.assertRaisesRegex(ConflictError, "unowned TAP"): + self.network.has_tap(vm) + before_taps = self.network.taps.copy() + before_active = self.network.active_taps.copy() + before_tables = self.network.firewall.tables.copy() + self.host.events.clear() + with self.assertRaisesRegex(UvmError, "policy pending"): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + pending = self.store.get(vm.id) + self.assertEqual((pending.ports, pending.operation), ((PortRule(22),), "policy")) + self.assertEqual(pending.pid, vm.pid) + self.assertEqual(self.network.taps, before_taps) + self.assertEqual(self.network.active_taps, before_active) + self.assertEqual(self.network.firewall.tables, before_tables) + self.assertFalse(set(self.host.actions(vm.id)) & { + "firewall.apply", "firewall.quarantine", "firewall.remove", + "firewall.cleanup_connections", "network.delete_vm_tap", "process.terminate", + }) + + def test_policy_fallback_rechecks_ownership_after_a_tap_is_replaced(self) -> None: + vm = self.create_vm(ports=(PortRule(22), PortRule(80))) + before_tables = self.network.firewall.tables.copy() + apply = self.network.firewall.apply + + def replace_tap_and_fail(record, *, quarantine=False): + if not quarantine: + self.network.taps[record.tap] = "operator-owned" + self.host.failures["firewall.apply", record.id] = UvmError("policy rejected") + return apply(record, quarantine=quarantine) + + self.host.events.clear() + with patch.object(self.network.firewall, "apply", side_effect=replace_tap_and_fail) as applied: + with self.assertRaisesRegex(UvmError, "policy pending.*unowned TAP"): + self.service.set_ports(vm.id, (PortRule(80),), mode="close") + self.assertEqual(applied.call_count, 1) + self.assertEqual(self.network.taps[vm.tap], "operator-owned") + self.assertIn(vm.tap, self.network.active_taps) + self.assertEqual(self.network.firewall.tables, before_tables) + self.assertNotIn("firewall.quarantine", self.host.actions(vm.id)) + self.assertEqual(self.store.get(vm.id).ports, (PortRule(22),)) def test_failed_close_keeps_tap_down_if_quarantine_also_fails(self) -> None: vm = self.create_vm(ports=(PortRule(22), PortRule(80))) @@ -348,10 +470,12 @@ class LifecycleBulkTests(LifecycleTestCase): self.assertEqual(self.store.get(retained.id).status, "stopped") def test_bulk_exact_ids_do_not_retarget_a_new_vm_named_after_a_deleted_id(self) -> None: - original = self.create_vm(name="original") + with patch("uvm.lifecycle.new_vm_id", return_value="vm-legacy"): + original = self.create_vm(name="original") selected = tuple(vm.id for vm in self.service.resolve_targets([original.name])) self.service.destroy(original.id) replacement = self.create_vm(name=original.id) + self.assertEqual(self.service.inspect(original.id).vm.id, replacement.id) before = self.store.get(replacement.id).to_dict() self.host.events.clear() results = self.service.run_many("delete", selected) diff --git a/uvm/firewall.py b/uvm/firewall.py index 7dca5fc..ba32df1 100644 --- a/uvm/firewall.py +++ b/uvm/firewall.py @@ -89,6 +89,8 @@ class FirewallManager: current = self._read_table(table["name"]) if current is not None: self._transaction([self._delete_owned(current, table)]) + if self._read_table(table["name"]) is not None: + raise UvmError("firewall table remains or was replaced after removal; keep the VM reservation") def status(self, vm: VmRecord) -> str: """Inspect actual hooks, ordered rules, identity and complete port set. @@ -137,9 +139,26 @@ class FirewallManager: All four tuple addresses are checked, including NAT reply tuples. A failed deletion is tolerated only if a successful list proves absence. The caller must retain the address reservation until this succeeds. + Legacy non-RFC1918 addresses are allowed here; only the recorded subnet, + when present, determines host-address boundaries, never current settings. """ self._require_offline(vm) - address = self._guest_ip(vm) + try: + address = IPv4Address(str(vm.guest_ip)) + stored_network = getattr(vm, "network", None) + network = IPv4Network(str(stored_network)) if stored_network is not None else None + except (ValueError, TypeError) as error: + raise ValidationError("conntrack cleanup requires a valid persisted IPv4 address/network") from error + if (address.is_unspecified or address.is_loopback or address.is_link_local + or address.is_multicast or address == IPv4Address("255.255.255.255")): + raise ValidationError("conntrack cleanup requires a usable persisted unicast IPv4 address") + # /31 point-to-point subnets and /32 host routes have no broadcast address. + if network is not None and ( + address not in network or (network.prefixlen < 31 + and address in {network.network_address, network.broadcast_address}) + ): + raise ValidationError("conntrack cleanup requires a usable guest IP inside its persisted network") + address = str(address) for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"): selector = ("--family", "ipv4", field, address) self._run(("conntrack", "--delete", *selector), check=False) diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index 4dd7484..b21ce0e 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -104,6 +104,10 @@ class LifecycleService: for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")): raise ValidationError("private ports require a network inside an RFC 1918 IPv4 range") check_kvm() + self._verify_firecracker() + self._network.firewall.check_available() + + def _verify_firecracker(self) -> None: manifest = load_manifest(self._settings.integrity_manifest_path) checksum = self._settings.firecracker_binary_sha256 if manifest.verified or self._settings.allow_unverified_downloads: @@ -111,7 +115,6 @@ class LifecycleService: verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary", "UVM_FIRECRACKER_BINARY_SHA256", allow_unverified=self._settings.allow_unverified_downloads) - self._network.firewall.check_available() def _boot(self, vm: VmRecord, kernel: Path) -> None: vm.status, vm.operation = "starting", "network" @@ -414,6 +417,7 @@ class LifecycleService: def diagnostics(self) -> list[dict]: checks = [("root", require_root), ("kvm", check_kvm), + ("Firecracker", self._verify_firecracker), ("guest assets", self._images.installed_assets), ("private firewall", self._network.firewall.check_available)] results = [] diff --git a/uvm/network.py b/uvm/network.py index 4a9e393..3d779d5 100644 --- a/uvm/network.py +++ b/uvm/network.py @@ -9,7 +9,7 @@ from ipaddress import IPv4Address from .config import Settings from .domain import VmRecord -from .errors import ConflictError, TapCreationError, UvmError, ValidationError +from .errors import ConflictError, UvmError, ValidationError from .firewall import FirewallManager from .system import CommandRunner @@ -166,27 +166,6 @@ class NetworkManager: raise ConflictError(f"refusing to modify unowned TAP {vm.tap}") return link - def create_tap(self, name: str) -> None: - existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True) - if existing.returncode == 0: - raise UvmError(f"TAP device already exists: {name}") - created = False - try: - self._runner.run(("ip", "tuntap", "add", "dev", name, "mode", "tap")) - created = True - self._runner.run(("ip", "link", "set", name, "master", self._settings.bridge)) - self._runner.run(("ip", "link", "set", name, "up")) - except BaseException as error: - if created: - try: - self.delete_tap(name) - except UvmError as cleanup_error: - raise TapCreationError( - f"could not configure TAP device {name}; cleanup also failed: {cleanup_error}", - tap_created=True, - ) from error - raise - def delete_tap(self, name: str) -> None: deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True) if deleted.returncode == 0: -- 2.54.0 From b3b1b527e18e9089dcb0781e955e861afa77f3f4 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Sat, 19 Sep 2026 10:50:29 +0530 Subject: [PATCH 4/5] feat: Implement UVM Dashboard with frontend and backend integration - Added dashboard.js for frontend functionality, including VM management and system health monitoring. - Created dashboard route to serve the dashboard HTML. - Introduced diagnostics route for token-protected host diagnostics. - Enhanced VMs router with bulk actions, VM log retrieval, and port management. - Updated lifecycle service to read VM log tails with truncation handling. - Integrated static file serving for dashboard assets in the FastAPI server. - Redirect root URL to the dashboard for improved user experience. --- DEVELOPER.md | 58 +- README.md | 43 +- USAGE.md | 149 ++++- image.png | Bin 0 -> 369109 bytes pyproject.toml | 5 +- tests/test_lifecycle_commands.py | 17 + tests/test_server.py | 219 +++++++- uvm/api_models.py | 40 ++ uvm/dashboard/__init__.py | 1 + uvm/dashboard/static/css/dashboard.css | 470 ++++++++++++++++ uvm/dashboard/static/index.html | 379 +++++++++++++ uvm/dashboard/static/js/dashboard.js | 739 +++++++++++++++++++++++++ uvm/lifecycle.py | 45 ++ uvm/routers/dashboard.py | 17 + uvm/routers/diagnostics.py | 18 + uvm/routers/vms.py | 139 ++++- uvm/server.py | 15 +- 17 files changed, 2285 insertions(+), 69 deletions(-) create mode 100644 image.png create mode 100644 uvm/dashboard/__init__.py create mode 100644 uvm/dashboard/static/css/dashboard.css create mode 100644 uvm/dashboard/static/index.html create mode 100644 uvm/dashboard/static/js/dashboard.js create mode 100644 uvm/routers/dashboard.py create mode 100644 uvm/routers/diagnostics.py diff --git a/DEVELOPER.md b/DEVELOPER.md index 69c8607..50d045b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -13,8 +13,10 @@ general-purpose Python library or a multi-host control plane. UVM installs host/guest assets, provisions private disks, and manages named VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk start/stop/restart, deletion, fixed-target bulk operations, inspection, SSH, -logs, doctor, and explicit port edits/sync. The optional API exposes only the -existing health/install/create/list/inspect/stop/delete operations. +logs, doctor, and explicit port edits/sync. The optional API and local dashboard +expose health, installation, VM lifecycle controls, and managed port-policy +controls, host diagnostics, bounded VM-log tails, and fixed-target bulk +operations. Interactive SSH remains CLI-only. There is no autostart or background reconciler, multi-host control plane, tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port @@ -80,7 +82,10 @@ Packet integration tests must remain inside disposable isolated namespaces. │ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent │ ├── api_models.py # Pydantic HTTP request/response models │ ├── server.py # FastAPI factory and Uvicorn launcher +│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS │ ├── routers/ +│ │ ├── dashboard.py # Dashboard document route +│ │ ├── diagnostics.py # Host diagnostic endpoint │ │ ├── dependencies.py # API application/token dependencies │ │ ├── health.py # Liveness endpoint │ │ ├── installation.py # Installation endpoint @@ -158,9 +163,12 @@ checks, not per-VM repair or guest application probes. ### HTTP Path Routers validate the token and Pydantic request, invoke application services, -and render whitelisted response models. The CLI expansion does not imply HTTP -endpoint parity: start/restart, port edits/sync, bulk operations, logs, doctor, -and interactive SSH have no HTTP endpoints. +and render whitelisted response models. Lifecycle, managed port-policy, +diagnostics, and bounded log-tail operations have explicit HTTP routes for the +dashboard. Bulk operations have a dedicated fixed-target HTTP route; interactive +SSH has no HTTP endpoint. The public dashboard shell does not bypass token +validation: it stores a supplied token only for the active browser session and +sends it with management requests. Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The `create_api()` factory requires an explicit host specifically to prevent it @@ -645,13 +653,22 @@ or secret manager when that matters. | Method | Route | Router | Notes | |---|---|---|---| +| `GET` | `/` | `server.py` | Redirect to `/dashboard` | +| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell | | `GET` | `/health` | `health.py` | Unauthenticated liveness response | +| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks | | `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | | `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action | | `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop while retaining private disk | -| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files and state | +| `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}/stop` | `vms.py` | Stop; optional `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; optional `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | `vms.py` | Exact ID only; body has `mode` and `ports` | +| `POST` | `/vms/{vm_id}/ports/sync` | `vms.py` | Exact ID only; reapply managed policy | +| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail | All management routes use `get_authorized_application()` and require: @@ -671,14 +688,24 @@ unmanaged VM from an omitted policy. Names share CLI validation. VM responses include `name`, `ports`, `policy_status`, and `operation` alongside resource/status fields and username. The schema excludes password and disk/socket/config/log path fields; error messages may still contain paths. -`ports: null` in a legacy response still means unmanaged. The existing -stop/delete routes use service defaults; no timeout/dry-run/bulk HTTP schemas -were added. Typed not-found/conflict errors map to 404/409. +`ports: null` in a legacy response still means unmanaged. Start/restart/stop +and port-policy mutations report the resulting policy status. Restart, stop, +and delete accept an optional finite non-negative `timeout` query parameter; +there is no dry-run HTTP schema. Port mutation bodies use +`{"mode":"set"|"open"|"close", "ports":[...]}` and always resolve the +target by exact ID to prevent name/IP reuse. A legacy policy must be set while +stopped before it can be started or synced. Typed not-found/conflict errors map +to 404/409. -There are **no new start/restart, ports, or bulk HTTP endpoints**. The API also -does not expose logs, doctor, interactive SSH, raw Firecracker calls, arbitrary -command execution, or host path access. Public `/docs`, `/redoc`, and -`/openapi.json` are informational, not privileged management routes. +Log reads accept a `tail` query parameter from 0 through 1000 and never follow +the file. `LifecycleService.read_log_tail()` reads at most the final 256 KiB and +marks a response truncated when an oversized line or that bound prevents a full +tail. Bulk bodies use an action plus either explicit identifiers or `all_vms`; +the router resolves them before `run_many()` executes the fixed IDs under one +operation lock. The API does not expose interactive SSH, raw Firecracker calls, +arbitrary command execution, or host path access. Public `/`, `/dashboard`, +`/docs`, `/redoc`, and `/openapi.json` do not perform privileged management +requests. ## 14. Configuration Reference @@ -810,7 +837,8 @@ on an isolated Linux host: `get_authorized_application()`, and register new routers in `create_api()`. Add API tests and update all route tables. New CLI commands do not implicitly authorize new HTTP endpoints; `/health` remains the only unauthenticated - operational route. + operational API route, while `/` and `/dashboard` only serve the public + dashboard shell. - Host primitives: use the specific network/firewall/install/process module and `CommandRunner` for external commands. Define ownership, idempotence, and compensation before invoking a primitive from lifecycle code. Use fake diff --git a/README.md b/README.md index 491a5da..b1d900a 100644 --- a/README.md +++ b/README.md @@ -217,35 +217,53 @@ sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \ ``` The default bind address is `127.0.0.1` and the default port is `8000`. -OpenAPI documentation is available at `/docs` once the server is running. +OpenAPI documentation is available at `/docs` once the server is running. The +root URL redirects to the local dashboard at `/dashboard`; enter the API token +there for the current browser session. The server exposes these routes: | Method | Route | Purpose | |---|---|---| +| `GET` | `/` | Redirect to the local dashboard | +| `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/health` | Unauthenticated liveness response | +| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `GET` | `/vms` | List VM records and observed status | | `POST` | `/vms` | Create; supports `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk | -| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources | +| `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}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID | +| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID | +| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log | API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`; -`no_default_ports` is a boolean, default `false`. Responses include `name`, +`no_default_ports` is a boolean, default `false`. Port changes use +`{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be +stopped and use `set` to establish a managed policy. Responses include `name`, `ports`, `policy_status`, and `operation`, but never passwords. See [USAGE.md](USAGE.md) for request examples and strict schema details. +Log reads use an optional `tail` query parameter from `0` to `1000`, default +`100`, and are capped at the final 256 KiB of the log. -The endpoint set is unchanged: there are **no start/restart, port-management, -or bulk HTTP endpoints**. SSH, logs, and doctor are also CLI-only. Use the -local CLI for those operations; the API is not a background reconciler. +Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or +`ports-sync`) plus either `identifiers` or `all_vms: true`; they return a result +for each fixed target and do not prompt for confirmation. Interactive SSH +remains CLI-only. The API and dashboard are not background reconcilers. ### API Security The server controls privileged host operations. `UVM_API_TOKEN` is required for every API server, including loopback-only deployments. All management -routes require it in the `X-UVM-Token` header. FastAPI's informational `/docs`, -`/redoc`, and `/openapi.json` endpoints do not require a token. +routes require it in the `X-UVM-Token` header. The dashboard shell, liveness +route, and FastAPI's informational `/docs`, `/redoc`, and `/openapi.json` +endpoints do not require a token; the dashboard stores the entered token only +for its current browser session and applies it to management requests. A non-loopback bind additionally requires TLS certificate and key paths: @@ -406,6 +424,8 @@ Selected files: │ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent │ ├── network.py # IP/MAC/TAP/bridge/NAT management │ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup +│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript +│ ├── routers/ # FastAPI routes, including the dashboard document │ └── firecracker/ # Firecracker config, API, and process adapters └── tests/ # Unit tests and opt-in isolated namespace packet tests ``` @@ -424,8 +444,9 @@ python3 -B -m unittest discover -s tests -v The unit tests do not need root, KVM, Firecracker, or host network changes. They cover validation, state migration and locking, integrity manifests, network/firewall rendering, Firecracker request ordering, process safety, -retained-disk resume, bulk operations, and lifecycle rollback. API route tests -run when the FastAPI and `httpx` test dependencies are installed. +retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery, +and API routes. API route tests run when the FastAPI and `httpx` test +dependencies are installed. Opt-in packet checks run **only in disposable isolated user/network namespaces**, which may require privileges and the installed network tools: diff --git a/USAGE.md b/USAGE.md index 17274a3..d4e94ad 100644 --- a/USAGE.md +++ b/USAGE.md @@ -602,11 +602,12 @@ inventing identity fields in `state.json`. ## 10. Use the FastAPI Server -The API uses the same local lifecycle services, but exposes only the existing -health/install/create/list/inspect/stop/delete endpoints. There are no new -start/restart, port-management, or bulk HTTP endpoints; SSH, logs, and doctor -also remain CLI-only. The server does not autostart VMs or reconcile rules, -and is not safe to expose directly to the public internet. +The API uses the same local lifecycle services and exposes health, installation, +VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port +controls. The local dashboard at `/dashboard` uses those same endpoints. There +API also supports fixed-target bulk actions; interactive SSH remains CLI-only. +The server does not autostart VMs or reconcile rules, and is not safe to expose +directly to the public internet. ### Start a Local Server @@ -620,15 +621,18 @@ sudo env \ ./uvm.py --serve --host 127.0.0.1 --port 8000 ``` -Liveness is at `http://127.0.0.1:8000/health`; OpenAPI docs are at +Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at +`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at `http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`. -The API token is required even for a loopback server. All VM and installation -routes require it. FastAPI's informational `/docs`, `/redoc`, and -`/openapi.json` endpoints are also available without a token; they do not -perform host mutations. Tokens must be visible ASCII with no whitespace. -Use a protected environment file or secret manager rather than recording a -real token in shell history; do not share tokens or guest passwords in reports. +The API token is required even for a loopback server. All VM, diagnostics, and +installation routes require it. The dashboard shell, liveness route, and FastAPI's +informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available +without a token; they do not perform host mutations. The dashboard keeps an +entered token only for the active browser session. Tokens must be visible ASCII +with no whitespace. Use a protected environment file or secret manager rather +than recording a real token in shell history; do not share tokens or guest +passwords in reports. ### Start a TLS Server @@ -675,16 +679,26 @@ curl --cacert /path/to/ca.pem \ | Method | Path | Operation | |---|---|---| +| `GET` | `/` | Redirect to `/dashboard` | +| `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/health` | Unauthenticated liveness | +| `GET` | `/diagnostics` | Run host readiness checks | | `POST` | `/install` | Install/refresh with a `force` boolean | | `GET` | `/vms` | List VM responses | | `POST` | `/vms` | Create and boot, HTTP 201 on success | +| `POST` | `/vms/actions` | Run selected/all VM lifecycle actions from a fixed snapshot | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | Stop and retain disk, using the default TERM wait | -| `DELETE` | `/vms/{identifier}` | Delete VM and private disk | +| `POST` | `/vms/{identifier}/start` | Start a retained VM | +| `POST` | `/vms/{identifier}/restart` | Restart a VM; accepts `timeout` query parameter | +| `POST` | `/vms/{identifier}/stop` | Stop and retain disk; accepts `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID | +| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID | +| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail | -These stop/delete endpoints do not expose the CLI timeout, dry-run, or bulk -flags and do not prompt for confirmation. +Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt +for confirmation. `timeout` is an optional finite, non-negative query parameter +on restart, stop, and delete; it has the same SIGTERM wait semantics as the CLI. ### Health, List, and Inspect @@ -739,9 +753,19 @@ The response never includes the password. Omitting credentials still uses labels return HTTP `422`. Example passwords above are placeholders; real secrets in curl arguments can enter shell history and process listings. -### Stop and Delete +### Start, Restart, Stop, and Delete ```sh +# Start a retained private disk. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/start" + +# Restart with an explicit SIGTERM wait. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/restart?timeout=10" + curl -X POST \ -H "X-UVM-Token: $UVM_API_TOKEN" \ "$UVM_API_URL/vms/api-web/stop" @@ -753,8 +777,95 @@ curl -X DELETE \ ``` Stop returns the VM response with `status: "stopped"`; delete returns its -exact `id` and `status: "terminated"`. Start the retained disk through the -local CLI, not a new HTTP route. +exact `id` and `status: "terminated"`. Start and restart return the updated VM +response. Start preserves the retained private disk and does not restore guest +memory. + +### Bulk Lifecycle Actions + +The dashboard batch toolbar uses this endpoint for selected VMs. The server +resolves the selected identifiers into a fixed ID snapshot before it invokes the +existing serialized bulk lifecycle service. + +```sh +# Restart two named VMs with a shared TERM wait. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"action":"restart","identifiers":["api-web","worker"],"timeout":10}' \ + "$UVM_API_URL/vms/actions" + +# Sync managed policies for every VM currently in the registry. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"action":"ports-sync","all_vms":true}' \ + "$UVM_API_URL/vms/actions" +``` + +Valid actions are `start`, `stop`, `restart`, `delete`, and `ports-sync`. +Provide `identifiers` or `all_vms: true`, but not both. The response contains +one `{id, action, outcome, error}` object per selected VM. HTTP bulk requests +do not prompt for confirmation or provide a dry-run; require an explicit +operator confirmation in a caller before destructive use. + +### Manage Private Ports + +The port mutation and sync routes require an **exact VM ID** returned by create +or list. They deliberately do not resolve a name or guest IP, preventing a +later name/IP reuse from changing the wrong policy. Port rules are strict JSON +objects, not CLI colon strings. + +```sh +# Replace the full managed TCP allowlist. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"set","ports":[{"port":22,"protocol":"TCP"},{"port":443,"protocol":"HTTPS"}]}' \ + "$UVM_API_URL/vms//ports" + +# Add or remove one or more rules without replacing the other rules. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"open","ports":[{"port":80,"protocol":"HTTP"}]}' \ + "$UVM_API_URL/vms//ports" +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"close","ports":[{"port":80,"protocol":"HTTP"}]}' \ + "$UVM_API_URL/vms//ports" + +# Reapply a policy after an external firewall change. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms//ports/sync" +``` + +`set` can use an empty `ports` array to close every managed service. A legacy +VM with `ports: null` must be stopped and use `set` before it can start or use +the other policy operations. `open`, `close`, and `sync` reject a legacy policy. +Mutating a live policy is subject to the same containment and confirmation rules +as the CLI. + +### Diagnostics and Log Tails + +The dashboard's System view calls the protected diagnostics endpoint. It runs +the same host-readiness checks as `uvm doctor`, but does not repair a host or +change VM state. + +```sh +curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/diagnostics" + +# Read the final 200 lines without opening a streaming follow request. +curl -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/logs?tail=200" +``` + +Log responses have `content` and `truncated` fields. `tail` is an integer from +0 through 1000, defaulting to 100. Reads are limited to the final 256 KiB of a +log; `truncated: true` means an oversized line or the read bound prevented a +complete requested tail. Use `uvm logs VM --follow` for continuous output. ### Install or Refresh Through the API diff --git a/image.png b/image.png new file mode 100644 index 0000000000000000000000000000000000000000..7c959a5d147dae30c9a0e03b2647d5704cca8639 GIT binary patch literal 369109 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 z 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]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 zⓈ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≫ZRy#gB(elCynBIlO>=JNj|65WC}z`J=!e>eGz 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⁡QVW8@_~bcZLY`W$&aArqeyO) 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 z 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 0a5784c..4de4114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,4 +20,7 @@ test = ["httpx>=0.27,<1"] uvm = "uvm.cli:main" [tool.setuptools] -packages = ["uvm", "uvm.firecracker", "uvm.routers"] +packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"] + +[tool.setuptools.package-data] +"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js"] diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py index f7b6cde..053635b 100644 --- a/tests/test_lifecycle_commands.py +++ b/tests/test_lifecycle_commands.py @@ -600,6 +600,23 @@ class LifecycleInspectionTests(LifecycleTestCase): self.assertFalse(settings.base.exists()) self.assertEqual(self.host.events, []) + def test_read_log_tail_is_bounded_and_marks_an_oversized_line(self) -> None: + vm = self.create_vm(name="log-reader") + path = Path(vm.log) + path.write_bytes(b"old\n" * 100_000 + b"last\n" * 2) + + tail = self.service.read_log_tail(vm.id, tail=2) + + self.assertEqual(tail.content, "last\n" * 2) + self.assertFalse(tail.truncated) + with self.assertRaisesRegex(ValidationError, "non-negative"): + self.service.read_log_tail(vm.id, tail=-1) + + path.write_bytes(b"x" * 300_000) + truncated = self.service.read_log_tail(vm.id, tail=1) + self.assertEqual(len(truncated.content), 262_144) + self.assertTrue(truncated.truncated) + def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None: self.check_kvm.side_effect = UvmError("KVM unavailable") self.host.failures["firewall.check_available", None] = UvmError("nft unavailable") diff --git a/tests/test_server.py b/tests/test_server.py index 686a1ec..34e9692 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -13,7 +13,7 @@ from uvm.cli import build_parser, main from uvm.config import Settings from uvm.domain import PortRule, VmRecord, VmSpec from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError -from uvm.lifecycle import ListedVm +from uvm.lifecycle import ListedVm, OperationResult from uvm.server import _http_status_for, create_api, run_server @@ -199,7 +199,21 @@ class FakeLifecycle: self.policy_status = "enforced" return self.vm - def stop(self, identifier: str) -> VmRecord: + def start(self, identifier: str) -> VmRecord: + self.inspect(identifier) + self.vm.status = "running" + self.observed_status = "running" + self.policy_status = "enforced" + return self.vm + + def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord: + self.inspect(identifier) + self.vm.status = "running" + self.observed_status = "running" + self.policy_status = "enforced" + return self.vm + + def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord: self.inspect(identifier) self.vm.status = "stopped" self.vm.pid = None @@ -208,10 +222,55 @@ class FakeLifecycle: self.policy_status = "inactive" return self.vm - def destroy(self, identifier: str) -> VmRecord: + def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord: self.inspect(identifier) return self.vm + def read_log_tail(self, identifier: str, *, tail: int) -> SimpleNamespace: + self.inspect(identifier) + return SimpleNamespace(content=f"last {tail} log lines", truncated=False) + + def diagnostics(self) -> list[dict]: + return [{"name": "kvm", "ok": True, "message": "available"}] + + def resolve_targets(self, identifiers: list[str], *, all_vms: bool = False) -> list[VmRecord]: + if all_vms and identifiers: + raise ValidationError("--all cannot be combined with explicit VM targets") + if not all_vms and not identifiers: + raise ValidationError("specify a VM or --all") + if all_vms: + return [self.vm] + return [self.inspect(identifier).vm for identifier in identifiers] + + def run_many( + self, + action: str, + vm_ids: list[str], + *, + timeout: float | None = None, + ) -> list[OperationResult]: + return [OperationResult(vm_id, action, "succeeded") for vm_id in vm_ids] + + def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord: + self.inspect(identifier) + if mode == "set": + self.vm.ports = ports + elif mode == "open": + existing = {p.port: p for p in self.vm.ports or ()} + for p in ports: + existing.setdefault(p.port, p) + self.vm.ports = tuple(sorted(existing.values(), key=lambda r: r.port)) + elif mode == "close": + drop = {p.port for p in ports} + self.vm.ports = tuple(p for p in self.vm.ports or () if p.port not in drop) + self.policy_status = "enforced" + return self.vm + + def sync_ports(self, identifier: str) -> VmRecord: + self.inspect(identifier) + self.policy_status = "enforced" + return self.vm + @unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests") class ApiRouterTests(unittest.TestCase): @@ -236,18 +295,40 @@ class ApiRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"status": "ok"}) + def test_dashboard_and_assets_are_available_without_credentials(self) -> None: + root = self.client.get("/", follow_redirects=False) + dashboard = self.client.get("/dashboard") + stylesheet = self.client.get("/dashboard/static/css/dashboard.css") + script = self.client.get("/dashboard/static/js/dashboard.js") + + self.assertEqual(root.status_code, 307) + self.assertEqual(root.headers["location"], "/dashboard") + self.assertEqual(dashboard.status_code, 200) + self.assertIn("UVM Dashboard", dashboard.text) + self.assertEqual(stylesheet.status_code, 200) + self.assertIn("--bg-body", stylesheet.text) + self.assertEqual(script.status_code, 200) + self.assertIn("sessionStorage", script.text) + def test_vm_routes_require_and_accept_the_api_token(self) -> None: - for method, path in ( - ("GET", "/vms"), - ("POST", "/vms"), - ("GET", "/vms/test-vm"), - ("POST", "/vms/test-vm/stop"), - ("DELETE", "/vms/test-vm"), - ("POST", "/install"), + for method, path, payload in ( + ("GET", "/vms", {}), + ("POST", "/vms", {}), + ("GET", "/vms/test-vm", {}), + ("GET", "/vms/test-vm/logs", {}), + ("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}), + ("POST", "/vms/test-vm/start", {}), + ("POST", "/vms/test-vm/restart", {}), + ("POST", "/vms/test-vm/stop", {}), + ("DELETE", "/vms/test-vm", {}), + ("POST", "/vms/test-vm/ports", {}), + ("POST", "/vms/test-vm/ports/sync", {}), + ("GET", "/diagnostics", {}), + ("POST", "/install", {}), ): for headers in ({}, {"X-UVM-Token": "wrong-token"}): with self.subTest(method=method, path=path, headers=headers): - response = self.client.request(method, path, headers=headers, json={}) + response = self.client.request(method, path, headers=headers, json=payload) self.assertEqual(response.status_code, 401) self.assertIsNone(self.lifecycle.last_spec) self.assertEqual(self.lifecycle.vm.status, "running") @@ -435,6 +516,105 @@ class ApiRouterTests(unittest.TestCase): self.assertEqual(destroyed.json()["id"], "vm-test") destroy.assert_called_with(identifier) + def test_lifecycle_timeouts_are_forwarded_when_supplied(self) -> None: + with ( + patch.object(self.lifecycle, "restart", wraps=self.lifecycle.restart) as restart, + patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop, + patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy, + ): + self.assertEqual( + self.client.post("/vms/vm-test/restart?timeout=1.5", headers=self.headers).status_code, + 200, + ) + self.assertEqual( + self.client.post("/vms/vm-test/stop?timeout=2.5", headers=self.headers).status_code, + 200, + ) + self.assertEqual( + self.client.delete("/vms/vm-test?timeout=3.5", headers=self.headers).status_code, + 200, + ) + + restart.assert_called_once_with("vm-test", timeout=1.5) + stop.assert_called_once_with("vm-test", timeout=2.5) + destroy.assert_called_once_with("vm-test", timeout=3.5) + + def test_start_restart_and_port_routes_update_vm_state(self) -> None: + started = self.client.post("/vms/vm-test/start", headers=self.headers) + self.assertEqual(started.status_code, 200) + self.assertEqual(started.json()["status"], "running") + self.assertEqual(started.json()["policy_status"], "enforced") + + restarted = self.client.post("/vms/vm-test/restart", headers=self.headers) + self.assertEqual(restarted.status_code, 200) + self.assertEqual(restarted.json()["status"], "running") + + self.lifecycle.vm.status = "stopped" + self.lifecycle.policy_status = "inactive" + sync = self.client.post("/vms/vm-test/ports/sync", headers=self.headers) + self.assertEqual(sync.status_code, 200) + self.assertEqual(sync.json()["id"], "vm-test") + self.assertEqual(sync.json()["policy_status"], "inactive") + + def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None: + with ( + patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve, + patch.object(self.lifecycle, "run_many", wraps=self.lifecycle.run_many) as run_many, + ): + response = self.client.post( + "/vms/actions", + headers=self.headers, + json={"action": "restart", "identifiers": ["test-vm"], "timeout": 1.5}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [{"id": "vm-test", "action": "restart", "outcome": "succeeded", "error": None}]) + resolve.assert_called_once_with(["test-vm"], all_vms=False) + run_many.assert_called_once_with("restart", ["vm-test"], timeout=1.5) + + def test_bulk_action_requires_exactly_one_selection_strategy(self) -> None: + for payload in ( + {"action": "start"}, + {"action": "start", "identifiers": ["vm-test"], "all_vms": True}, + ): + with self.subTest(payload=payload): + response = self.client.post("/vms/actions", headers=self.headers, json=payload) + self.assertEqual(response.status_code, 422) + + def test_log_and_diagnostic_routes_return_bounded_observability_data(self) -> None: + logs = self.client.get("/vms/test-vm/logs?tail=5", headers=self.headers) + diagnostics = self.client.get("/diagnostics", headers=self.headers) + + self.assertEqual(logs.status_code, 200) + self.assertEqual(logs.json(), {"content": "last 5 log lines", "truncated": False}) + self.assertEqual(diagnostics.status_code, 200) + self.assertEqual(diagnostics.json(), [{"name": "kvm", "ok": True, "message": "available"}]) + + def test_port_update_modes_mutate_rules(self) -> None: + set_resp = self.client.post( + "/vms/vm-test/ports", + headers=self.headers, + json={"mode": "set", "ports": [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]}, + ) + self.assertEqual(set_resp.status_code, 200) + self.assertEqual(set_resp.json()["ports"], [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]) + + open_resp = self.client.post( + "/vms/vm-test/ports", + headers=self.headers, + json={"mode": "open", "ports": [{"port": 22, "protocol": "TCP"}]}, + ) + self.assertEqual(open_resp.status_code, 200) + self.assertEqual(open_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]) + + close_resp = self.client.post( + "/vms/vm-test/ports", + headers=self.headers, + json={"mode": "close", "ports": [{"port": 80, "protocol": "HTTP"}]}, + ) + self.assertEqual(close_resp.status_code, 200) + self.assertEqual(close_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 443, "protocol": "HTTPS"}]) + def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None: for ports, status, operation, policy_status in ( (None, "running", None, "unmanaged"), @@ -456,9 +636,15 @@ class ApiRouterTests(unittest.TestCase): for method, path, operation in ( ("GET", "/vms", "list_vms"), ("GET", "/vms/test-vm", "inspect"), + ("GET", "/vms/test-vm/logs", "read_log_tail"), ("POST", "/vms", "create"), + ("POST", "/vms/test-vm/start", "start"), + ("POST", "/vms/test-vm/restart", "restart"), ("POST", "/vms/test-vm/stop", "stop"), ("DELETE", "/vms/test-vm", "destroy"), + ("POST", "/vms/test-vm/ports", "set_ports"), + ("POST", "/vms/test-vm/ports/sync", "sync_ports"), + ("GET", "/diagnostics", "diagnostics"), ): for error, expected in ( (NotFoundError("unknown guest"), 404), @@ -480,19 +666,28 @@ class ApiRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 404) self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}}) - def test_api_schema_keeps_only_existing_operations(self) -> None: + def test_api_schema_exposes_dashboard_and_management_operations(self) -> None: schema = self.client.get("/openapi.json").json() self.assertEqual( {(path, method) for path, methods in schema["paths"].items() for method in methods}, { + ("/", "get"), + ("/dashboard", "get"), ("/health", "get"), ("/install", "post"), ("/vms", "get"), ("/vms", "post"), + ("/vms/actions", "post"), ("/vms/{identifier}", "get"), + ("/vms/{identifier}/logs", "get"), + ("/vms/{vm_id}/start", "post"), + ("/vms/{vm_id}/restart", "post"), ("/vms/{vm_id}/stop", "post"), ("/vms/{vm_id}", "delete"), + ("/vms/{vm_id}/ports", "post"), + ("/vms/{vm_id}/ports/sync", "post"), + ("/diagnostics", "get"), }, ) self.assertEqual( diff --git a/uvm/api_models.py b/uvm/api_models.py index 896787c..9ccf2ed 100644 --- a/uvm/api_models.py +++ b/uvm/api_models.py @@ -73,6 +73,46 @@ class DestroyResponse(BaseModel): status: str = "terminated" +class PortUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: Literal["set", "open", "close"] = "set" + ports: list[PortRuleModel] = Field(default_factory=list) + + +class PortSyncResponse(BaseModel): + id: str + policy_status: str + status: str + + +class VmLogResponse(BaseModel): + content: str + truncated: bool + + +class DiagnosticResponse(BaseModel): + name: str + ok: bool + message: str + + +class VmBulkActionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["start", "stop", "restart", "delete", "ports-sync"] + identifiers: list[str] = Field(default_factory=list) + all_vms: bool = Field(default=False, strict=True) + timeout: float | None = None + + +class VmActionResultResponse(BaseModel): + id: str + action: str + outcome: str + error: str | None = None + + def vm_response( vm: VmRecord, *, diff --git a/uvm/dashboard/__init__.py b/uvm/dashboard/__init__.py new file mode 100644 index 0000000..1cca740 --- /dev/null +++ b/uvm/dashboard/__init__.py @@ -0,0 +1 @@ +# UVM Dashboard static assets package. diff --git a/uvm/dashboard/static/css/dashboard.css b/uvm/dashboard/static/css/dashboard.css new file mode 100644 index 0000000..fd4052c --- /dev/null +++ b/uvm/dashboard/static/css/dashboard.css @@ -0,0 +1,470 @@ +/* ─── Design Tokens ─── */ +:root { + --bg-body: #0b0b0f; + --bg-sidebar: #0f0f14; + --bg-card: #141419; + --bg-card-hover: #1a1a22; + --bg-input: #0e0e12; + --bg-elevated: #1c1c24; + + --border-color: #1f1f28; + --border-hover: #2a2a36; + --divider: #1a1a22; + + --text-primary: #f0f0f5; + --text-secondary: #8a8a98; + --text-muted: #5a5a68; + + --accent: #6366f1; + --accent-hover: #818cf8; + --accent-soft: rgba(99, 102, 241, 0.12); + + --green: #22c55e; + --green-soft: rgba(34, 197, 94, 0.12); + --red: #ef4444; + --red-soft: rgba(239, 68, 68, 0.12); + --amber: #f59e0b; + --amber-soft: rgba(245, 158, 11, 0.12); + --blue: #3b82f6; + --blue-soft: rgba(59, 130, 246, 0.12); + --purple: #a855f7; + --purple-soft: rgba(168, 85, 247, 0.12); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow-md: 0 4px 12px rgba(0,0,0,0.5); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.6); + + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ─── Reset ─── */ +*, *::before, *::after { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: var(--font-sans); + background: var(--bg-body); + color: var(--text-primary); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ─── Layout ─── */ +#app { min-height: 100vh; } +.screen { display: none; min-height: 100vh; } +.screen.active { display: flex; } + +#main-app { flex-direction: row; } + +.sidebar { + width: 260px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 24px 16px; + position: fixed; + inset: 0 auto 0 0; + z-index: 50; +} + +.sidebar-brand { margin-bottom: 32px; padding: 0 8px; } +.sidebar-brand .logo { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; + font-size: 18px; + letter-spacing: -0.02em; + color: var(--text-primary); +} +.sidebar-brand .logo svg { color: var(--text-primary); } + +.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; } +.nav-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; font-weight: 500; + transition: background var(--transition), color var(--transition); +} +.nav-item:hover { background: var(--bg-card-hover); color: var(--text-primary); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); } +.nav-item svg { opacity: 0.8; } + +.sidebar-footer { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--divider); + display: flex; + flex-direction: column; + gap: 12px; +} +.connection-status { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--text-muted); +} +.status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--green); + box-shadow: 0 0 6px var(--green); +} +.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); } + +.main { + flex: 1; + margin-left: 260px; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.topbar { + position: sticky; top: 0; z-index: 40; + background: rgba(11,11,15,0.8); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-color); + padding: 16px 32px; + display: flex; align-items: center; justify-content: space-between; +} +.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; } +.topbar-right { display: flex; align-items: center; gap: 10px; } + +.content { padding: 28px 32px; flex: 1; } + +/* ─── Pages ─── */ +.page { display: none; animation: fadeIn 220ms ease; } +.page.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } + +/* ─── Cards ─── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 24px; + transition: border-color var(--transition), box-shadow var(--transition); +} +.card:hover { border-color: var(--border-hover); } +.card-title { margin: 0 0 16px; font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } +.card-heading { margin: 0 0 20px; font-size: 18px; font-weight: 600; } +.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.card-title-row .card-title { margin: 0; } + +/* ─── Stats ─── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 28px; +} +.stats-grid.two { grid-template-columns: repeat(2, 1fr); } + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; +} +.stat-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; +} +.stat-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } +.stat-icon { + width: 36px; height: 36px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; +} +.icon-blue { background: var(--blue-soft); color: var(--blue); } +.icon-green { background: var(--green-soft); color: var(--green); } +.icon-amber { background: var(--amber-soft); color: var(--amber); } +.icon-purple { background: var(--purple-soft); color: var(--purple); } +.stat-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; } +.stat-meta { font-size: 12px; color: var(--text-muted); margin-top: 8px; } +.text-green { color: var(--green); } +.text-amber { color: var(--amber); } +.text-red { color: var(--red); } + +/* ─── Buttons ─── */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border: 1px solid transparent; border-radius: var(--radius-md); + padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit; + cursor: pointer; transition: background var(--transition), border-color var(--transition), transform 80ms ease; + background: transparent; color: var(--text-primary); +} +.btn:active { transform: scale(0.98); } +.btn-primary { background: var(--text-primary); color: var(--bg-body); border-color: var(--text-primary); } +.btn-primary:hover { background: #e2e2e8; border-color: #e2e2e8; } +.btn-ghost { border-color: var(--border-color); color: var(--text-secondary); } +.btn-ghost:hover { background: var(--bg-card-hover); color: var(--text-primary); } +.btn-danger { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.25); } +.btn-danger:hover { background: rgba(239,68,68,0.2); } +.btn-warning { background: var(--amber-soft); color: var(--amber); border-color: rgba(245,158,11,0.25); } +.btn-warning:hover { background: rgba(245,158,11,0.2); } +.btn-sm { padding: 6px 10px; font-size: 12px; } +.btn-lg { padding: 10px 18px; font-size: 14px; } +.btn-icon { padding: 8px; } + +/* ─── Tables ─── */ +.table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.data-table thead th { + text-align: left; padding: 12px 16px; + color: var(--text-secondary); font-weight: 500; font-size: 12px; + border-bottom: 1px solid var(--divider); + white-space: nowrap; +} +.data-table tbody td { padding: 14px 16px; border-bottom: 1px solid var(--divider); vertical-align: middle; } +.data-table tbody tr { transition: background var(--transition); } +.data-table tbody tr:hover { background: var(--bg-card-hover); } +.data-table .actions { text-align: right; white-space: nowrap; } +.data-table .selection { width: 36px; padding-right: 0; text-align: center; } +.data-table input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } + +.table-empty { display: none; padding: 40px 0; } +.table-empty.visible { display: block; } + +/* ─── Badges ─── */ +.badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; +} +.badge-running { background: var(--green-soft); color: var(--green); } +.badge-stopped { background: var(--red-soft); color: var(--red); } +.badge-unknown { background: var(--bg-elevated); color: var(--text-muted); } +.status-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; +} +.badge-loading { background: var(--bg-elevated); color: var(--text-muted); } +.badge-ok { background: var(--green-soft); color: var(--green); } +.badge-error { background: var(--red-soft); color: var(--red); } + +/* ─── VM Grid / List ─── */ +.vm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} +.vm-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 18px; + cursor: pointer; + transition: border-color var(--transition), transform var(--transition); +} +.vm-card:hover { border-color: var(--border-hover); transform: translateY(-2px); } +.vm-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.vm-card-name { font-weight: 600; font-size: 14px; } +.vm-card-ip { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.vm-card-meta { display: flex; gap: 12px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); } +.vm-card-meta span { display: flex; align-items: center; gap: 4px; } + +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.section-header h3 { margin: 0; font-size: 16px; font-weight: 600; } +.link { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 500; } +.link:hover { text-decoration: underline; } + +/* ─── Toolbar ─── */ +.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.search-box { + display: flex; align-items: center; gap: 10px; + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 0 12px; flex: 1; max-width: 360px; +} +.search-box input { + background: transparent; border: none; outline: none; color: var(--text-primary); + font: inherit; font-size: 13px; padding: 9px 0; width: 100%; +} +.search-box svg { color: var(--text-muted); flex-shrink: 0; } +.select { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); color: var(--text-primary); + font: inherit; font-size: 13px; padding: 8px 28px 8px 12px; + appearance: none; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a98' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; +} +.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.bulk-controls { display: none; align-items: center; gap: 8px; } +.bulk-controls.visible { display: flex; } +.bulk-controls span { color: var(--text-secondary); font-size: 12px; white-space: nowrap; } + +/* ─── Forms ─── */ +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field.full { grid-column: 1 / -1; } +.field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.03em; } +.field input, .field select { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); color: var(--text-primary); + font: inherit; font-size: 13px; padding: 10px 12px; + transition: border-color var(--transition); +} +.field input:focus, .field select:focus { outline: none; border-color: var(--accent); } +.hint-inline { color: var(--text-muted); font-weight: 400; text-transform: none; letter-spacing: 0; } +.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 24px; } +.checkbox-field { flex-direction: row; align-items: center; gap: 10px; } +.checkbox { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-secondary); } +.checkbox input { position: absolute; opacity: 0; width: 0; height: 0; } +.checkmark { + width: 18px; height: 18px; border-radius: 5px; + border: 1px solid var(--border-hover); background: var(--bg-input); + display: flex; align-items: center; justify-content: center; + transition: background var(--transition), border-color var(--transition); +} +.checkbox input:checked + .checkmark { background: var(--accent); border-color: var(--accent); } +.checkmark::after { + content: ''; width: 5px; height: 9px; border: solid white; + border-width: 0 2px 2px 0; transform: rotate(45deg); + opacity: 0; transition: opacity var(--transition); +} +.checkbox input:checked + .checkmark::after { opacity: 1; } + +/* ─── Auth Screen ─── */ +#auth-screen { align-items: center; justify-content: center; padding: 24px; } +.auth-card { + width: 100%; max-width: 420px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + padding: 36px; + box-shadow: var(--shadow-lg); +} +.auth-header { text-align: center; margin-bottom: 28px; } +.auth-header h1 { margin: 16px 0 6px; font-size: 22px; font-weight: 700; } +.auth-header p { margin: 0; color: var(--text-secondary); font-size: 14px; } +.auth-form { display: flex; flex-direction: column; gap: 16px; } +.error-text { color: var(--red); font-size: 12px; margin: 0; min-height: 16px; } +.error-block { color: var(--red); font-size: 12px; background: var(--red-soft); padding: 12px; border-radius: var(--radius-md); border: 1px solid rgba(239,68,68,0.2); margin-top: 12px; white-space: pre-wrap; } +.error-block.hidden { display: none; } + +/* ─── VM Detail ─── */ +.vm-detail-header { margin-bottom: 20px; } +.back-btn { margin-bottom: 12px; } +.vm-detail-title { display: flex; align-items: center; gap: 12px; } +.vm-detail-title h2 { margin: 0; font-size: 22px; font-weight: 700; } +.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } +.detail-card.wide { grid-column: 1 / -1; } +.detail-rows { display: flex; flex-direction: column; gap: 12px; } +.detail-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--divider); } +.detail-row:last-child { border-bottom: none; } +.detail-key { color: var(--text-secondary); } +.detail-value { color: var(--text-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } +.port-list { display: flex; flex-wrap: wrap; gap: 8px; } +.port-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border-color); + font-size: 12px; font-weight: 500; +} +.port-editor { display: none; margin-top: 12px; } +.port-editor-input { + width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-input); color: var(--text-primary); + font: inherit; font-size: 13px; +} +.port-editor-input:focus { outline: none; border-color: var(--accent); } +.port-editor-actions, .port-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.action-bar { display: flex; gap: 10px; flex-wrap: wrap; } +.hint { font-size: 12px; color: var(--text-muted); margin-top: 10px; } +.muted { color: var(--text-muted); } +.ssh-command { + display: block; margin-bottom: 12px; padding: 10px; + border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); color: #c4c4d2; overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +/* ─── System ─── */ +.system-card .card-title { margin-bottom: 12px; } +.system-status { display: flex; align-items: center; gap: 12px; margin-top: 8px; } +.system-actions { display: flex; align-items: center; gap: 14px; margin-top: 12px; } +.code-block { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; color: var(--text-secondary); margin-top: 12px; overflow: auto; +} +.log-output { + max-height: 320px; overflow: auto; margin: 0; + padding: 14px; border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); color: #b8b8c5; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; +} +.diagnostics-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } +.diagnostic-item { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); +} +.diagnostic-mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; margin-top: 5px; } +.diagnostic-mark.ok { background: var(--green); box-shadow: 0 0 6px var(--green); } +.diagnostic-mark.error { background: var(--red); box-shadow: 0 0 6px var(--red); } +.diagnostic-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.diagnostic-message { margin-top: 2px; font-size: 11px; color: var(--text-muted); overflow-wrap: anywhere; } + +/* ─── Empty State ─── */ +.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); } +.empty-state p { margin: 0 0 12px; } + +/* ─── Modal ─── */ +.modal { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; } +.modal.open { display: flex; } +.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); } +.modal-card { + position: relative; z-index: 1; + background: var(--bg-card); border: 1px solid var(--border-color); + border-radius: var(--radius-xl); padding: 28px; width: 100%; max-width: 400px; + box-shadow: var(--shadow-lg); +} +.modal-card h3 { margin: 0 0 8px; font-size: 18px; } +.modal-card p { margin: 0 0 20px; color: var(--text-secondary); font-size: 13px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } + +/* ─── Toasts ─── */ +#toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 10px; } +.toast { + background: var(--bg-card); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 12px 16px; + box-shadow: var(--shadow-md); font-size: 13px; + animation: slideIn 200ms ease; min-width: 260px; + display: flex; align-items: center; gap: 10px; +} +.toast.success { border-color: rgba(34,197,94,0.35); color: var(--green); } +.toast.error { border-color: rgba(239,68,68,0.35); color: var(--red); } +@keyframes slideIn { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } } + +/* ─── Responsive ─── */ +@media (max-width: 1024px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .detail-grid { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + .sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } + .main { margin-left: 0; } + #main-app { flex-direction: column; } + .stats-grid { grid-template-columns: 1fr; } + .form-grid { grid-template-columns: 1fr; } + .content { padding: 20px; } + .topbar { padding: 12px 20px; } +} + +/* ─── Scrollbar ─── */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #2a2a36; border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #3a3a48; } diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html new file mode 100644 index 0000000..e7e8f75 --- /dev/null +++ b/uvm/dashboard/static/index.html @@ -0,0 +1,379 @@ + + + + + + UVM Dashboard + + + +

    + +
    +
    +
    + +

    Welcome back

    +

    Enter your management API token to access the dashboard.

    +
    +
    +
    + + +
    + +

    +
    +
    +
    + + +
    + + +
    +
    +
    +

    Overview

    +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    + Total VMs +
    + +
    +
    +
    —
    +
    registered VMs
    +
    +
    +
    + Running +
    + +
    +
    +
    —
    +
    active microVMs
    +
    +
    +
    + Stopped +
    + +
    +
    +
    —
    +
    inactive microVMs
    +
    +
    +
    + API Health +
    + +
    +
    +
    —
    +
    last check —
    +
    +
    + +
    +

    Recent Virtual Machines

    + View all +
    +
    +
    +

    No virtual machines yet.

    + +
    +
    +
    + + +
    +
    +
    + +
    +
    + 0 selected + + +
    + +
    +
    +
    + + + + + + + + + + + + + + +
    VMStatusGuest IPResourcesPortsPolicyActions
    +
    +
    +
    +

    No virtual machines found.

    + +
    +
    +
    +
    + + +
    +
    + +
    +

    —

    + — +
    +
    +
    +
    +

    Configuration

    +
    +
    +
    +
    +

    Networking

    +
    +
    +
    +
    +

    SSH Access

    + ssh root@10.42.0.2 + +

    Run this from a trusted local terminal.

    +
    +
    +

    Port Rules

    +
    +

    No port rules configured.

    +
    +
    + +
    + + + + +
    +
    +
    + + +
    +
    +
    +
    +

    Firecracker Log

    + +
    +
    Loading recent log entries…
    +

    Showing up to 100 recent lines.

    +
    +
    +

    Actions

    +
    + + + + +
    +

    Use the copied SSH command from a trusted local terminal.

    +
    +
    +
    + + +
    +
    +

    Create Virtual Machine

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    
    +            
    +
    + + +
    +
    +
    +

    API Health

    +
    + Checking… +

    Send a test request to the management API.

    +
    +
    +
    +

    Installation

    +

    Install or refresh host assets (Firecracker, kernel, rootfs). This is a privileged operation.

    +
    + + +
    + +
    +
    + +
    +

    Server Information

    +
    +
    Dashboard Version1.0.0
    +
    API URL—
    +
    Observed At—
    +
    +
    +
    +
    +

    Host Diagnostics

    + +
    +
    +

    Open System to run host readiness checks.

    +
    +
    +
    +
    +
    +
    + + +
    + + + +
    + + + + diff --git a/uvm/dashboard/static/js/dashboard.js b/uvm/dashboard/static/js/dashboard.js new file mode 100644 index 0000000..d4cae4d --- /dev/null +++ b/uvm/dashboard/static/js/dashboard.js @@ -0,0 +1,739 @@ +/* ─── UVM Dashboard ─── */ + +(function () { + 'use strict'; + + // ─ State ─ + const state = { + token: sessionStorage.getItem('uvm_token') || '', + baseUrl: location.origin, + vms: [], + health: null, + diagnostics: null, + selectedVmIds: new Set(), + currentPage: 'overview', + currentVmId: null, + refreshTimer: null, + }; + + // ─ Helpers ─ + const $ = (sel, el=document) => el.querySelector(sel); + const $$ = (sel, el=document) => [...el.querySelectorAll(sel)]; + + function formatRam(mib) { + const value = Number(mib); + if (!Number.isFinite(value)) return '—'; + return value % 1024 === 0 ? `${value / 1024} GiB` : `${value} MiB`; + } + + function statusClass(status) { + if (status === 'running') return 'running'; + if (status === 'stopped') return 'stopped'; + return 'unknown'; + } + + function showScreen(id) { + $$('.screen').forEach(s => s.classList.remove('active')); + $(id).classList.add('active'); + } + + function showPage(name) { + state.currentPage = name; + $$('.page').forEach(p => p.classList.remove('active')); + const page = $('#page-' + name); + if (page) page.classList.add('active'); + + $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === name)); + + const titles = { + overview: 'Overview', + vms: 'Virtual Machines', + 'vm-detail': 'VM Details', + create: 'Create VM', + system: 'System', + }; + $('#page-title').textContent = titles[name] || name; + } + + function toast(message, type='success') { + const t = document.createElement('div'); + t.className = 'toast ' + type; + t.textContent = message; + $('#toast-container').appendChild(t); + setTimeout(() => t.remove(), 3500); + } + + async function api(path, opts={}) { + const url = (state.baseUrl || '').replace(/\/$/, '') + path; + const res = await fetch(url, { + ...opts, + headers: { + 'Content-Type': 'application/json', + 'X-UVM-Token': state.token, + ...(opts.headers || {}), + }, + }); + const text = await res.text(); + let data = null; + try { data = JSON.parse(text); } catch {} + if (!res.ok) { + const msg = data?.error?.message || `HTTP ${res.status}`; + throw new Error(msg); + } + return data; + } + + // ─ Auth ─ + function initAuth() { + if (state.token) { + showScreen('#main-app'); + showPage('overview'); + startRefresh(); + return; + } + showScreen('#auth-screen'); + } + + $('#auth-btn').addEventListener('click', async () => { + const token = $('#token-input').value.trim(); + if (!token) { $('#auth-error').textContent = 'Token is required.'; return; } + state.token = token; + try { + // Health is intentionally public, so verify the supplied token on a protected route. + state.vms = await api('/vms') || []; + sessionStorage.setItem('uvm_token', token); + $('#token-input').value = ''; + $('#auth-error').textContent = ''; + showScreen('#main-app'); + showPage('overview'); + startRefresh(); + toast('Connected successfully'); + } catch (e) { + $('#auth-error').textContent = e.message || 'Connection failed.'; + state.token = ''; + } + }); + + $('#logout-btn').addEventListener('click', () => { + state.token = ''; + $('#token-input').value = ''; + sessionStorage.removeItem('uvm_token'); + localStorage.removeItem('uvm_token'); + localStorage.removeItem('uvm_baseUrl'); + stopRefresh(); + showScreen('#auth-screen'); + }); + + // ─ Data Loading ─ + async function loadVms() { + try { + state.vms = await api('/vms') || []; + const availableIds = new Set(state.vms.map(vm => vm.id)); + state.selectedVmIds = new Set( + [...state.selectedVmIds].filter(id => availableIds.has(id)), + ); + renderOverviewStats(); + renderOverviewVMs(); + renderVMsTable(); + if (state.currentPage === 'vm-detail' && state.currentVmId) { + renderVMDetail(state.currentVmId); + } + updateConnStatus(true); + } catch (e) { + updateConnStatus(false); + console.error('loadVms failed', e); + } + } + + async function loadHealth() { + try { + state.health = await api('/health'); + const el = $('#stat-health'); + el.textContent = state.health?.status || 'ok'; + el.className = 'stat-value text-green'; + $('#health-ago').textContent = 'just now'; + renderSystemHealth(); + } catch (e) { + $('#stat-health').textContent = 'unreachable'; + $('#stat-health').className = 'stat-value text-red'; + $('#health-ago').textContent = '—'; + renderSystemHealth(); + } + } + + async function loadDiagnostics() { + const list = $('#diagnostics-list'); + list.innerHTML = '

    Running host readiness checks…

    '; + try { + state.diagnostics = await api('/diagnostics'); + renderDiagnostics(); + } catch (e) { + state.diagnostics = null; + list.innerHTML = `

    Diagnostics unavailable: ${esc(e.message)}

    `; + } + } + + function renderDiagnostics() { + const list = $('#diagnostics-list'); + if (!state.diagnostics?.length) { + list.innerHTML = '

    No diagnostic checks returned.

    '; + return; + } + list.innerHTML = state.diagnostics.map((check) => ` +
    + +
    +
    ${esc(check.name)}
    +
    ${esc(check.message)}
    +
    +
    + `).join(''); + } + + function updateConnStatus(ok) { + const dot = $('#conn-dot'); + const label = $('#conn-label'); + dot.classList.toggle('offline', !ok); + label.textContent = ok ? 'Connected' : 'Disconnected'; + } + + function renderOverviewStats() { + const total = state.vms.length; + const running = state.vms.filter(v => v.status === 'running').length; + const stopped = state.vms.filter(v => v.status !== 'running').length; + $('#stat-total').textContent = total; + $('#stat-running').textContent = running; + $('#stat-stopped').textContent = stopped; + } + + function renderOverviewVMs() { + const container = $('#overview-vm-list'); + if (!state.vms.length) { + container.innerHTML = ` +
    +

    No virtual machines yet.

    + +
    `; + return; + } + const recent = state.vms.slice(0, 6); + container.innerHTML = recent.map(vm => ` +
    +
    +
    +
    ${esc(vm.name || vm.id)}
    +
    ${esc(vm.guest_ip || '—')}
    +
    + ${esc(vm.status || 'unknown')} +
    +
    + ${vm.cpu || 1} vCPU + ${formatRam(vm.ram_mib)} +
    +
    + `).join(''); + } + + function renderVMsTable() { + const tbody = $('#vms-table tbody'); + const empty = $('#vms-empty'); + const selectAll = $('#vm-select-all'); + const filterStatus = $('#vm-filter-status').value; + const search = $('#vm-search').value.trim().toLowerCase(); + + let list = state.vms.slice(); + if (filterStatus !== 'all') { + list = list.filter(v => v.status === filterStatus); + } + if (search) { + list = list.filter(v => + (v.name || '').toLowerCase().includes(search) || + (v.id || '').toLowerCase().includes(search) || + (v.guest_ip || '').includes(search) + ); + } + + if (!list.length) { + tbody.innerHTML = ''; + empty.classList.add('visible'); + selectAll.checked = false; + selectAll.indeterminate = false; + renderBulkControls(); + return; + } + empty.classList.remove('visible'); + + tbody.innerHTML = list.map(vm => { + const ports = (vm.ports || []).map(p => `${p.port}:${p.protocol}`).join(' ') || '—'; + const actionBtns = []; + if (vm.status === 'running') { + actionBtns.push(``); + actionBtns.push(``); + } else { + actionBtns.push(``); + } + return ` + + + +
    ${esc(vm.name || '—')}
    +
    ${esc(vm.id)}
    + + ${esc(vm.status || 'unknown')} + ${esc(vm.guest_ip || '—')} + ${vm.cpu || 1} vCPU · ${formatRam(vm.ram_mib)} + ${ports} + ${esc(vm.policy_status || '—')} + + + ${actionBtns.join('')} + + + + `; + }).join(''); + const selectedVisible = list.filter(vm => state.selectedVmIds.has(vm.id)).length; + selectAll.checked = selectedVisible === list.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < list.length; + renderBulkControls(); + } + + function renderBulkControls() { + const count = state.selectedVmIds.size; + $('#bulk-controls').classList.toggle('visible', count > 0); + $('#bulk-selection-count').textContent = `${count} selected`; + } + + // ─ VM Details ─ + async function renderVMDetail(id) { + let vm = state.vms.find(v => v.id === id || v.name === id || v.guest_ip === id); + if (!vm) { + try { vm = await api(`/vms/${id}`); } catch (e) { + toast('Failed to load VM details: ' + e.message, 'error'); + location.hash = '#vms'; + return; + } + } + state.currentVmId = vm.id; + $('#detail-name').textContent = vm.name || vm.id; + $('#detail-status').textContent = vm.status || 'unknown'; + $('#detail-status').className = 'badge badge-' + statusClass(vm.status); + + $('#detail-config').innerHTML = ` +
    ID${esc(vm.id)}
    +
    Name${esc(vm.name || '—')}
    +
    Status${esc(vm.status)}
    +
    Operation${esc(vm.operation || '—')}
    +
    vCPUs${vm.cpu || 1}
    +
    RAM${formatRam(vm.ram_mib)}
    +
    Guest User${esc(vm.username || '—')}
    + `; + + $('#detail-network').innerHTML = ` +
    Guest IP${esc(vm.guest_ip || '—')}
    +
    Observed Status${esc(vm.observed_status || '—')}
    +
    Policy Status${esc(vm.policy_status || '—')}
    + `; + + const sshCommand = `ssh ${vm.username || 'root'}@${vm.guest_ip}`; + const sshAllowed = (vm.ports || []).some(port => port.port === 22); + $('#detail-ssh-command').textContent = sshCommand; + $('#detail-ssh-copy-btn').onclick = () => copyText(sshCommand); + $('#detail-ssh-hint').textContent = sshAllowed + ? 'Run this from a trusted local terminal; UVM does not expose guest passwords.' + : 'TCP/22 is not currently allowed by this VM policy.'; + + const ports = vm.ports || []; + $('#detail-ports').innerHTML = vm.ports === null + ? `

    This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}

    ` + : ports.length + ? ports.map(p => `${p.port} / ${p.protocol}`).join(' ') + : '

    No port rules configured.

    '; + + // Actions visibility + $('#detail-start-btn').style.display = vm.status !== 'running' ? 'inline-flex' : 'none'; + $('#detail-start-btn').onclick = () => startVM(vm.id); + $('#detail-restart-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; + $('#detail-restart-btn').onclick = () => restartVM(vm.id); + $('#detail-stop-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; + $('#detail-stop-btn').onclick = () => stopVM(vm.id); + $('#detail-delete-btn').onclick = () => deleteVM(vm.id); + + // Port editing + const portsInput = $('#detail-ports-input'); + portsInput.value = ports.map(p => `${p.port}:${p.protocol}`).join(', '); + $('#detail-ports-edit').style.display = 'none'; + $('#detail-ports').style.display = 'block'; + const canSetLegacyPorts = vm.ports === null && vm.status === 'stopped'; + $('#detail-ports-edit-btn').style.display = vm.ports !== null || canSetLegacyPorts ? 'inline-flex' : 'none'; + $('#detail-ports-edit-btn').onclick = () => { + $('#detail-ports-edit').style.display = 'block'; + $('#detail-ports').style.display = 'none'; + }; + $('#detail-ports-cancel').onclick = () => { + $('#detail-ports-edit').style.display = 'none'; + $('#detail-ports').style.display = 'block'; + }; + $('#detail-ports-set').onclick = () => updatePorts(vm.id, 'set'); + $('#detail-ports-set').style.display = 'inline-flex'; + $('#detail-ports-open').onclick = () => updatePorts(vm.id, 'open'); + $('#detail-ports-open').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-close').onclick = () => updatePorts(vm.id, 'close'); + $('#detail-ports-close').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-sync-btn').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-sync-btn').onclick = () => syncPorts(vm.id); + $('#detail-log-refresh-btn').onclick = () => loadVmLogs(vm.id); + loadVmLogs(vm.id); + } + + async function loadVmLogs(id) { + const output = $('#detail-log-output'); + const meta = $('#detail-log-meta'); + output.textContent = 'Loading recent log entries…'; + meta.textContent = 'Showing up to 100 recent lines.'; + try { + const log = await api(`/vms/${encodeURIComponent(id)}/logs?tail=100`); + if (state.currentVmId !== id) return; + output.textContent = log.content || 'No log entries yet.'; + meta.textContent = log.truncated + ? 'Showing the available tail; an oversized log line was truncated.' + : 'Showing up to 100 recent lines.'; + } catch (e) { + if (state.currentVmId !== id) return; + output.textContent = 'Log unavailable.'; + meta.textContent = e.message; + } + } + + // ─ Actions ─ + async function startVM(id) { + try { + await api(`/vms/${id}/start`, { method: 'POST' }); + toast('VM started'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Start failed: ' + e.message, 'error'); } + } + + async function restartVM(id) { + showConfirm(`Restart ${esc(id)}?`, 'The VM will be stopped and then started again.', async () => { + try { + await api(`/vms/${id}/restart`, { method: 'POST' }); + toast('VM restarted'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Restart failed: ' + e.message, 'error'); } + }); + } + + async function stopVM(id) { + showConfirm(`Stop ${esc(id)}?`, 'The VM will be sent SIGTERM and SIGKILL if necessary. Guest data is retained.', async () => { + try { + await api(`/vms/${id}/stop`, { method: 'POST' }); + toast('VM stopped'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Stop failed: ' + e.message, 'error'); } + }); + } + + async function deleteVM(id) { + showConfirm(`Delete ${esc(id)}?`, 'This permanently removes the VM disk and releases its name and IP.', async () => { + try { + await api(`/vms/${id}`, { method: 'DELETE' }); + toast('VM deleted'); + if (state.currentPage === 'vm-detail') location.hash = '#vms'; + await loadVms(); + } catch (e) { toast('Delete failed: ' + e.message, 'error'); } + }); + } + + async function updatePorts(id, mode) { + let ports; + try { + ports = parsePorts($('#detail-ports-input').value); + } catch (e) { + toast(e.message, 'error'); + return; + } + const payload = { mode, ports }; + try { + await api(`/vms/${id}/ports`, { method: 'POST', body: JSON.stringify(payload) }); + toast({ set: 'Ports updated', open: 'Ports opened', close: 'Ports closed' }[mode]); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Port update failed: ' + e.message, 'error'); } + } + + async function syncPorts(id) { + try { + await api(`/vms/${id}/ports/sync`, { method: 'POST' }); + toast('Ports synced'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Port sync failed: ' + e.message, 'error'); } + } + + // ─ Create VM ─ + $('#create-submit').addEventListener('click', async () => { + let ports; + try { + ports = parsePorts($('#create-ports').value); + } catch (e) { + showCreateError(e.message); + return; + } + const body = { + name: $('#create-name').value.trim() || undefined, + cpu: parseInt($('#create-cpu').value, 10) || 1, + ram: $('#create-ram').value.trim() || '512M', + guest_ip: $('#create-guest-ip').value.trim() || undefined, + username: $('#create-username').value.trim() || undefined, + password: $('#create-password').value, + ports, + no_default_ports: $('#create-no-default-ports').checked, + }; + if (!body.password) { showCreateError('Password is required.'); return; } + const errEl = $('#create-error'); + errEl.classList.add('hidden'); + try { + await api('/vms', { method: 'POST', body: JSON.stringify(body) }); + toast('VM created successfully'); + resetCreateForm(); + location.hash = '#vms'; + await loadVms(); + } catch (e) { + showCreateError(e.message); + } + }); + + $('#create-cancel').addEventListener('click', () => { history.back(); }); + + function parsePorts(val) { + if (!val.trim()) return []; + return val.split(',').map((entry) => { + const parts = entry.trim().split(':'); + const port = Number(parts[0]); + const protocol = (parts[1] || 'TCP').trim().toUpperCase(); + if ( + parts.length > 2 + || !Number.isInteger(port) + || port < 1 + || port > 65535 + || !['TCP', 'HTTP', 'HTTPS'].includes(protocol) + ) { + throw new Error(`Invalid port rule: ${entry.trim()}`); + } + return { port, protocol }; + }); + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + toast('Command copied'); + } catch { + const field = document.createElement('textarea'); + field.value = value; + field.style.position = 'fixed'; + field.style.opacity = '0'; + document.body.appendChild(field); + field.select(); + document.execCommand('copy'); + field.remove(); + toast('Command copied'); + } + } + + function showCreateError(msg) { + const el = $('#create-error'); + el.textContent = msg; + el.classList.remove('hidden'); + } + + function resetCreateForm() { + $('#create-name').value = ''; + $('#create-cpu').value = '1'; + $('#create-ram').value = '512M'; + $('#create-guest-ip').value = ''; + $('#create-username').value = 'root'; + $('#create-password').value = 'root'; + $('#create-ports').value = '22:TCP'; + $('#create-no-default-ports').checked = false; + $('#create-error').classList.add('hidden'); + } + + // ─ System ─ + function renderSystemHealth() { + const wrap = $('#system-health'); + if (!state.health) { + wrap.innerHTML = 'Checking…

    Send a test request to the management API.

    '; + return; + } + wrap.innerHTML = `Healthy

    API responding normally.

    `; + } + + $('#install-btn').addEventListener('click', async () => { + const force = $('#install-force').checked; + const result = $('#install-result'); + result.classList.remove('hidden'); + result.textContent = 'Installing…'; + try { + const data = await api('/install', { method: 'POST', body: JSON.stringify({ force }) }); + result.textContent = JSON.stringify(data, null, 2); + toast('Installation completed'); + } catch (e) { + result.textContent = 'Error: ' + e.message; + toast('Installation failed: ' + e.message, 'error'); + } + }); + $('#diagnostics-refresh-btn').addEventListener('click', loadDiagnostics); + + // ─ Router ─ + function route() { + const hash = location.hash.slice(1) || 'overview'; + if (hash.startsWith('vm/')) { + showPage('vm-detail'); + renderVMDetail(hash.slice(3)); + } else if (hash === 'vms') { + showPage('vms'); + renderVMsTable(); + } else if (hash === 'create') { + showPage('create'); + } else if (hash === 'system') { + showPage('system'); + $('#info-api-url').textContent = state.baseUrl || '—'; + $('#info-observed').textContent = new Date().toLocaleString(); + loadDiagnostics(); + } else { + showPage('overview'); + } + } + + // ─ Modal ─ + function showConfirm(title, body, onOk) { + $('#confirm-title').innerHTML = title; + $('#confirm-body').textContent = body; + $('#confirm-modal').classList.add('open'); + + const okHandler = async () => { + $('#confirm-modal').classList.remove('open'); + cleanup(); + await onOk(); + }; + const cancelHandler = () => { + $('#confirm-modal').classList.remove('open'); + cleanup(); + }; + + function cleanup() { + $('#confirm-ok').removeEventListener('click', okHandler); + $('#confirm-cancel').removeEventListener('click', cancelHandler); + $('#confirm-modal .modal-overlay').removeEventListener('click', cancelHandler); + } + + $('#confirm-ok').addEventListener('click', okHandler); + $('#confirm-cancel').addEventListener('click', cancelHandler); + $('#confirm-modal .modal-overlay').addEventListener('click', cancelHandler); + } + + // ─ Refresh ─ + function startRefresh() { + loadVms(); + loadHealth(); + state.refreshTimer = setInterval(() => { loadVms(); loadHealth(); }, 5000); + } + function stopRefresh() { + if (state.refreshTimer) clearInterval(state.refreshTimer); + state.refreshTimer = null; + } + + $('#refresh-btn').addEventListener('click', () => { + loadVms(); + loadHealth(); + if (state.currentPage === 'system') loadDiagnostics(); + toast('Refreshed'); + }); + $('#create-vm-btn').addEventListener('click', () => { location.hash = '#create'; }); + + // Search / Filter + $('#vm-search').addEventListener('input', renderVMsTable); + $('#vm-filter-status').addEventListener('change', renderVMsTable); + $('#vm-select-all').addEventListener('change', (event) => { + $$('input[data-vm-select]', $('#vms-table tbody')).forEach((checkbox) => { + if (event.target.checked) state.selectedVmIds.add(checkbox.dataset.vmSelect); + else state.selectedVmIds.delete(checkbox.dataset.vmSelect); + }); + renderVMsTable(); + }); + $('#vms-table tbody').addEventListener('click', (event) => { + const selection = event.target.closest('input[data-vm-select]'); + if (selection) { + if (selection.checked) state.selectedVmIds.add(selection.dataset.vmSelect); + else state.selectedVmIds.delete(selection.dataset.vmSelect); + renderVMsTable(); + return; + } + const button = event.target.closest('button[data-vm-action]'); + if (!button) return; + + const { vmAction, vmId } = button.dataset; + if (vmAction === 'view') location.hash = `#vm/${vmId}`; + if (vmAction === 'start') startVM(vmId); + if (vmAction === 'restart') restartVM(vmId); + if (vmAction === 'stop') stopVM(vmId); + if (vmAction === 'delete') deleteVM(vmId); + }); + $('#bulk-run-btn').addEventListener('click', () => { + const action = $('#bulk-action').value; + const ids = [...state.selectedVmIds]; + const labels = { + start: 'Start', + restart: 'Restart', + stop: 'Stop', + 'ports-sync': 'Sync ports for', + delete: 'Delete', + }; + const detail = action === 'delete' + ? 'This permanently removes every selected VM disk and releases its resources.' + : `${labels[action]} ${ids.length} selected VM${ids.length === 1 ? '' : 's'}.`; + showConfirm( + `${labels[action]} ${ids.length} VM${ids.length === 1 ? '' : 's'}?`, + detail, + async () => { + try { + const results = await api('/vms/actions', { + method: 'POST', + body: JSON.stringify({ action, identifiers: ids }), + }); + const failed = results.filter(result => result.outcome !== 'succeeded'); + state.selectedVmIds.clear(); + await loadVms(); + toast( + failed.length + ? `${results.length - failed.length} succeeded, ${failed.length} failed` + : `${results.length} VM${results.length === 1 ? '' : 's'} updated`, + failed.length ? 'error' : 'success', + ); + } catch (e) { + toast(`Bulk ${action} failed: ${e.message}`, 'error'); + } + }, + ); + }); + + // ─ Utils ─ + function esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ─ Boot ─ + window.addEventListener('hashchange', route); + initAuth(); + route(); +})(); diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index b21ce0e..7182f79 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import re import shutil import time @@ -39,6 +40,15 @@ class OperationResult: error: str | None = None +@dataclass(frozen=True, slots=True) +class LogTail: + content: str + truncated: bool + + +_MAX_LOG_TAIL_BYTES = 262_144 + + class LifecycleService: """All mutating entry points use operation lock -> short state transaction.""" @@ -415,6 +425,41 @@ class LifecycleService: self._validate_paths(vm) return Path(vm.log) + def read_log_tail(self, identifier: str, *, tail: int) -> LogTail: + """Read a bounded tail from a validated VM log without following it.""" + if type(tail) is not int or tail < 0: + raise ValidationError("log tail must be a non-negative integer") + path = self.log_path(identifier) + try: + with path.open("rb") as stream: + end = stream.seek(0, os.SEEK_END) + if tail == 0: + return LogTail(content="", truncated=False) + start = max(0, end - _MAX_LOG_TAIL_BYTES) + previous = b"" + if start: + stream.seek(start - 1) + previous = stream.read(1) + stream.seek(start) + content = stream.read(end - start) + except OSError as error: + raise UvmError("could not read VM log") from error + + truncated = False + if start and previous != b"\n": + newline = content.find(b"\n") + if newline == -1: + truncated = True + else: + content = content[newline + 1:] + lines = content.splitlines(keepends=True) + if start and len(lines) < tail: + truncated = True + return LogTail( + content=b"".join(lines[-tail:]).decode("utf-8", errors="replace"), + truncated=truncated, + ) + def diagnostics(self) -> list[dict]: checks = [("root", require_root), ("kvm", check_kvm), ("Firecracker", self._verify_firecracker), diff --git a/uvm/routers/dashboard.py b/uvm/routers/dashboard.py new file mode 100644 index 0000000..f512852 --- /dev/null +++ b/uvm/routers/dashboard.py @@ -0,0 +1,17 @@ +"""Dashboard frontend routes.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +router = APIRouter() + +_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html" + + +@router.get("", response_class=HTMLResponse) +def get_dashboard() -> str: + return _DASHBOARD_HTML.read_text(encoding="utf-8") diff --git a/uvm/routers/diagnostics.py b/uvm/routers/diagnostics.py new file mode 100644 index 0000000..5603d38 --- /dev/null +++ b/uvm/routers/diagnostics.py @@ -0,0 +1,18 @@ +"""Token-protected host diagnostics endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..api_models import DiagnosticResponse +from ..app import Application +from .dependencies import get_authorized_application + +router = APIRouter(tags=["diagnostics"]) + + +@router.get("/diagnostics", response_model=list[DiagnosticResponse]) +def diagnostics( + application: Application = Depends(get_authorized_application), +) -> list[DiagnosticResponse]: + return [DiagnosticResponse(**check) for check in application.lifecycle.diagnostics()] diff --git a/uvm/routers/vms.py b/uvm/routers/vms.py index 6f93344..f432fc6 100644 --- a/uvm/routers/vms.py +++ b/uvm/routers/vms.py @@ -2,18 +2,49 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Query, status -from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response +from ..api_models import ( + DestroyResponse, + PortSyncResponse, + PortUpdateRequest, + VmActionResultResponse, + VmBulkActionRequest, + VmCreateRequest, + VmLogResponse, + VmResponse, + vm_response, +) from ..app import Application -from ..domain import PortRule, VmSpec -from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username +from ..domain import PortRule, VmRecord, VmSpec +from ..validation import ( + parse_cpu, + parse_name, + parse_password, + parse_ports, + parse_ram, + parse_timeout, + parse_username, +) from .dependencies import get_authorized_application router = APIRouter(prefix="/vms", tags=["vms"]) +def _mutation_policy_status(vm: VmRecord) -> str: + """Return the policy state that follows a successful lifecycle mutation.""" + if vm.ports is None: + return "unmanaged" + if vm.operation is not None: + return "unknown" + if vm.status == "running": + return "enforced" + if vm.status == "stopped": + return "inactive" + return "unknown" + + @router.get("", response_model=list[VmResponse]) def list_vms( application: Application = Depends(get_authorized_application), @@ -47,29 +78,117 @@ def create_vm( ports=ports, ) ) - policy_status = "unmanaged" if vm.ports is None else "unknown" - if vm.ports is not None and vm.status == "running" and vm.operation is None: - policy_status = "enforced" - return vm_response(vm, policy_status=policy_status) + return vm_response(vm, policy_status=_mutation_policy_status(vm)) + + +@router.post("/actions", response_model=list[VmActionResultResponse]) +def run_bulk_action( + request: VmBulkActionRequest, + application: Application = Depends(get_authorized_application), +) -> list[VmActionResultResponse]: + timeout = parse_timeout(str(request.timeout)) if request.timeout is not None else None + targets = application.lifecycle.resolve_targets(request.identifiers, all_vms=request.all_vms) + results = application.lifecycle.run_many( + request.action, + [vm.id for vm in targets], + timeout=timeout, + ) + return [ + VmActionResultResponse( + id=result.id, + action=result.action, + outcome=result.outcome, + error=result.error, + ) + for result in results + ] + + +@router.post("/{vm_id}/start", response_model=VmResponse) +def start_vm( + vm_id: str, + application: Application = Depends(get_authorized_application), +) -> VmResponse: + vm = application.lifecycle.start(vm_id) + return vm_response(vm, policy_status=_mutation_policy_status(vm)) + + +@router.post("/{vm_id}/restart", response_model=VmResponse) +def restart_vm( + vm_id: str, + timeout: float | None = None, + application: Application = Depends(get_authorized_application), +) -> VmResponse: + vm = ( + application.lifecycle.restart(vm_id) + if timeout is None + else application.lifecycle.restart(vm_id, timeout=timeout) + ) + return vm_response(vm, policy_status=_mutation_policy_status(vm)) @router.post("/{vm_id}/stop", response_model=VmResponse) def stop_vm( vm_id: str, + timeout: float | None = None, application: Application = Depends(get_authorized_application), ) -> VmResponse: - return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive") + vm = ( + application.lifecycle.stop(vm_id) + if timeout is None + else application.lifecycle.stop(vm_id, timeout=timeout) + ) + return vm_response(vm, policy_status=_mutation_policy_status(vm)) @router.delete("/{vm_id}", response_model=DestroyResponse) def destroy_vm( vm_id: str, + timeout: float | None = None, application: Application = Depends(get_authorized_application), ) -> DestroyResponse: - vm = application.lifecycle.destroy(vm_id) + vm = ( + application.lifecycle.destroy(vm_id) + if timeout is None + else application.lifecycle.destroy(vm_id, timeout=timeout) + ) return DestroyResponse(id=vm.id) +@router.post("/{vm_id}/ports", response_model=VmResponse) +def update_ports( + vm_id: str, + request: PortUpdateRequest, + application: Application = Depends(get_authorized_application), +) -> VmResponse: + ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports]) + vm = application.lifecycle.set_ports(vm_id, ports, mode=request.mode) + return vm_response(vm, policy_status=_mutation_policy_status(vm)) + + +@router.post("/{vm_id}/ports/sync", response_model=PortSyncResponse) +def sync_ports( + vm_id: str, + application: Application = Depends(get_authorized_application), +) -> PortSyncResponse: + vm = application.lifecycle.sync_ports(vm_id) + return PortSyncResponse( + id=vm.id, + policy_status=_mutation_policy_status(vm), + status=vm.status, + ) + + +@router.get("/{identifier}/logs", response_model=VmLogResponse) +def get_vm_logs( + identifier: str, + tail: int = Query(default=100, ge=0, le=1000), + application: Application = Depends(get_authorized_application), +) -> VmLogResponse: + log = application.lifecycle.read_log_tail(identifier, tail=tail) + return VmLogResponse(content=log.content, truncated=log.truncated) + + @router.get("/{identifier}", response_model=VmResponse) def get_vm( identifier: str, diff --git a/uvm/server.py b/uvm/server.py index fa2cd7e..95b0145 100644 --- a/uvm/server.py +++ b/uvm/server.py @@ -27,7 +27,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non try: from fastapi import FastAPI - from fastapi.responses import JSONResponse + from fastapi.responses import JSONResponse, RedirectResponse + from fastapi.staticfiles import StaticFiles except ModuleNotFoundError as error: raise UvmError( "FastAPI server support is not installed. Install this project with its dependencies." @@ -36,6 +37,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non from .routers.health import router as health_router from .routers.installation import router as installation_router from .routers.vms import router as vms_router + from .routers.diagnostics import router as diagnostics_router + from .routers.dashboard import router as dashboard_router api = FastAPI( title="uvm", @@ -54,6 +57,16 @@ def create_api(application: Application | None = None, *, host: str | None = Non api.include_router(health_router) api.include_router(installation_router) api.include_router(vms_router) + api.include_router(diagnostics_router) + + static_dir = Path(__file__).parent / "dashboard" / "static" + api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static") + api.include_router(dashboard_router, prefix="/dashboard") + + @api.get("/") + async def root() -> Any: + return RedirectResponse(url="/dashboard") + return api -- 2.54.0 From 781ee753ec2d61e9b1c9ee4607cfec0ec75a2210 Mon Sep 17 00:00:00 2001 From: kstyagi Date: Sun, 20 Sep 2026 09:08:45 +0530 Subject: [PATCH 5/5] Refactor code structure for improved readability and maintainability --- README.md | 10 + image.png | Bin 369109 -> 0 bytes pyproject.toml | 2 +- tests/test_server.py | 4 +- uvm/dashboard/static/css/dashboard.css | 1381 ++++++++---- uvm/dashboard/static/fonts/OFL.txt | 92 + .../static/fonts/manrope-latin.woff2 | Bin 0 -> 24836 bytes uvm/dashboard/static/index.html | 552 ++--- uvm/dashboard/static/js/dashboard.js | 1961 +++++++++++------ 9 files changed, 2502 insertions(+), 1500 deletions(-) delete mode 100644 image.png create mode 100644 uvm/dashboard/static/fonts/OFL.txt create mode 100644 uvm/dashboard/static/fonts/manrope-latin.woff2 diff --git a/README.md b/README.md index b1d900a..506024e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,16 @@ OpenAPI documentation is available at `/docs` once the server is running. The root URL redirects to the local dashboard at `/dashboard`; enter the API token there for the current browser session. +The dashboard includes a fleet overview, searchable machine collection, private +network and port-policy view, host diagnostics, and machine provisioning. Open a +machine to manage its lifecycle, edit or sync its port rules, copy its SSH command, +or read recent logs. Select machines in the collection for bulk operations. +Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every +five seconds while the tab is visible; host checks refresh every thirty seconds +and can also be run manually. Charts show observed machine states and allocated +resources, rather than guest CPU or memory utilization. All frontend assets, +including the font, are served locally; no frontend build step is required. + The server exposes these routes: | Method | Route | Purpose | diff --git a/image.png b/image.png deleted file mode 100644 index 7c959a5d147dae30c9a0e03b2647d5704cca8639..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 369109 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 z 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]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 zⓈ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≫ZRy#gB(elCynBIlO>=JNj|65WC}z`J=!e>eGz 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⁡QVW8@_~bcZLY`W$&aArqeyO) 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 z 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 diff --git a/pyproject.toml b/pyproject.toml index 4de4114..d20f6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,4 +23,4 @@ uvm = "uvm.cli:main" packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"] [tool.setuptools.package-data] -"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js"] +"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"] diff --git a/tests/test_server.py b/tests/test_server.py index 34e9692..5671c0e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -304,9 +304,9 @@ class ApiRouterTests(unittest.TestCase): self.assertEqual(root.status_code, 307) self.assertEqual(root.headers["location"], "/dashboard") self.assertEqual(dashboard.status_code, 200) - self.assertIn("UVM Dashboard", dashboard.text) + self.assertIn("UVM Control", dashboard.text) self.assertEqual(stylesheet.status_code, 200) - self.assertIn("--bg-body", stylesheet.text) + self.assertIn("--acid", stylesheet.text) self.assertEqual(script.status_code, 200) self.assertIn("sessionStorage", script.text) diff --git a/uvm/dashboard/static/css/dashboard.css b/uvm/dashboard/static/css/dashboard.css index fd4052c..a8986f0 100644 --- a/uvm/dashboard/static/css/dashboard.css +++ b/uvm/dashboard/static/css/dashboard.css @@ -1,470 +1,943 @@ -/* ─── Design Tokens ─── */ +@font-face { + font-family: Manrope; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url('../fonts/manrope-latin.woff2') format('woff2'); +} + :root { - --bg-body: #0b0b0f; - --bg-sidebar: #0f0f14; - --bg-card: #141419; - --bg-card-hover: #1a1a22; - --bg-input: #0e0e12; - --bg-elevated: #1c1c24; - - --border-color: #1f1f28; - --border-hover: #2a2a36; - --divider: #1a1a22; - - --text-primary: #f0f0f5; - --text-secondary: #8a8a98; - --text-muted: #5a5a68; - - --accent: #6366f1; - --accent-hover: #818cf8; - --accent-soft: rgba(99, 102, 241, 0.12); - - --green: #22c55e; - --green-soft: rgba(34, 197, 94, 0.12); - --red: #ef4444; - --red-soft: rgba(239, 68, 68, 0.12); - --amber: #f59e0b; - --amber-soft: rgba(245, 158, 11, 0.12); - --blue: #3b82f6; - --blue-soft: rgba(59, 130, 246, 0.12); - --purple: #a855f7; - --purple-soft: rgba(168, 85, 247, 0.12); - - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --radius-xl: 20px; - - --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); - --shadow-md: 0 4px 12px rgba(0,0,0,0.5); - --shadow-lg: 0 8px 24px rgba(0,0,0,0.6); - - --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; - --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --canvas: #e8e7ed; + --canvas-deep: #dddae4; + --paper: #faf9fc; + --white: #fff; + --ink: #35333e; + --ink-soft: #55515f; + --muted: #78717f; + --faint: #a29cae; + --line: rgba(110, 99, 135, .11); + --acid: #ede39f; + --yellow: #e6d783; + --yellow-soft: #f4efce; + --yellow-ink: #746633; + --peach: #ddb5a9; + --peach-soft: #f3e5e0; + --sage: #a9b9a1; + --sage-soft: #e9eee6; + --lilac: #b9aed0; + --lilac-soft: #eae4f2; + --red: #bd766e; + --red-soft: #f3e4e1; + --shadow-card: 0 5px 14px rgba(66, 52, 92, .035), 0 18px 34px -23px rgba(66, 52, 92, .17); + --radius: 21px; + --sans: Manrope, 'Avenir Next', 'Segoe UI', sans-serif; + --mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + --ease: cubic-bezier(.22, .68, 0, 1.01); } -/* ─── Reset ─── */ *, *::before, *::after { box-sizing: border-box; } -html, body { height: 100%; } +html { min-height: 100%; background: #dad8e0; } body { margin: 0; - font-family: var(--font-sans); - background: var(--bg-body); - color: var(--text-primary); - line-height: 1.5; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ─── Layout ─── */ -#app { min-height: 100vh; } -.screen { display: none; min-height: 100vh; } -.screen.active { display: flex; } - -#main-app { flex-direction: row; } - -.sidebar { - width: 260px; - background: var(--bg-sidebar); - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - padding: 24px 16px; - position: fixed; - inset: 0 auto 0 0; - z-index: 50; -} - -.sidebar-brand { margin-bottom: 32px; padding: 0 8px; } -.sidebar-brand .logo { - display: flex; - align-items: center; - gap: 12px; - font-weight: 700; - font-size: 18px; - letter-spacing: -0.02em; - color: var(--text-primary); -} -.sidebar-brand .logo svg { color: var(--text-primary); } - -.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; } -.nav-item { - display: flex; align-items: center; gap: 12px; - padding: 10px 12px; - border-radius: var(--radius-md); - color: var(--text-secondary); - text-decoration: none; - font-size: 14px; font-weight: 500; - transition: background var(--transition), color var(--transition); -} -.nav-item:hover { background: var(--bg-card-hover); color: var(--text-primary); } -.nav-item.active { background: var(--accent-soft); color: var(--accent); } -.nav-item svg { opacity: 0.8; } - -.sidebar-footer { - margin-top: auto; - padding-top: 16px; - border-top: 1px solid var(--divider); - display: flex; - flex-direction: column; - gap: 12px; -} -.connection-status { - display: flex; align-items: center; gap: 8px; - font-size: 12px; color: var(--text-muted); -} -.status-dot { - width: 8px; height: 8px; border-radius: 50%; - background: var(--green); - box-shadow: 0 0 6px var(--green); -} -.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); } - -.main { - flex: 1; - margin-left: 260px; - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.topbar { - position: sticky; top: 0; z-index: 40; - background: rgba(11,11,15,0.8); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border-color); - padding: 16px 32px; - display: flex; align-items: center; justify-content: space-between; -} -.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; } -.topbar-right { display: flex; align-items: center; gap: 10px; } - -.content { padding: 28px 32px; flex: 1; } - -/* ─── Pages ─── */ -.page { display: none; animation: fadeIn 220ms ease; } -.page.active { display: block; } -@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } - -/* ─── Cards ─── */ -.card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 24px; - transition: border-color var(--transition), box-shadow var(--transition); -} -.card:hover { border-color: var(--border-hover); } -.card-title { margin: 0 0 16px; font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } -.card-heading { margin: 0 0 20px; font-size: 18px; font-weight: 600; } -.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } -.card-title-row .card-title { margin: 0; } - -/* ─── Stats ─── */ -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 20px; - margin-bottom: 28px; -} -.stats-grid.two { grid-template-columns: repeat(2, 1fr); } - -.stat-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 20px; -} -.stat-header { - display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; -} -.stat-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } -.stat-icon { - width: 36px; height: 36px; border-radius: 10px; - display: flex; align-items: center; justify-content: center; -} -.icon-blue { background: var(--blue-soft); color: var(--blue); } -.icon-green { background: var(--green-soft); color: var(--green); } -.icon-amber { background: var(--amber-soft); color: var(--amber); } -.icon-purple { background: var(--purple-soft); color: var(--purple); } -.stat-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; } -.stat-meta { font-size: 12px; color: var(--text-muted); margin-top: 8px; } -.text-green { color: var(--green); } -.text-amber { color: var(--amber); } -.text-red { color: var(--red); } - -/* ─── Buttons ─── */ -.btn { - display: inline-flex; align-items: center; justify-content: center; gap: 8px; - border: 1px solid transparent; border-radius: var(--radius-md); - padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit; - cursor: pointer; transition: background var(--transition), border-color var(--transition), transform 80ms ease; - background: transparent; color: var(--text-primary); -} -.btn:active { transform: scale(0.98); } -.btn-primary { background: var(--text-primary); color: var(--bg-body); border-color: var(--text-primary); } -.btn-primary:hover { background: #e2e2e8; border-color: #e2e2e8; } -.btn-ghost { border-color: var(--border-color); color: var(--text-secondary); } -.btn-ghost:hover { background: var(--bg-card-hover); color: var(--text-primary); } -.btn-danger { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.25); } -.btn-danger:hover { background: rgba(239,68,68,0.2); } -.btn-warning { background: var(--amber-soft); color: var(--amber); border-color: rgba(245,158,11,0.25); } -.btn-warning:hover { background: rgba(245,158,11,0.2); } -.btn-sm { padding: 6px 10px; font-size: 12px; } -.btn-lg { padding: 10px 18px; font-size: 14px; } -.btn-icon { padding: 8px; } - -/* ─── Tables ─── */ -.table-wrap { overflow-x: auto; } -.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } -.data-table thead th { - text-align: left; padding: 12px 16px; - color: var(--text-secondary); font-weight: 500; font-size: 12px; - border-bottom: 1px solid var(--divider); - white-space: nowrap; -} -.data-table tbody td { padding: 14px 16px; border-bottom: 1px solid var(--divider); vertical-align: middle; } -.data-table tbody tr { transition: background var(--transition); } -.data-table tbody tr:hover { background: var(--bg-card-hover); } -.data-table .actions { text-align: right; white-space: nowrap; } -.data-table .selection { width: 36px; padding-right: 0; text-align: center; } -.data-table input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } - -.table-empty { display: none; padding: 40px 0; } -.table-empty.visible { display: block; } - -/* ─── Badges ─── */ -.badge { - display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; -} -.badge-running { background: var(--green-soft); color: var(--green); } -.badge-stopped { background: var(--red-soft); color: var(--red); } -.badge-unknown { background: var(--bg-elevated); color: var(--text-muted); } -.status-badge { - display: inline-flex; align-items: center; gap: 6px; - padding: 6px 12px; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; -} -.badge-loading { background: var(--bg-elevated); color: var(--text-muted); } -.badge-ok { background: var(--green-soft); color: var(--green); } -.badge-error { background: var(--red-soft); color: var(--red); } - -/* ─── VM Grid / List ─── */ -.vm-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 16px; -} -.vm-card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 18px; - cursor: pointer; - transition: border-color var(--transition), transform var(--transition); -} -.vm-card:hover { border-color: var(--border-hover); transform: translateY(-2px); } -.vm-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } -.vm-card-name { font-weight: 600; font-size: 14px; } -.vm-card-ip { font-size: 12px; color: var(--text-muted); margin-top: 2px; } -.vm-card-meta { display: flex; gap: 12px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); } -.vm-card-meta span { display: flex; align-items: center; gap: 4px; } - -.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } -.section-header h3 { margin: 0; font-size: 16px; font-weight: 600; } -.link { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 500; } -.link:hover { text-decoration: underline; } - -/* ─── Toolbar ─── */ -.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } -.search-box { - display: flex; align-items: center; gap: 10px; - background: var(--bg-input); border: 1px solid var(--border-color); - border-radius: var(--radius-md); padding: 0 12px; flex: 1; max-width: 360px; -} -.search-box input { - background: transparent; border: none; outline: none; color: var(--text-primary); - font: inherit; font-size: 13px; padding: 9px 0; width: 100%; -} -.search-box svg { color: var(--text-muted); flex-shrink: 0; } -.select { - background: var(--bg-input); border: 1px solid var(--border-color); - border-radius: var(--radius-md); color: var(--text-primary); - font: inherit; font-size: 13px; padding: 8px 28px 8px 12px; - appearance: none; cursor: pointer; - background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a98' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; background-position: right 10px center; -} -.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } -.bulk-controls { display: none; align-items: center; gap: 8px; } -.bulk-controls.visible { display: flex; } -.bulk-controls span { color: var(--text-secondary); font-size: 12px; white-space: nowrap; } - -/* ─── Forms ─── */ -.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -.field { display: flex; flex-direction: column; gap: 6px; } -.field.full { grid-column: 1 / -1; } -.field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.03em; } -.field input, .field select { - background: var(--bg-input); border: 1px solid var(--border-color); - border-radius: var(--radius-md); color: var(--text-primary); - font: inherit; font-size: 13px; padding: 10px 12px; - transition: border-color var(--transition); -} -.field input:focus, .field select:focus { outline: none; border-color: var(--accent); } -.hint-inline { color: var(--text-muted); font-weight: 400; text-transform: none; letter-spacing: 0; } -.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 24px; } -.checkbox-field { flex-direction: row; align-items: center; gap: 10px; } -.checkbox { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-secondary); } -.checkbox input { position: absolute; opacity: 0; width: 0; height: 0; } -.checkmark { - width: 18px; height: 18px; border-radius: 5px; - border: 1px solid var(--border-hover); background: var(--bg-input); - display: flex; align-items: center; justify-content: center; - transition: background var(--transition), border-color var(--transition); -} -.checkbox input:checked + .checkmark { background: var(--accent); border-color: var(--accent); } -.checkmark::after { - content: ''; width: 5px; height: 9px; border: solid white; - border-width: 0 2px 2px 0; transform: rotate(45deg); - opacity: 0; transition: opacity var(--transition); -} -.checkbox input:checked + .checkmark::after { opacity: 1; } - -/* ─── Auth Screen ─── */ -#auth-screen { align-items: center; justify-content: center; padding: 24px; } -.auth-card { - width: 100%; max-width: 420px; - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-xl); - padding: 36px; - box-shadow: var(--shadow-lg); -} -.auth-header { text-align: center; margin-bottom: 28px; } -.auth-header h1 { margin: 16px 0 6px; font-size: 22px; font-weight: 700; } -.auth-header p { margin: 0; color: var(--text-secondary); font-size: 14px; } -.auth-form { display: flex; flex-direction: column; gap: 16px; } -.error-text { color: var(--red); font-size: 12px; margin: 0; min-height: 16px; } -.error-block { color: var(--red); font-size: 12px; background: var(--red-soft); padding: 12px; border-radius: var(--radius-md); border: 1px solid rgba(239,68,68,0.2); margin-top: 12px; white-space: pre-wrap; } -.error-block.hidden { display: none; } - -/* ─── VM Detail ─── */ -.vm-detail-header { margin-bottom: 20px; } -.back-btn { margin-bottom: 12px; } -.vm-detail-title { display: flex; align-items: center; gap: 12px; } -.vm-detail-title h2 { margin: 0; font-size: 22px; font-weight: 700; } -.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } -.detail-card.wide { grid-column: 1 / -1; } -.detail-rows { display: flex; flex-direction: column; gap: 12px; } -.detail-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--divider); } -.detail-row:last-child { border-bottom: none; } -.detail-key { color: var(--text-secondary); } -.detail-value { color: var(--text-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } -.port-list { display: flex; flex-wrap: wrap; gap: 8px; } -.port-chip { - display: inline-flex; align-items: center; gap: 6px; - padding: 5px 10px; border-radius: var(--radius-sm); - background: var(--bg-elevated); border: 1px solid var(--border-color); - font-size: 12px; font-weight: 500; -} -.port-editor { display: none; margin-top: 12px; } -.port-editor-input { - width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); - border-radius: var(--radius-md); background: var(--bg-input); color: var(--text-primary); - font: inherit; font-size: 13px; -} -.port-editor-input:focus { outline: none; border-color: var(--accent); } -.port-editor-actions, .port-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } -.action-bar { display: flex; gap: 10px; flex-wrap: wrap; } -.hint { font-size: 12px; color: var(--text-muted); margin-top: 10px; } -.muted { color: var(--text-muted); } -.ssh-command { - display: block; margin-bottom: 12px; padding: 10px; - border: 1px solid var(--border-color); border-radius: var(--radius-md); - background: var(--bg-input); color: #c4c4d2; overflow-wrap: anywhere; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--ink); + background: radial-gradient(ellipse at 20% 0, #eeedf1, transparent 65%), #dad8e0; + font-family: var(--sans); font-size: 12px; + font-weight: 450; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +button, input, select, textarea { font: inherit; } +button, a, input, select { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button, input, select { color: inherit; } +button { border: 0; } +button:disabled { cursor: wait; opacity: .55; } +a { color: inherit; text-decoration: none; } +h1, h2, h3, p { margin: 0; } +h1, h2, h3, strong { font-weight: 550; } +h1, h2, h3 { line-height: 1.3; } +h1:focus { outline: none; } +fieldset { min-width: 0; } +input { min-width: 0; } +input::placeholder { color: #918a9e; } +input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; cursor: pointer; } +::selection { background: #ece1aa; color: var(--ink); } +:focus-visible { outline: 2px solid #a79967; outline-offset: 4px; } +[hidden] { display: none !important; } +.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; } +.icon { display: block; width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.muted { color: var(--muted); } +.mono { font-family: var(--mono); font-size: 10px; } +.danger-text { color: #a45f57 !important; } +.subtle-label { display: block; color: var(--muted); font-size: 10px; font-weight: 450; letter-spacing: .015em; } +.dot { display: inline-block; width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--faint); } +.dot-yellow { background: var(--yellow); box-shadow: 0 0 0 3px rgba(230, 215, 131, .11); } +.dot-peach { background: var(--peach); } +.dot-sage { background: var(--sage); } +.dot-lilac { background: var(--lilac); } +.dot-error { background: var(--red); } +.status-inline { display: inline-flex; align-items: center; gap: 7px; } +.button, .icon-button, .quiet-link, .mini-link { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } +.button { + min-height: 37px; + padding: 8px 17px; + border: 1px solid rgba(255, 255, 255, .8); + border-radius: 30px; + background: rgba(255, 255, 255, .57); + color: var(--ink-soft); + font-size: 11px; + font-weight: 550; + line-height: 1.4; + white-space: nowrap; + transition: background .2s, box-shadow .2s, transform .25s var(--ease); +} +.button:hover:not(:disabled) { transform: translateY(-1px); background: #fff; box-shadow: 0 4px 12px #5145660b; } +.button:active:not(:disabled) { transform: translateY(0); } +.button .icon { width: 15px; height: 15px; } +.button-primary { background: var(--acid); border-color: #f6efc5; color: #554d30; box-shadow: 0 5px 13px #9784460a; } +.button-primary:hover:not(:disabled) { background: #e9dc91; box-shadow: 0 6px 16px #97844616; } +.button-danger { background: var(--red-soft); color: #96544d; border-color: #e8cfc9; } +.button-danger:hover:not(:disabled) { background: #edd3cd; } +.button-quiet { background: transparent; border-color: transparent; color: var(--muted); box-shadow: none; } +.button-small { min-height: 31px; font-size: 10px; padding: 6px 12px; } +.button-wide { width: 100%; justify-content: space-between; padding-inline: 20px; } +.button-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.icon-button { width: 33px; height: 33px; padding: 0; border: 1px solid transparent; border-radius: 50%; background: transparent; color: var(--muted); transition: background .2s, color .2s, transform .2s; } +.icon-button:hover:not(:disabled) { background: #ffffffa8; color: var(--ink); } +.icon-button .icon { width: 16px; height: 16px; } +.quiet-link { justify-content: flex-start; padding: 0; background: transparent; color: var(--muted); font-size: 10px; font-weight: 450; transition: color .2s; } +.quiet-link:hover { color: var(--ink); } +.quiet-link .icon { width: 15px; height: 15px; } +.mini-link { color: var(--muted); padding: 3px; border-radius: 50%; } +.mini-link:hover { color: var(--ink); background: #fff8; } +.mini-link .icon { width: 13px; height: 13px; } +.round-icon { display: inline-flex; align-items: center; justify-content: center; width: 39px; height: 39px; flex: 0 0 auto; background: #f7f5fa; border: 1px solid #fff9; border-radius: 14px; color: var(--muted); } +.tag { display: inline-flex; align-items: center; gap: 6px; font-size: 10px; line-height: 1.4; padding: 6px 10px; border-radius: 20px; background: #ffffff67; color: var(--muted); white-space: nowrap; } +.tag .icon { width: 13px; height: 13px; } +.badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; background: #ffffff69; border-radius: 20px; font-size: 10px; line-height: 1.4; font-weight: 450; color: var(--ink-soft); white-space: nowrap; } +.badge::before { content: ''; width: 5px; height: 5px; flex: 0 0 auto; background: var(--faint); border-radius: 50%; } +.badge-running::before { background: var(--yellow); } +.badge-stopped::before { background: var(--peach); } +.badge-good::before { background: #a1b292; } +.badge-attention::before { background: var(--red); } +.badge-attention { color: #a26259; background: #f5e9e57a; } +.badge-pending::before { background: var(--lilac); } +.port-chip { display: inline-flex; align-items: center; gap: 5px; border-radius: 7px; padding: 4px 7px; background: #f4f1f7; color: #756d81; font-size: 9px; white-space: nowrap; } +.port-chip small { color: #918699; font-size: 8px; } +.port-list { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; min-height: 34px; color: var(--muted); font-size: 11px; } +.port-list .port-chip { padding: 7px 11px; font-size: 11px; background: #eee9f3; } +.port-list .port-chip small { font-size: 9px; } + +/* A quiet, two-part navigation panel, lifted over the workspace. */ +.app-shell { + display: grid; + grid-template-columns: 402px minmax(0, 1fr); + max-width: 1840px; + height: calc(100dvh - 48px); + min-height: 680px; + margin: 24px auto; + width: calc(100% - 48px); + overflow: hidden; + border: 1px solid #ffffffac; + border-radius: 25px; + background: linear-gradient(135deg, #ecebf1 0, #e6e4ec 100%); + box-shadow: 0 24px 70px -35px #493e6640, 0 2px 7px #61546d06; +} +.workspace-drawer { + display: grid; + grid-template-columns: minmax(0, 1fr) 172px; + gap: 16px; + padding: 25px 17px 24px 20px; + overflow-y: auto; + overscroll-behavior: contain; + background: linear-gradient(125deg, #f1f0f5dd, #f0eef4ba); + border-radius: 24px 23px 28px 24px; + border-right: 1px solid #ffffff4d; + box-shadow: 7px 0 27px -18px #71628422; + z-index: 2; + scrollbar-width: none; +} +.sidebar { display: flex; flex-direction: column; min-width: 0; } +.brand { display: inline-flex; align-items: center; gap: 8px; width: fit-content; color: #4d4857; line-height: 1; } +.brand > span:nth-child(2) { font-size: 27px; font-weight: 650; letter-spacing: -1.8px; } +.brand-period { color: #b3a166; } +.brand-tag { align-self: end; margin: 0 0 3px 2px; color: #898291; font-size: 8px; letter-spacing: .025em; } +.brand-flower { display: block; position: relative; width: 20px; height: 20px; flex: 0 0 auto; transform: rotate(-12deg); } +.brand-flower i { position: absolute; width: 8px; height: 8px; border-radius: 2px 6px 2px 6px; background: var(--yellow); } +.brand-flower i:nth-child(1) { left: 1px; top: 1px; } +.brand-flower i:nth-child(2) { right: 1px; top: 1px; transform: rotate(90deg); background: #d9d3df; } +.brand-flower i:nth-child(3) { left: 1px; bottom: 1px; transform: rotate(90deg); background: #b6adbf; } +.brand-flower i:nth-child(4) { right: 1px; bottom: 1px; } +.sidebar-heading { display: flex; gap: 10px; margin: 33px 0 22px; } +.sidebar-heading-mark { margin-top: 2px; color: #827b8e; font-size: 17px; } +.sidebar-heading h2 { font-size: 20px; font-weight: 450; letter-spacing: -.055em; line-height: 1.4; } +.sidebar-heading h2 span { display: block; } +.sidebar-search { display: flex; align-items: center; gap: 9px; padding: 0 4px; color: var(--muted); background: transparent; text-align: left; font-size: 10px; } +.sidebar-search .icon { width: 15px; height: 15px; } +.sidebar-search kbd { margin-left: auto; font-family: var(--sans); font-size: 10px; color: var(--faint); } +.primary-nav { display: grid; gap: 5px; margin-top: 35px; } +.nav-item { display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 8px 10px; color: var(--muted); border-radius: 25px; font-size: 11px; font-weight: 450; transition: background .25s, box-shadow .25s, color .25s; } +.nav-item > .icon { width: 15px; height: 15px; } +.nav-item:hover { background: #ffffff5e; color: var(--ink); } +.nav-item.active { background: #ffffffed; box-shadow: 0 5px 14px #62537608; color: var(--ink); } +.nav-count { margin-left: auto; color: var(--muted); font-size: 9px; } +.nav-indicator { display: none; width: 4px; height: 4px; margin-left: auto; background: var(--lilac); border-radius: 50%; } +.nav-item.active .nav-indicator { display: block; } +.nav-health-dot { width: 5px; height: 5px; margin-left: auto; border-radius: 50%; } +.nav-item .nav-end-icon { width: 12px; height: 12px; margin-left: auto; color: var(--faint); } +.nav-create { margin-top: 9px; } +.sidebar-secondary { margin: 20px 7px 0; border-top: 1px solid var(--line); padding-top: 17px; } +.sidebar-secondary .nav-item { padding-inline: 3px; font-size: 10px; } +.sidebar-bottom { margin-top: auto; padding: 50px 5px 0; } +.workspace-identity { display: flex; align-items: center; gap: 9px; padding-top: 18px; border-top: 1px solid var(--line); } +.workspace-avatar { display: grid; place-items: center; width: 29px; height: 32px; flex: 0 0 auto; border: 1px solid #fff; border-radius: 10px; background: #e5dfed; color: #867b92; font-size: 18px; font-weight: 500; } +.workspace-identity > div { display: grid; gap: 4px; } +.workspace-identity strong { font-size: 9px; font-weight: 550; } +.workspace-identity > div > span { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 8px; line-height: 1.2; } +.workspace-identity .dot { width: 4px; height: 4px; } +.sidebar-bottom > .quiet-link { font-size: 9px; margin-top: 22px; } +.sidebar-bottom > .quiet-link .icon { width: 13px; height: 13px; } +.context-rail { display: flex; flex-direction: column; gap: 17px; min-width: 0; padding-top: 53px; } +.insight-card { position: relative; display: block; min-height: 161px; padding: 15px 14px 11px; border: 1px solid #ffffffd9; border-radius: 19px; background: linear-gradient(140deg, #fff, #fbfafc); box-shadow: var(--shadow-card); transition: transform .3s var(--ease), box-shadow .3s; } +.insight-card::before { content: ''; position: absolute; top: -3px; left: 27px; right: 27px; height: 6px; border-radius: 20px; background: #e7cfdc; filter: blur(3px); z-index: -1; } +.insight-card .card-caption { gap: 4px; font-size: 9px; } +.insight-card .card-caption .icon { width: 12px; height: 12px; color: #9b8caa; } +.insight-card p { color: var(--muted); font-size: 10px; line-height: 1.55; min-height: 42px; margin-top: 9px; } +.insight-card p strong { color: #59505f; font-weight: 650; } +.insight-matrix { display: grid; place-items: center; height: 35px; margin-top: 5px; background-image: radial-gradient(#b9adb93b .75px, transparent .9px); background-size: 4px 4px; mask-image: radial-gradient(ellipse, #000 15%, transparent 70%); } +.insight-matrix span { display: block; width: 21px; height: 21px; background: repeating-conic-gradient(#92837b 0 25%, transparent 0 50%) 50% / 4px 4px; mask-image: radial-gradient(ellipse at center 75%, #000, transparent 70%); } +.insight-footer { display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 7px; color: #99919f; } +.insight-footer .dot { width: 4px; height: 4px; } +.collection { display: block; position: relative; border-radius: 18px; transition: transform .3s var(--ease); } +.collection:hover, .insight-card:hover { transform: translateY(-3px); } +.collection-label { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 65px; padding: 13px 14px; border: 1px solid #ffffffe6; border-radius: 18px; background: linear-gradient(130deg, #fffffff7, #fcfbfee6); box-shadow: var(--shadow-card); z-index: 1; } +.collection-label > div { display: grid; gap: 3px; } +.collection-label strong { font-size: 11px; font-weight: 550; } +.collection-label span { color: var(--muted); font-size: 9px; } +.collection-label > .icon { width: 13px; height: 13px; color: #8f849f; } +.collection-art { position: relative; overflow: hidden; height: 64px; margin: 0 3px -11px; } +.paper-machine { position: absolute; width: 100px; height: 91px; padding: 14px; border: 1px solid #fff; border-radius: 10px; background: linear-gradient(150deg, #fff, #eae8ed); box-shadow: -3px 1px 12px #6f5d8810; } +.back-paper { left: 11px; top: 18px; transform: rotate(-14deg); } +.front-paper { right: 6px; top: 5px; transform: rotate(9deg); } +.paper-machine i { display: block; height: 2px; margin-top: 5px; border-radius: 3px; background: #dcd8e1; } +.paper-machine i:nth-last-child(2) { width: 60%; } +.paper-machine > span { display: block; width: 11px; height: 3px; background: #e7dca5; border-radius: 3px; margin-top: 5px; } +.paper-machine .icon { width: 18px; height: 18px; color: #b5aaba; margin-bottom: 6px; } +.collection-machines::after { content: ''; position: absolute; bottom: -2px; left: 21px; right: 21px; height: 13px; background: #ebe5c9; border-radius: 50%; filter: blur(10px); } +.network-art { height: 62px; margin-bottom: -12px; } +.network-paper { position: absolute; left: 0; right: 0; top: 7px; height: 79px; padding: 12px; border: 1px solid #eeeeef; border-radius: 16px; background: #e7e4ebaa; box-shadow: inset 0 7px 9px #fff6; } +.network-paper::before { content: ''; position: absolute; left: 10px; right: 10px; top: 9px; height: 67px; background: #f3f0f7; border-radius: 11px; box-shadow: 0 0 0 1px #fff7; } +.network-paper .icon { position: absolute; width: 16px; height: 16px; left: 18px; top: 19px; color: #b8acbe; } +.network-paper i { position: absolute; top: 33px; width: 5px; height: 5px; border-radius: 50%; background: #c9bece; } +.network-paper i:nth-of-type(1) { left: 50px; } +.network-paper i:nth-of-type(2) { left: 88px; top: 19px; background: #e3d694; } +.network-paper i:nth-of-type(3) { right: 13px; } +.network-line { position: absolute; left: 52px; right: 16px; top: 20px; height: 17px; border-top: 1px solid #d9cedf; border-left: 1px solid #d9cedf; border-right: 1px solid #d9cedf; border-radius: 5px; z-index: 1; } +.collection-network::after { content: ''; position: absolute; left: 14px; right: 14px; bottom: -3px; height: 10px; border-radius: 20px; background: #e4cad6; filter: blur(5px); } +.collection-new .collection-label { background: #faf9fc9c; border-color: #ffffff96; box-shadow: 0 5px 15px #62537603; } +.rail-footnote { margin-top: auto; padding: 7px 14px 0; color: #a098ac; font-size: 8px; line-height: 1.65; } + +/* The canvas stays light; grouping comes from tone and space. */ +.workspace { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.topbar { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 0 27px; } +.workspace-crumb { display: inline-flex; align-items: center; gap: 8px; min-height: 29px; padding: 5px 13px; border-radius: 25px; background: #ffffffab; box-shadow: 0 3px 12px #67577d03; font-size: 10px; white-space: nowrap; } +.workspace-crumb .dot { width: 5px; height: 5px; } +.crumb-divider { color: #c0b9c9; padding: 0 1px; } +#breadcrumb { color: #8e8499; } +.refresh-button { width: 28px; height: 28px; } +.refresh-button .icon { width: 13px; height: 13px; } +.refresh-button.is-loading .icon { animation: spin 1.3s linear infinite; } +.global-search { position: relative; display: flex; align-items: center; gap: 8px; max-width: 260px; min-width: 135px; width: 100%; margin-left: auto; padding: 5px 2px; } +.global-search > .icon { width: 14px; height: 14px; color: #62596f; } +.global-search input { width: 100%; outline: none; border: 0; background: transparent; font-size: 10px; padding: 4px 0; } +.global-search:focus-within { border-bottom: 1px solid #c7bdd3; } +.global-search kbd { white-space: nowrap; color: #a499b1; font-family: var(--sans); font-size: 9px; } +.topbar-create { width: 29px; height: 29px; border: 1px solid #fff9; background: #ffffff60; margin-left: 6px; } +.mobile-menu { display: none; } +.page-content { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 12px 27px 25px; scrollbar-gutter: stable; } +.page { animation: page-appear .35s var(--ease) both; } +.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 23px; min-height: 52px; } +.page-heading h1 { font-size: 27px; font-weight: 450; letter-spacing: -.055em; margin-top: 5px; overflow-wrap: anywhere; } +.heading-period { color: #aa9aac; } +.page-heading .subtle-label { font-size: 9px; } +.date-label { font-size: 9px; color: #898090; align-self: end; padding-bottom: 4px; } +.overview-metrics { display: grid; grid-template-columns: .98fr 1.02fr 1fr; gap: 13px; } +.metric-card { min-width: 0; min-height: 153px; padding: 16px 18px; border: 1px solid #ffffff73; border-radius: var(--radius); background: linear-gradient(120deg, #f9f8fba8, #f6f4f987); box-shadow: 0 5px 15px #55456602; } +.card-caption { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.card-caption h2 { font-size: 12px; font-weight: 500; letter-spacing: -.025em; } +.caption-icon { width: 15px; height: 15px; color: #a59bb0; } +.fleet-number { display: flex; align-items: center; gap: 16px; margin: 12px 0 13px; } +.fleet-number > strong { font-size: 39px; font-weight: 350; letter-spacing: -.07em; line-height: 1.1; } +.fleet-number > span { color: var(--muted); font-size: 9px; line-height: 1.55; } +.metric-bottom { display: flex; justify-content: space-between; gap: 8px; font-size: 9px; } +.metric-bottom .dot { width: 5px; height: 5px; } +.signal-readouts { display: flex; justify-content: space-between; gap: 10px; margin-top: 16px; } +.signal-readouts > div { display: grid; align-items: center; grid-template-columns: 5px 1fr; gap: 2px 6px; } +.signal-readouts .dot { width: 4px; height: 4px; } +.signal-readouts strong { font-size: 16px; font-weight: 450; line-height: 1.4; } +.signal-readouts small { grid-column: 2; color: var(--muted); font-size: 9px; } +.segmented-track { display: flex; height: 3px; margin-top: 18px; border-radius: 5px; background: #e2dce9; overflow: hidden; } +.segmented-track span { width: 0; transition: width .7s var(--ease); } +.segmented-track span:first-child { background: linear-gradient(90deg, #b3c0a9, #d4dba6); } +.segmented-track span:last-child { background: linear-gradient(90deg, #e0c4ae, #dbaa9f); } +.range-labels { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; color: #94899f; font-size: 7px; } +.resource-line { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; margin-top: 19px; } +.resource-line > span { font-size: 12px; letter-spacing: -.02em; } +.resource-line > small { color: var(--muted); font-size: 8px; text-align: right; } +.resource-track { height: 3px; border-radius: 4px; background: #e5dfca8a; margin-top: 6px; overflow: hidden; } +.resource-track > span { display: block; width: 0; height: 100%; border-radius: inherit; background: #e6d88e; box-shadow: 0 0 5px #e7d89555; transition: width .7s var(--ease); } +.second-resource { margin-top: 15px; } +.memory-track { background: #e6dfeb; } +.memory-track > span { background: #beb0d1; } +.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; } +.section-heading h2 { font-size: 14px; font-weight: 500; letter-spacing: -.03em; } +.section-heading > div > span { display: block; color: var(--muted); font-size: 10px; margin-top: 4px; } +.portfolio-section { margin-top: 26px; } +.portfolio-section > .section-heading { margin: 0 3px 12px; } +.portfolio-content { min-height: 184px; } +.table-scroll { overflow-x: auto; scrollbar-width: thin; } +.portfolio-table { width: 100%; min-width: 565px; border-collapse: separate; border-spacing: 0 6px; text-align: left; } +.portfolio-table th { color: var(--muted); font-size: 10px; font-weight: 450; padding: 0 10px 5px; white-space: nowrap; } +.portfolio-table td { padding: 12px 10px; color: var(--ink-soft); font-size: 11px; transition: background .2s; } +.portfolio-table th:first-child, .portfolio-table td:first-child { padding-left: 14px; } +.portfolio-table tbody tr { background: #faf8fc22; } +.portfolio-table tbody tr:nth-child(2n) { background: #ffffff7d; } +.portfolio-table tbody tr:hover { background: #ffffffbf; } +.portfolio-table td:first-child { border-radius: 13px 0 0 13px; } +.portfolio-table td:last-child { border-radius: 0 13px 13px 0; width: 25px; padding-inline: 6px; } +.portfolio-table .badge { font-size: 9px; padding: 4px 7px; } +.portfolio-table .mini-link .icon { width: 12px; height: 12px; } +.portfolio-name { display: flex; align-items: center; gap: 9px; color: var(--ink); font-size: 11px; } +.portfolio-name > .icon { width: 13px; height: 13px; color: #b4a6bd; } +.portfolio-name a:hover { text-decoration: underline; text-underline-offset: 3px; } +.resource-inline { white-space: nowrap; font-size: 10px; } +.resource-inline span { color: #aca0b6; padding: 0 4px; } +.soft-panel { min-width: 0; padding: 21px; border: 1px solid #ffffff69; border-radius: var(--radius); background: linear-gradient(120deg, #f7f5fa8c, #f3f0f775); } +.overview-bottom { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; margin-top: 22px; } +.activity-panel { padding: 19px 20px 13px; } +.activity-panel .section-heading { margin-bottom: 8px; } +.activity-body { display: flex; align-items: center; justify-content: center; gap: 34px; } +.fleet-ring { position: relative; width: 208px; max-width: 56%; flex: 0 0 auto; aspect-ratio: 1; } +.fleet-ring > svg { display: block; width: 100%; height: 100%; overflow: visible; } +.fleet-ring circle { fill: none; stroke-width: 3; } +.ring-track { stroke: #e2dce9; } +.ring-segment { transform: rotate(-90deg); transform-origin: center; stroke-dasharray: 0 100; stroke-linecap: round; transition: stroke-dasharray .8s var(--ease), stroke-dashoffset .8s var(--ease); } +.ring-running { stroke: #e7d98b; } +.ring-stopped { stroke: #dcb2a5; } +.ring-other { stroke: #baabcb; } +.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; padding-top: 7px; } +.ring-center > span { color: var(--muted); font-size: 9px; } +.ring-center > strong { font-size: 43px; font-weight: 250; letter-spacing: -.06em; line-height: 1.4; } +.ring-center > strong small { font-size: 26px; font-weight: 300; } +.ring-center > span:last-child { font-size: 8px; } +.ring-legend { display: grid; gap: 21px; flex: 1; max-width: 150px; padding-right: 3px; } +.ring-legend > div { display: flex; align-items: flex-start; gap: 7px; } +.ring-legend .dot { width: 4px; height: 4px; margin-top: 5px; } +.ring-legend span { color: var(--ink-soft); font-size: 10px; line-height: 1.4; } +.ring-legend small { display: block; color: var(--muted); font-size: 8px; margin-top: 4px; } +.ring-legend strong { margin-left: auto; color: var(--ink-soft); font-size: 13px; font-weight: 400; } +.notes-panel { padding: 20px 20px 15px; } +.notes-panel .section-heading { margin-bottom: 12px; } +.note-row { display: flex; align-items: center; gap: 10px; padding: 13px 0; border-bottom: 1px solid #b5a6c016; } +.note-row > .icon { width: 15px; height: 15px; color: #a89ab3; } +.note-row > div { min-width: 0; } +.note-row strong { display: block; color: var(--ink-soft); font-size: 11px; font-weight: 450; } +.note-row small { display: block; color: var(--muted); font-size: 9px; margin-top: 2px; } +.note-row > span { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; } +.note-row .icon:last-child { width: 11px; height: 11px; } +.note-row:hover strong { color: #4f405b; } +.notes-footer { margin-top: 15px; width: 100%; justify-content: space-between; font-size: 9px; } +.page-footer { display: flex; justify-content: space-between; gap: 12px; color: #a292ad; margin: 21px 3px 0; font-size: 8px; } +.page-footer > span:first-child { display: flex; align-items: center; gap: 6px; } +.page-footer .dot { width: 4px; height: 4px; } +.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 215px; padding: 30px 18px; text-align: center; border-radius: 18px; background: #f9f7fc47; } +.empty-state .empty-icon { position: relative; display: grid; place-items: center; width: 43px; height: 43px; margin-bottom: 4px; border: 1px solid #ffffffed; border-radius: 15px; background: linear-gradient(135deg, #fff, #e7e1ee); color: #b0a1bd; box-shadow: 0 8px 13px -8px #9183aa38; transform: rotate(-7deg); } +.empty-state .empty-icon::before { content: ''; position: absolute; inset: 4px -5px -3px 5px; border: 1px solid #ffffffbf; border-radius: 15px; z-index: -1; } +.empty-state h3 { font-size: 14px; font-weight: 450; letter-spacing: -.035em; } +.empty-state p { max-width: 290px; color: var(--muted); font-size: 11px; } +.empty-state .button { margin-top: 7px; } +.portfolio-content .empty-state { min-height: 191px; padding: 22px 15px; border: 1px solid #ffffff4d; } +.portfolio-content .empty-state h3 { font-size: 13px; } +.portfolio-content .empty-state p { font-size: 9px; max-width: none; } +.portfolio-content .empty-state .button { min-height: 29px; font-size: 9px; } +.loading-state { display: grid; place-items: center; min-height: 170px; color: var(--muted); font-size: 11px; } + +/* The machine collection. */ +.compact-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; margin-bottom: 25px; } +.compact-metric { display: flex; align-items: center; gap: 15px; min-height: 100px; padding: 20px; border: 1px solid #ffffff80; border-radius: 19px; background: #f9f7fba6; } +.compact-metric > .round-icon { width: 37px; height: 41px; background: #eee8f3; color: #9d8daa; } +.compact-metric:nth-child(2) > .round-icon { background: #f3eed5; color: #ac9a65; } +.compact-metric:nth-child(3) > .round-icon { background: #efe2e0; color: #ba9692; } +.compact-metric > div { min-width: 0; } +.compact-metric strong { display: block; font-size: 25px; font-weight: 350; line-height: 1.25; letter-spacing: -.05em; overflow-wrap: anywhere; } +.compact-metric span { color: var(--muted); font-size: 10px; } +.compact-metric .metric-word { font-size: 18px; } +.fleet-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 15px; } +.filter-tabs { display: flex; gap: 3px; padding: 4px; border: 1px solid #ffffff57; border-radius: 28px; background: #ffffff21; } +.filter-tab { padding: 8px 13px; border-radius: 22px; background: transparent; color: var(--muted); font-size: 11px; transition: background .2s, color .2s, box-shadow .2s; white-space: nowrap; } +.filter-tab.active { background: #fff; color: #5f496c; box-shadow: 0 3px 9px #68517508; } +.filter-tab:hover { color: var(--ink); } +.select-wrap { position: relative; display: inline-flex; align-items: center; } +.select-wrap::after { content: ''; position: absolute; right: 12px; width: 5px; height: 5px; border-right: 1px solid #92819f; border-bottom: 1px solid #92819f; transform: translateY(-2px) rotate(45deg); pointer-events: none; } +.select-wrap select { appearance: none; max-width: 100%; min-height: 32px; border: 1px solid #ffffff70; border-radius: 18px; background: #f7f4fa9c; padding: 6px 28px 6px 12px; color: #897494; font-size: 9px; cursor: pointer; } +.fleet-list-panel { padding: 5px 16px 0; border: 1px solid #ffffff73; border-radius: 21px; background: #f8f5fb57; } +.list-search-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 1px 13px; } +.search-field { display: flex; align-items: center; gap: 10px; max-width: 310px; width: 100%; color: #a18caf; } +.search-field .icon { width: 15px; height: 15px; } +.search-field input { width: 100%; padding: 7px 0; border: 0; outline: 0; background: transparent; font-size: 10px; } +.search-field:focus-within { border-bottom: 1px solid #c7b9d4; } +.list-search-row > span { font-size: 9px; color: #a18cab; white-space: nowrap; } +.machine-table { width: 100%; min-width: 690px; border-collapse: separate; border-spacing: 0 7px; text-align: left; } +.machine-table th { padding: 3px 9px 9px; color: var(--muted); font-size: 10px; font-weight: 450; } +.machine-table td { padding: 14px 9px; background: #ffffff91; color: var(--ink-soft); font-size: 11px; transition: background .2s; } +.machine-table tbody tr:nth-child(2n) td { background: #ffffff47; } +.machine-table tbody tr:hover td { background: #fff; } +.machine-table tr.selected td { background: #f6f0d9bb; } +.machine-table td:first-child { border-radius: 13px 0 0 13px; } +.machine-table td:last-child { border-radius: 0 13px 13px 0; } +.machine-table .check-cell { width: 28px; padding-right: 3px; padding-left: 10px; } +.machine-table input[type='checkbox'] { vertical-align: middle; } +.machine-name { display: flex; align-items: center; gap: 9px; } +.machine-name > .round-icon { width: 29px; height: 33px; border-radius: 10px; background: #eee7f280; } +.machine-name .round-icon .icon { width: 15px; height: 15px; color: #b39cc0; } +.machine-name > div { min-width: 80px; } +.machine-name a { display: block; max-width: 175px; color: var(--ink); font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.machine-name a:hover { text-decoration: underline; text-underline-offset: 4px; } +.machine-name small { display: block; color: var(--muted); font-size: 9px; margin-top: 3px; } +.allocation-cell { white-space: nowrap; } +.allocation-cell small { display: block; color: var(--muted); font-size: 10px; margin-top: 3px; } +.table-ports { display: flex; flex-wrap: wrap; gap: 3px; max-width: 135px; } +.table-ports .muted { font-size: 9px; color: #a78cb3; } +.row-controls { display: flex; align-items: center; justify-content: flex-end; gap: 1px; } +.row-controls .icon-button { width: 27px; height: 27px; color: #aa91b6; } +.row-controls .icon { width: 13px; height: 13px; } +.row-controls-heading { width: 67px; } +.list-footer { display: flex; justify-content: space-between; gap: 15px; padding: 17px 1px; color: #aa90b6; font-size: 8px; } +.bulk-controls { display: flex; align-items: center; gap: 9px; margin: 2px 0 13px; padding: 7px 10px; border: 1px solid #f6efc8; border-radius: 14px; background: #f3edc67d; } +.bulk-controls > span { margin-right: auto; font-size: 10px; color: #8a7945; } +.bulk-controls .icon-button { width: 25px; height: 25px; } +.operation-results { margin-top: 18px; padding: 17px 20px; border: 1px solid #fff8; border-radius: 17px; background: #f7f1eb; color: #957364; } +.operation-results header { display: flex; align-items: center; justify-content: space-between; gap: 15px; } +.operation-results h2 { font-size: 12px; } +.operation-results ul { margin: 7px 0 0; padding-left: 17px; font-size: 10px; } +.operation-results li { padding: 3px 0; overflow-wrap: anywhere; } + +/* Network and inspection. */ +.network-layout { display: grid; grid-template-columns: .85fr 1.4fr; gap: 16px; } +.network-map-panel { padding: 24px; } +.fabric-diagram { display: flex; flex-direction: column; align-items: center; padding: 23px 0; } +.fabric-node { display: flex; flex-direction: column; align-items: center; gap: 5px; position: relative; z-index: 1; } +.fabric-node > .round-icon { background: #fbf9fc; width: 45px; height: 45px; border-radius: 15px; margin-bottom: 4px; box-shadow: 0 8px 16px #78618c07; } +.fabric-node strong { font-size: 11px; font-weight: 500; } +.fabric-node small { color: #a08bac; font-size: 8px; } +.fabric-connector { width: 1px; height: 31px; margin: 8px 0; background: linear-gradient(#d7cbe1, #dccf94); } +.fabric-gateway .round-icon { background: #f4eecf; color: #b09d61; } +.fabric-branches { position: relative; display: flex; justify-content: space-between; width: 150px; height: 29px; margin-top: 21px; border-top: 1px solid #d8cce0; border-radius: 6px; } +.fabric-branches::before { content: ''; position: absolute; left: 50%; bottom: 100%; width: 1px; height: 21px; background: #d8cce0; } +.fabric-branches span { display: block; width: 1px; height: 20px; background: #d8cce0; } +.fabric-guests { display: flex; justify-content: space-between; width: 173px; margin-bottom: 16px; } +.fabric-guests .icon { width: 24px; height: 24px; color: #bba8c8; } +.fabric-diagram > .muted { font-size: 9px; color: #a38baf; } +.panel-footnote { color: var(--muted); font-size: 10px; line-height: 1.8; margin-top: 13px; overflow-wrap: anywhere; } +.policy-row { display: block; padding: 16px 2px; border-bottom: 1px solid #b9a6c51f; } +.policy-row:last-child { border-bottom: 0; } +.policy-row-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 7px; } +.policy-row-heading strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } +.policy-row-heading .icon { width: 13px; height: 13px; color: #ad97b9; } +.policy-row-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; color: var(--muted); font-size: 10px; } +.policy-row .table-ports { max-width: none; } +.policy-row:hover strong { color: #967249; } +.back-link { margin: 3px 0 21px; } +.detail-heading { margin-bottom: 22px; } +.detail-heading #detail-id { display: block; margin-top: 7px; color: #a58cb2; font-size: 8px; word-break: break-all; } +.detail-heading h1 { max-width: 450px; } +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } +.detail-card { padding: 23px; } +.detail-list { margin: 0; } +.detail-list > div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; padding: 10px 0; border-bottom: 1px solid #bcabca21; } +.detail-list > div:last-child { border-bottom: 0; } +.detail-list dt { color: var(--muted); font-size: 11px; } +.detail-list dd { max-width: 66%; margin: 0; color: var(--ink-soft); text-align: right; font-size: 11px; overflow-wrap: anywhere; } +.detail-list .mono { font-size: 10px; } +.ssh-panel { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 15px; padding: 18px 21px; } +.ssh-panel > div:first-child { display: flex; align-items: center; gap: 12px; } +.ssh-panel h2 { font-size: 12px; font-weight: 500; } +.ssh-panel p { font-size: 10px; color: var(--muted); margin-top: 4px; max-width: 300px; } +.ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; } +.ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; } +.ssh-command .icon-button { width: 27px; height: 27px; } +.ports-panel, .log-panel { margin-top: 15px; } +.port-editor { padding-top: 5px; } +.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; } + +/* Provisioning uses the same paper-like surfaces. */ +.create-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(220px, .9fr); align-items: start; gap: 17px; } +.create-fields { display: grid; gap: 15px; min-width: 0; } +.form-section { padding: 24px; } +.form-section .section-heading { margin-bottom: 22px; } +.form-section .section-heading h2 { display: flex; align-items: center; gap: 9px; } +.form-section .section-heading > div > span { padding-left: 31px; font-size: 9px; } +.step-number { display: inline-grid; place-items: center; width: 23px; height: 23px; border-radius: 50%; background: #e9e0ee; color: #ac91b7; font-size: 9px; } +.field { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.field > span:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--ink-soft); font-size: 11px; } +.field small { color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.6; } +.field input { width: 100%; min-height: 39px; padding: 10px 12px; border: 1px solid #ffffffb3; border-radius: 11px; background: #fff9; color: var(--ink-soft); font-size: 12px; transition: background .2s, border-color .2s, box-shadow .2s; } +.field input:focus { outline: none; background: #ffffffed; border-color: #c3b3a0; box-shadow: 0 0 0 3px #e6dca329; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 18px; } +.preset-fieldset { margin: 19px 0 0; padding: 0; border: 0; } +.preset-fieldset legend { color: var(--muted); font-size: 10px; padding: 0 0 9px; } +.size-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; } +.size-preset { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; padding: 12px; border: 1px solid #fff9; border-radius: 13px; background: #ffffff3b; transition: background .2s, border-color .2s; } +.size-preset .icon { width: 17px; height: 17px; color: #b59dbf; margin-bottom: 4px; } +.size-preset strong { font-size: 11px; font-weight: 450; color: var(--ink-soft); } +.size-preset span { font-size: 9px; color: var(--muted); white-space: nowrap; } +.size-preset.active { border-color: #e6dca2; background: #f6f0d991; } +.size-preset.active .icon { color: #b7a367; } +.size-preset.active strong { color: #9b8854; } +.size-preset:hover { background: #fff9; } +.password-field { display: flex; align-items: center; position: relative; } +.password-field input { padding-right: 37px; } +.password-field > .icon-button { position: absolute; right: 5px; width: 27px; height: 27px; } +.toggle-field { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; cursor: pointer; } +.toggle-field > span:first-child { display: grid; gap: 4px; } +.toggle-field strong { font-size: 11px; font-weight: 450; color: var(--ink-soft); } +.toggle-field small { font-size: 9px; color: var(--muted); } +.toggle-field > input { position: absolute; right: 0; opacity: 0; width: 33px; height: 20px; } +.toggle-track { position: relative; width: 31px; height: 18px; flex: 0 0 auto; border-radius: 15px; background: #d8cce1; transition: background .25s; } +.toggle-track::after { content: ''; position: absolute; width: 12px; height: 12px; top: 3px; left: 3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #79658721; transition: transform .25s var(--ease); } +.toggle-field > input:checked + .toggle-track { background: #d7c782; } +.toggle-field > input:checked + .toggle-track::after { transform: translateX(13px); } +.toggle-field > input:focus-visible + .toggle-track { outline: 2px solid #aa9560; outline-offset: 4px; } +.create-summary { position: sticky; top: 0; padding: 24px 22px; background: linear-gradient(140deg, #fefcffa6, #f5f0f76b); } +.summary-art { position: relative; height: 105px; margin-bottom: 20px; } +.summary-art > div, .summary-art > span { position: absolute; width: 90px; height: 72px; left: 50%; top: 14px; border: 1px solid #fff; border-radius: 17px; background: #eeebf1; box-shadow: 0 10px 21px -10px #9b87ad40; } +.summary-art > div:nth-child(1) { transform: translateX(-38%) rotate(15deg); background: #e8e2ed; } +.summary-art > div:nth-child(2) { transform: translateX(-62%) rotate(-12deg); background: #f4f0f8; } +.summary-art > span { display: grid; place-items: center; transform: translateX(-50%) rotate(-3deg); background: linear-gradient(130deg, #fffeff, #f1eadb); } +.summary-art .icon { width: 31px; height: 31px; color: #b8a990; } +.create-summary > .subtle-label { font-size: 8px; color: #ab91b3; } +.create-summary > h2 { margin-top: 8px; color: var(--ink-soft); font-size: 20px; font-weight: 400; letter-spacing: -.055em; overflow-wrap: anywhere; } +.create-summary > p:not(.form-error) { margin-top: 10px; color: var(--muted); font-size: 10px; } +.create-summary .detail-list { margin: 23px 0; } +.create-summary .detail-list dt, .create-summary .detail-list dd { font-size: 10px; } +.create-summary .button { font-size: 10px; } +.summary-cancel { display: flex; justify-content: center; margin-top: 13px; font-size: 9px; color: #b29ab9; } +.summary-note { display: flex; justify-content: center; align-items: center; gap: 6px; color: #b9a3bd; font-size: 8px; margin-top: 26px; } +.summary-note .icon { width: 12px; height: 12px; } +.form-error { color: #b1726b; font-size: 10px; line-height: 1.7; margin: 9px 0; overflow-wrap: anywhere; } +.form-error:empty { display: none; } + +/* Host readiness and installation. */ +.health-layout { display: grid; grid-template-columns: 1.35fr 1fr; gap: 16px; align-items: start; } +.health-side { display: grid; gap: 16px; } +.diagnostics-panel { padding: 22px; } +.diagnostics-list { display: grid; gap: 8px; } +.diagnostic-item { display: flex; align-items: flex-start; gap: 11px; padding: 13px 12px; border: 1px solid #ffffff61; border-radius: 13px; background: #ffffff47; } +.diagnostic-item > .diagnostic-icon { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 auto; border-radius: 9px; background: #e8ebe1; color: #98a383; } +.diagnostic-icon .icon { width: 13px; height: 13px; } +.diagnostic-item.attention > .diagnostic-icon { background: #efe0d8; color: #c19e8a; } +.diagnostic-item > div { min-width: 0; flex: 1; } +.diagnostic-item h3 { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--ink-soft); font-size: 11px; font-weight: 500; } +.diagnostic-item h3 > span { font-size: 9px; font-weight: 450; white-space: nowrap; color: #7a8562; } +.diagnostic-item.attention h3 > span { color: #a2856a; } +.diagnostic-item p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; } +.install-panel { padding: 24px; } +.install-panel > .round-icon { width: 44px; height: 44px; background: #f2eacb; color: #b09c67; margin-bottom: 18px; } +.install-panel h2 { font-size: 16px; font-weight: 450; letter-spacing: -.04em; } +.install-panel > p { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.8; } +.install-assets { display: grid; gap: 12px; margin: 24px 0; padding: 17px 0; border-block: 1px solid #beabcc24; } +.install-assets > span { display: flex; align-items: center; gap: 9px; font-size: 11px; color: var(--muted); } +.install-assets .icon { width: 14px; height: 14px; color: #bca5c3; } +.install-panel .toggle-field { margin-top: 6px; } +.install-panel .toggle-field strong { font-size: 9px; } +.install-panel .button { font-size: 10px; } +.install-result { max-height: 280px; margin: 17px 0 0; padding: 13px; border-radius: 12px; background: #e9e1ee; color: #95749d; font-family: var(--mono); font-size: 9px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; } +.server-info { padding: 23px; } +.server-info .detail-list { margin: 0 0 18px; } +.server-info .detail-list > div { flex-direction: column; gap: 3px; } +.server-info .detail-list dd { max-width: 100%; text-align: left; } +.server-info .quiet-link { font-size: 9px; } + +/* Authentication is part of the same workspace, not a different product. */ +.auth-screen { display: grid; place-items: center; min-height: 100dvh; padding: 32px; } +.auth-window { display: grid; grid-template-columns: 1.08fr 1fr; width: min(100%, 990px); min-height: 620px; overflow: hidden; border: 1px solid #ffffffe0; border-radius: 29px; background: #f3f0f6; box-shadow: 0 25px 75px -36px #7158834d; } +.auth-story { display: flex; flex-direction: column; position: relative; padding: 35px 40px 26px; overflow: hidden; background: linear-gradient(130deg, #edeaf1, #e4deeb); border-right: 1px solid #ffffffa1; border-radius: 28px; } +.auth-story-copy { position: relative; margin-top: 55px; z-index: 1; } +.auth-story-copy > .subtle-label { font-size: 9px; color: #a68dad; } +.auth-story-copy h1 { font-size: 43px; letter-spacing: -.065em; line-height: 1.2; font-weight: 400; color: #64556e; margin-top: 14px; } +.auth-story-copy p { color: var(--muted); font-size: 12px; line-height: 1.9; margin-top: 18px; } +.auth-sculpture { position: relative; flex: 1; min-height: 255px; } +.sculpture-card { position: absolute; width: 155px; height: 125px; left: 50%; top: 74px; border-radius: 22px; border: 1px solid #ffffffe0; background: linear-gradient(130deg, #fefcff, #eee6f1); box-shadow: 0 18px 25px -15px #7b608d59; } +.sculpture-back { transform: translateX(-63%) rotate(-22deg) translateY(-25px); background: #e4dbea; } +.sculpture-middle { transform: translateX(-37%) rotate(15deg) translateY(-15px); } +.sculpture-front { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 12px; padding: 20px 24px; transform: translateX(-50%) rotate(-4deg); background: linear-gradient(130deg, #fff, #f2ecd9); } +.sculpture-front > .icon { width: 30px; height: 30px; color: #b5a68c; } +.sculpture-front > span { color: #a58c8a; font-size: 10px; } +.sculpture-lights { display: flex; gap: 5px; } +.sculpture-lights i { display: block; width: 4px; height: 4px; border-radius: 50%; background: #e0cf85; } +.sculpture-lights i:nth-child(2) { background: #d5cbdc; } +.sculpture-orbit { position: absolute; width: 260px; height: 260px; left: 50%; top: 3px; border: 1px solid #c9b9d438; border-radius: 50%; transform: translateX(-50%); } +.orbit-inner { width: 206px; height: 206px; top: 30px; border-color: #fff7; } +.orbit-outer::after { content: ''; position: absolute; top: 31px; left: 36px; width: 7px; height: 7px; border-radius: 50%; background: #e1d094; box-shadow: 0 0 0 5px #e6d9b326; } +.sculpture-caption { position: absolute; left: 0; right: 0; top: 240px; display: flex; align-items: center; justify-content: center; gap: 6px; color: #af93b5; font-size: 8px; } +.sculpture-caption .dot { width: 4px; height: 4px; } +.auth-story-footer { position: relative; margin-top: 37px; color: #ae96b5; font-size: 8px; } +.auth-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; padding: 48px 45px; background: linear-gradient(150deg, #faf8fc, #f1ebf5); } +.auth-panel-inner { width: 100%; max-width: 310px; } +.auth-lock { width: 43px; height: 43px; background: #eee7f2; color: #a18aaa; margin-bottom: 28px; } +.auth-panel h2 { margin: 9px 0 13px; font-size: 27px; font-weight: 400; letter-spacing: -.055em; color: #735b7d; } +.auth-panel-inner > p { color: var(--muted); font-size: 12px; line-height: 1.85; } +.auth-panel form { margin-top: 29px; } +.auth-panel .field input { background: #fdfbfe; border-color: #e7dcec; min-height: 43px; } +.auth-panel .button { margin-top: 16px; min-height: 41px; } +.auth-panel-inner > .auth-note { display: flex; align-items: center; gap: 6px; font-size: 8px; color: #b39bbd; margin-top: 19px; } +.auth-note .icon { width: 12px; height: 12px; } +.auth-help { position: absolute; bottom: 30px; color: #b39bbd; font-size: 8px; } + +/* Small interactions, accessible dialogs, and a useful search. */ +.search-results { position: absolute; top: calc(100% + 10px); right: -8px; width: 330px; max-width: calc(100vw - 40px); max-height: 380px; padding: 7px; overflow-y: auto; z-index: 12; border: 1px solid #fff; border-radius: 17px; background: #faf7fceF; box-shadow: 0 12px 35px #6f538526; backdrop-filter: blur(20px); } +.search-result { display: flex; align-items: center; gap: 9px; padding: 10px; border-radius: 11px; color: #8b6f98; font-size: 10px; } +.search-result[aria-selected='true'], .search-result:hover { background: #eee5f4; } +.search-result > .icon { width: 15px; height: 15px; color: #b398c2; } +.search-result > div { min-width: 0; flex: 1; } +.search-result strong { display: block; font-size: 10px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-result small { display: block; margin-top: 2px; font-size: 8px; color: #b296c0; } +.search-empty { padding: 20px; color: #ac8cb9; font-size: 10px; } +.action-menu { position: fixed; width: 181px; padding: 6px; z-index: 30; border: 1px solid #fff; border-radius: 15px; background: #fcf9feee; backdrop-filter: blur(18px); box-shadow: 0 12px 30px #71538025; } +.action-menu button { display: flex; align-items: center; gap: 9px; padding: 10px; width: 100%; border-radius: 10px; background: transparent; color: #8b6b9a; font-size: 10px; text-align: left; } +.action-menu button:hover, .action-menu button:focus { background: #ede3f4; outline: none; } +.action-menu .icon { width: 14px; height: 14px; color: #b599c3; } +.action-menu .menu-danger { color: #b77970; } +.action-menu .menu-danger .icon { color: #c7978f; } +.notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; margin-bottom: 20px; border: 1px solid #fff9; border-radius: 13px; background: #f3ebe0; color: #a38a67; font-size: 10px; overflow-wrap: anywhere; } +.notice > .icon { width: 15px; height: 15px; } +.notice > .quiet-link { margin-left: auto; flex-shrink: 0; color: inherit; } +.notice-error { background: #f3e6e3; color: #ad7973; } +.confirm-dialog { width: min(420px, calc(100% - 34px)); max-height: calc(100dvh - 50px); padding: 29px; border: 1px solid #fff; border-radius: 24px; background: #f8f5fb; color: var(--ink); box-shadow: 0 30px 90px #69537738; overflow-y: auto; } +.confirm-dialog[open] { animation: dialog-appear .25s var(--ease); } +.confirm-dialog::backdrop { background: #6655722e; backdrop-filter: blur(6px); } +.confirm-dialog .round-icon { background: #ece4f1; margin-bottom: 19px; } +.confirm-dialog .subtle-label { font-size: 9px; } +.confirm-dialog h2 { color: var(--ink); font-size: 21px; font-weight: 450; letter-spacing: -.045em; margin-top: 8px; overflow-wrap: anywhere; } +.confirm-dialog p { color: var(--muted); font-size: 12px; line-height: 1.8; margin-top: 13px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 26px; } +.toast-container { position: fixed; bottom: 27px; right: 30px; display: grid; gap: 9px; max-width: min(370px, calc(100vw - 34px)); z-index: 60; } +.toast { display: flex; align-items: flex-start; gap: 10px; padding: 14px 17px; border: 1px solid #fff; border-radius: 15px; background: #fbf8fef5; color: #9271a1; box-shadow: 0 9px 35px #77568521; font-size: 11px; animation: toast-appear .35s var(--ease); overflow-wrap: anywhere; } +.toast > .icon { width: 16px; height: 16px; color: #b2b98a; margin-top: 1px; } +.toast.toast-error { color: #b7786e; } +.toast.toast-error > .icon { color: #c68d81; } +.noscript-message { padding: 20px; text-align: center; color: #8c6d98; } +.nav-scrim { display: none; } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes page-appear { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } +@keyframes dialog-appear { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes toast-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +* { scrollbar-width: thin; scrollbar-color: #c9bdd354 transparent; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-thumb { border-radius: 8px; background: #c9bdd36b; } +::-webkit-scrollbar-thumb:hover { background: #b5a4c18f; } + +@media (min-width: 1650px) { + .app-shell { grid-template-columns: 423px minmax(0, 1fr); } + .workspace-drawer { grid-template-columns: minmax(0, 1fr) 174px; gap: 19px; padding-inline: 24px 21px; } + .nav-item { font-size: 11px; min-height: 40px; } + .sidebar-heading h2 { font-size: 23px; } + .context-rail { gap: 20px; } + .insight-card { min-height: 177px; padding: 17px; } + .insight-card .card-caption { font-size: 10px; } + .insight-card p { font-size: 10px; } + .collection-label { min-height: 72px; } + .collection-label strong { font-size: 11px; } + .collection-label span { font-size: 9px; } + .collection-art { height: 79px; } + .topbar { min-height: 79px; } + .page-content { padding: 15px 33px 30px; } + .metric-card { min-height: 169px; padding: 20px 23px; } + .card-caption h2 { font-size: 12px; } + .fleet-number { margin-block: 15px; } + .fleet-number > strong { font-size: 45px; } + .fleet-number > span, .metric-bottom, .signal-readouts small { font-size: 9px; } + .range-labels { font-size: 8px; } + .signal-readouts { margin-top: 20px; } + .resource-line > span { font-size: 12px; } + .resource-line > small { font-size: 8px; } + .resource-line { margin-top: 23px; } + .second-resource { margin-top: 17px; } + .section-heading h2 { font-size: 15px; } + .section-heading > div > span { font-size: 10px; } + .portfolio-section { margin-top: 31px; } + .portfolio-table th { font-size: 9px; } + .portfolio-table td { padding-block: 15px; font-size: 10px; } + .portfolio-table .badge { font-size: 9px; } + .portfolio-name { font-size: 11px; } + .overview-bottom { margin-top: 27px; } + .fleet-ring { width: 226px; } + .ring-center > strong { font-size: 51px; } + .ring-center > span, .ring-legend span { font-size: 10px; } + .ring-center > span:last-child, .ring-legend small { font-size: 8px; } + .ring-legend { max-width: 195px; gap: 25px; } + .note-row { padding-block: 18px; } + .note-row strong { font-size: 11px; } + .note-row small { font-size: 9px; } + .notes-footer { font-size: 10px; } + .machine-table th { font-size: 10px; } + .machine-table td { padding-block: 18px; font-size: 11px; } + .machine-name a { max-width: 260px; font-size: 12px; } + .machine-name small { font-size: 9px; } } -/* ─── System ─── */ -.system-card .card-title { margin-bottom: 12px; } -.system-status { display: flex; align-items: center; gap: 12px; margin-top: 8px; } -.system-actions { display: flex; align-items: center; gap: 14px; margin-top: 12px; } -.code-block { - background: var(--bg-input); border: 1px solid var(--border-color); - border-radius: var(--radius-md); padding: 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; color: var(--text-secondary); margin-top: 12px; overflow: auto; +@media (max-width: 1360px) { + .app-shell { grid-template-columns: 342px minmax(0, 1fr); width: calc(100% - 32px); height: calc(100dvh - 32px); margin: 16px auto; } + .workspace-drawer { grid-template-columns: minmax(0, 1fr) 142px; gap: 12px; padding-inline: 16px 14px; } + .sidebar-heading h2 { font-size: 19px; } + .brand-tag { display: none; } + .nav-item { font-size: 9px; gap: 8px; padding-inline: 9px; } + .insight-card { padding-inline: 12px; } + .insight-card .card-caption { font-size: 8px; } + .insight-card p { font-size: 8px; } + .collection-label { padding-inline: 12px; } + .collection-label span { font-size: 7px; } + .topbar { padding-inline: 20px; } + .page-content { padding-inline: 20px; } + .metric-card { padding: 15px; } + .card-caption h2 { font-size: 10px; } + .fleet-number > span { font-size: 7px; } + .fleet-number { gap: 10px; } + .metric-bottom { font-size: 7px; } + .overview-bottom { grid-template-columns: 1.15fr 1fr; } + .activity-panel, .notes-panel { padding-inline: 16px; } + .fleet-ring { width: 165px; } + .ring-center > strong { font-size: 37px; } + .ring-legend { gap: 18px; } + .ring-legend small { display: none; } + .note-row { padding-block: 11px; } + .note-row strong { font-size: 8px; } + .note-row small { font-size: 7px; } + .notes-footer { font-size: 7px; } + .create-layout { grid-template-columns: minmax(0, 1.7fr) minmax(195px, .85fr); gap: 13px; } + .form-section { padding: 19px; } + .create-summary { padding: 21px 17px; } + .size-preset { padding: 10px 9px; } + .size-preset span { font-size: 7px; } + .compact-metric { padding: 17px; gap: 11px; } + .health-layout { grid-template-columns: 1.2fr 1fr; gap: 13px; } } -.log-output { - max-height: 320px; overflow: auto; margin: 0; - padding: 14px; border: 1px solid var(--border-color); border-radius: var(--radius-md); - background: var(--bg-input); color: #b8b8c5; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; -} -.diagnostics-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } -.diagnostic-item { - display: flex; align-items: flex-start; gap: 10px; - padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); - background: var(--bg-input); -} -.diagnostic-mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; margin-top: 5px; } -.diagnostic-mark.ok { background: var(--green); box-shadow: 0 0 6px var(--green); } -.diagnostic-mark.error { background: var(--red); box-shadow: 0 0 6px var(--red); } -.diagnostic-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } -.diagnostic-message { margin-top: 2px; font-size: 11px; color: var(--text-muted); overflow-wrap: anywhere; } -/* ─── Empty State ─── */ -.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); } -.empty-state p { margin: 0 0 12px; } - -/* ─── Modal ─── */ -.modal { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; } -.modal.open { display: flex; } -.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); } -.modal-card { - position: relative; z-index: 1; - background: var(--bg-card); border: 1px solid var(--border-color); - border-radius: var(--radius-xl); padding: 28px; width: 100%; max-width: 400px; - box-shadow: var(--shadow-lg); +@media (max-width: 1150px) { + .app-shell { grid-template-columns: 200px minmax(0, 1fr); } + .workspace-drawer { display: flex; padding: 25px 17px; } + .sidebar { width: 100%; } + .context-rail { display: none; } + .brand-tag { display: inline; } + .nav-item { font-size: 10px; } + .sidebar-heading h2 { font-size: 21px; } + .overview-bottom { grid-template-columns: 1.2fr 1fr; } + .health-layout { grid-template-columns: 1.15fr 1fr; } + .diagnostics-panel, .install-panel, .server-info { padding: 19px; } + .create-layout { grid-template-columns: minmax(0, 1.7fr) minmax(208px, .9fr); } + .detail-heading { align-items: flex-start; } + .ssh-panel { align-items: flex-start; flex-direction: column; gap: 15px; } + .ssh-command { width: 100%; } } -.modal-card h3 { margin: 0 0 8px; font-size: 18px; } -.modal-card p { margin: 0 0 20px; color: var(--text-secondary); font-size: 13px; } -.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } -/* ─── Toasts ─── */ -#toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 10px; } -.toast { - background: var(--bg-card); border: 1px solid var(--border-color); - border-radius: var(--radius-md); padding: 12px 16px; - box-shadow: var(--shadow-md); font-size: 13px; - animation: slideIn 200ms ease; min-width: 260px; - display: flex; align-items: center; gap: 10px; -} -.toast.success { border-color: rgba(34,197,94,0.35); color: var(--green); } -.toast.error { border-color: rgba(239,68,68,0.35); color: var(--red); } -@keyframes slideIn { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } } - -/* ─── Responsive ─── */ -@media (max-width: 1024px) { - .stats-grid { grid-template-columns: repeat(2, 1fr); } +@media (max-width: 900px) { + .app-shell { grid-template-columns: 182px minmax(0, 1fr); } + .workspace-drawer { padding-inline: 13px; } + .brand-tag { display: none; } + .nav-item { font-size: 9px; } + .global-search { max-width: 185px; } + .global-search kbd { display: none; } + .topbar { padding-inline: 18px; gap: 6px; } + .workspace-crumb { font-size: 8px; gap: 6px; padding-inline: 10px; } + .page-content { padding-inline: 18px; } + .page-heading h1 { font-size: 24px; } + .date-label { font-size: 8px; } + .overview-metrics { grid-template-columns: 1fr 1fr; gap: 11px; } + .resources-metric { grid-column: 1 / -1; min-height: auto; display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; padding-block: 14px; } + .resources-metric .card-caption { grid-column: 1 / -1; } + .resources-metric .resource-line { grid-row: 2; margin-top: 13px; } + .resources-metric .resource-track { grid-row: 3; } + .resources-metric .second-resource { grid-column: 2; } + .overview-bottom { grid-template-columns: 1fr; } + .activity-body { justify-content: center; gap: 30px; } + .fleet-ring { width: 185px; } + .ring-legend { max-width: 165px; gap: 23px; } + .ring-legend small { display: block; } + .notes-panel { padding: 20px; } + .note-row { padding-block: 12px; } + .note-row strong { font-size: 10px; } + .note-row small { font-size: 8px; } + .notes-footer { font-size: 9px; } + .compact-metrics { gap: 9px; } + .compact-metric { padding: 15px; gap: 9px; } + .compact-metric > .round-icon { display: none; } + .compact-metric strong { font-size: 24px; } + .compact-metric span { font-size: 8px; } + .filter-tab { padding-inline: 10px; font-size: 9px; } + .fleet-toolbar { flex-wrap: wrap; gap: 11px; } + .health-layout, .network-layout { grid-template-columns: 1fr; } + .health-side { grid-template-columns: 1fr 1fr; gap: 13px; align-items: start; } + .fabric-diagram { padding-block: 10px; } + .network-map-panel .fabric-diagram { display: grid; grid-template-columns: 1fr 50px 1fr; justify-items: center; } + .network-map-panel .fabric-connector { height: 1px; width: 45px; } + .network-map-panel .fabric-branches, .network-map-panel .fabric-guests { display: none; } + .network-map-panel .fabric-diagram > .muted { grid-column: 1 / -1; margin-top: 25px; } + .network-map-panel .panel-footnote { text-align: center; } + .create-layout { grid-template-columns: 1fr; } + .create-summary { position: static; padding: 23px; } + .summary-art { display: none; } + .create-summary .detail-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0 25px; margin-block: 15px; } + .create-summary > .button { max-width: 300px; margin: auto; display: flex; } + .summary-note { margin-top: 17px; } .detail-grid { grid-template-columns: 1fr; } -} -@media (max-width: 768px) { - .sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } - .main { margin-left: 0; } - #main-app { flex-direction: column; } - .stats-grid { grid-template-columns: 1fr; } - .form-grid { grid-template-columns: 1fr; } - .content { padding: 20px; } - .topbar { padding: 12px 20px; } + .detail-heading { flex-direction: column; gap: 17px; } + .auth-window { min-height: 590px; } + .auth-story { padding: 29px; } + .auth-story-copy h1 { font-size: 37px; } + .auth-panel { padding-inline: 30px; } } -/* ─── Scrollbar ─── */ -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #2a2a36; border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: #3a3a48; } +@media (max-width: 700px) { + html, body { background: #e9e5ef; } + .app-shell { display: block; width: calc(100% - 20px); height: auto; min-height: calc(100dvh - 20px); margin: 10px; border-radius: 21px; overflow: clip; } + .workspace { min-height: calc(100dvh - 22px); } + .workspace-drawer { position: fixed; inset: 10px auto 10px 10px; width: 237px; z-index: 42; padding: 25px 20px; background: #f2edf7f5; border: 1px solid #fff; border-radius: 22px; backdrop-filter: blur(25px); transform: translateX(calc(-100% - 16px)); visibility: hidden; transition: transform .3s var(--ease), visibility .3s; box-shadow: 15px 0 45px #72508412; } + .app-shell.nav-open .workspace-drawer { visibility: visible; transform: translateX(0); } + .nav-scrim:not([hidden]) { display: block; position: fixed; inset: 0; z-index: 41; background: #80648e36; backdrop-filter: blur(3px); } + .brand-tag { display: inline; } + .nav-item { font-size: 11px; min-height: 41px; } + .sidebar-heading h2 { font-size: 23px; } + .sidebar-bottom { padding-top: 35px; } + .sidebar-bottom > .quiet-link { margin-top: 20px; } + .mobile-menu { display: inline-flex; width: 29px; height: 29px; flex: 0 0 auto; } + .topbar { min-height: 61px; padding-inline: 14px; gap: 7px; } + .workspace-crumb { background: transparent; padding-inline: 2px; font-size: 10px; box-shadow: none; } + .workspace-crumb > span:not(:last-child) { display: none; } + #breadcrumb { color: #92749d; } + .refresh-button { margin-right: 2px; } + .global-search { max-width: 215px; min-width: 80px; } + .global-search input { font-size: 9px; } + .topbar-create { flex-shrink: 0; margin-left: 1px; } + .page-content { flex: 1 0 auto; overflow: visible; padding: 13px 17px 24px; scrollbar-gutter: auto; } + .page-heading { gap: 12px; margin-bottom: 20px; } + .page-heading h1 { font-size: 26px; } + .page-heading .subtle-label { font-size: 9px; } + .date-label { display: none; } + .metric-card { min-height: 147px; padding: 17px; } + .card-caption h2 { font-size: 11px; } + .fleet-number > strong { font-size: 39px; } + .fleet-number > span { font-size: 8px; } + .metric-bottom { font-size: 8px; } + .resources-metric { min-height: 101px; } + .portfolio-section { margin-top: 25px; } + .portfolio-table { min-width: 540px; } + .overview-bottom { margin-top: 19px; } + .activity-panel, .notes-panel { padding: 20px; } + .activity-body { gap: 24px; } + .fleet-ring { width: 192px; } + .ring-center > strong { font-size: 43px; } + .ring-legend span { font-size: 10px; } + .ring-legend small { font-size: 8px; } + .page-footer { flex-direction: column; gap: 5px; font-size: 8px; margin-top: 20px; } + .compact-metrics { margin-bottom: 21px; gap: 8px; } + .compact-metric { min-height: 83px; padding: 14px; } + .compact-metric strong { font-size: 26px; } + .compact-metric .metric-word { font-size: 17px; } + .compact-metric span { font-size: 8px; } + .fleet-toolbar { margin-bottom: 13px; } + .fleet-list-panel { padding-inline: 11px; } + .filter-tabs { width: 100%; justify-content: space-between; gap: 0; } + .filter-tab { flex: 1; font-size: 9px; padding-inline: 9px; } + .fleet-toolbar > .select-wrap { margin-left: auto; } + .list-search-row { gap: 8px; } + .search-field input { font-size: 9px; } + .list-search-row > span { font-size: 8px; } + .list-footer { font-size: 8px; } + .list-footer > span:last-child { display: none; } + .bulk-controls { flex-wrap: wrap; gap: 7px; } + .bulk-controls > span { font-size: 9px; } + .bulk-controls select { font-size: 8px; } + .bulk-controls .button { font-size: 9px; } + .health-side { grid-template-columns: 1fr; } + .health-layout { gap: 13px; } + .install-panel, .server-info, .diagnostics-panel { padding: 22px; } + .diagnostic-item p { font-size: 9px; } + .diagnostic-item h3 { font-size: 11px; } + .install-panel > p { font-size: 11px; } + .install-panel .toggle-field strong { font-size: 10px; } + .server-info .detail-list > div { flex-direction: row; } + .server-info .detail-list dd { max-width: 65%; text-align: right; } + .form-section { padding: 21px; } + .create-summary { padding: 23px; } + .size-preset { padding: 12px; } + .size-preset span { font-size: 8px; } + .field input { font-size: 12px; } + .detail-delete-row { align-items: flex-start; } + .detail-delete-row > span { max-width: 170px; } + .ports-panel > .section-heading, .log-panel > .section-heading { align-items: flex-start; } + .ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child { flex: 1; } + .auth-screen { padding: 20px; } + .auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; } + .auth-story { min-height: 240px; padding: 26px; border-right: 0; } + .auth-story-copy { margin-top: 27px; } + .auth-story-copy > .subtle-label { font-size: 8px; } + .auth-story-copy h1 { font-size: 34px; margin-top: 8px; } + .auth-story-copy p { display: none; } + .auth-sculpture { position: absolute; width: 130px; height: 150px; min-height: 0; right: -2px; bottom: 23px; opacity: .65; transform: scale(.62); transform-origin: right bottom; } + .sculpture-caption { display: none; } + .auth-story-footer { margin-top: 19px; font-size: 7px; } + .auth-panel { padding: 31px 28px 60px; } + .auth-lock { display: none; } + .auth-panel h2 { font-size: 25px; } + .auth-panel form { margin-top: 22px; } + .auth-help { bottom: 23px; } + .toast-container { bottom: 17px; right: 17px; } + .notice { align-items: flex-start; font-size: 9px; } +} + +@media (max-width: 390px) { + .topbar { gap: 3px; padding-inline: 11px; } + .workspace-crumb { font-size: 9px; } + .global-search input { font-size: 8px; } + .page-content { padding-inline: 13px; } + .page-heading h1 { font-size: 23px; } + .page-heading > .button { padding-inline: 12px; font-size: 9px; } + .page-heading > .tag { font-size: 8px; padding-inline: 8px; } + .card-caption h2 { font-size: 10px; } + .metric-card { padding: 14px; } + .fleet-number > span { font-size: 7px; } + .metric-bottom { font-size: 7px; } + .signal-readouts small { font-size: 7px; } + .range-labels { font-size: 6px; } + .activity-body { gap: 12px; } + .fleet-ring { width: 160px; } + .ring-center > strong { font-size: 36px; } + .ring-legend span { font-size: 9px; } + .ring-legend small { font-size: 7px; } + .form-section { padding: 17px; } + .size-presets { gap: 7px; } + .size-preset { padding: 10px; } + .size-preset span { font-size: 7px; } + .form-grid { gap: 11px; } + .field > span:first-child { font-size: 9px; } + .section-heading h2 { font-size: 12px; } + .log-panel .section-heading { flex-wrap: wrap; } + .auth-screen { padding: 13px; } +} + +@media (max-height: 820px) and (min-width: 1151px) { + .context-rail { gap: 13px; padding-top: 46px; } + .insight-card { min-height: 150px; } + .collection-art { height: 53px; } + .collection-label { min-height: 60px; } + .rail-footnote { display: none; } + .sidebar-bottom { padding-top: 30px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } +} diff --git a/uvm/dashboard/static/fonts/OFL.txt b/uvm/dashboard/static/fonts/OFL.txt new file mode 100644 index 0000000..89d50bf --- /dev/null +++ b/uvm/dashboard/static/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/uvm/dashboard/static/fonts/manrope-latin.woff2 b/uvm/dashboard/static/fonts/manrope-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..71eb731d558046199aa7f985adbf812890a093a1 GIT binary patch literal 24836 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 literal 0 HcmV?d00001 diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html index e7e8f75..7520d77 100644 --- a/uvm/dashboard/static/index.html +++ b/uvm/dashboard/static/index.html @@ -1,379 +1,205 @@ - + - - UVM Dashboard - + + + + UVM Control + + + + -

      - -
      -
      -
      - -

      Welcome back

      -

      Enter your management API token to access the dashboard.

      -
      -
      -
      - - -
      - -

      + + +
      +
      +
      + uvm.workspace +
      A little space for big ideas

      Your compute.
      A little calmer.

      A considered workspace for your machines,
      your network, and everything in between.

      + +
      -
      - - -
      - - -
      -
      -
      -

      Overview

      -
      -
      - - -
      -
      - -
      - -
      -
      -
      -
      - Total VMs -
      - -
      -
      -
      —
      -
      registered VMs
      -
      -
      -
      - Running -
      - -
      -
      -
      —
      -
      active microVMs
      -
      -
      -
      - Stopped -
      - -
      -
      -
      —
      -
      inactive microVMs
      -
      -
      -
      - API Health -
      - -
      -
      -
      —
      -
      last check —
      -
      -
      - -
      -

      Recent Virtual Machines

      - View all -
      -
      -
      -

      No virtual machines yet.

      - -
      -
      -
      - - -
      -
      -
      - -
      -
      - 0 selected - - -
      - -
      -
      -
      - - - - - - - - - - - - - - -
      VMStatusGuest IPResourcesPortsPolicyActions
      -
      -
      -
      -

      No virtual machines found.

      - -
      -
      -
      -
      - - -
      -
      - -
      -

      —

      - — -
      -
      -
      -
      -

      Configuration

      -
      -
      -
      -
      -

      Networking

      -
      -
      -
      -
      -

      SSH Access

      - ssh root@10.42.0.2 - -

      Run this from a trusted local terminal.

      -
      -
      -

      Port Rules

      -
      -

      No port rules configured.

      -
      -
      - -
      - - - - -
      -
      -
      - - -
      -
      -
      -
      -

      Firecracker Log

      - -
      -
      Loading recent log entries…
      -

      Showing up to 100 recent lines.

      -
      -
      -

      Actions

      -
      - - - - -
      -

      Use the copied SSH command from a trusted local terminal.

      -
      -
      -
      - - -
      -
      -

      Create Virtual Machine

      -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      - -
      -
      -
      - - -
      -
      
      -            
      -
      - - -
      -
      -
      -

      API Health

      -
      - Checking… -

      Send a test request to the management API.

      -
      -
      -
      -

      Installation

      -

      Install or refresh host assets (Firecracker, kernel, rootfs). This is a privileged operation.

      -
      - - -
      - -
      -
      - -
      -

      Server Information

      -
      -
      Dashboard Version1.0.0
      -
      API URL—
      -
      Observed At—
      -
      -
      -
      -
      -

      Host Diagnostics

      - -
      -
      -

      Open System to run host readiness checks.

      -
      -
      -
      +
      +
      + + Your local workspace +

      Welcome to UVM

      +

      Bring your machines into view.
      Connect with your management token to begin.

      +
      + + + +
      +

      Saved only for this browser session.

      + Explore the API
      +
      - -
      - - -