Compare commits

...
36 Commits
Author SHA1 Message Date
kstyagi cc2ceb3bcc Merge pull request 'Dev' (#6) from dev into main
Reviewed-on: #6
2026-09-25 15:47:12 +00:00
kstyagi a4e9cefbc3 feat: enhance guest agent setup and terminal support for microVMs 2026-09-25 21:15:53 +05:30
kstyagi 6573f483e6 feat: update terminal support messaging and enhance guest agent provisioning logic 2026-09-25 21:12:21 +05:30
kstyagi d870c98bfa feat: add terminal session management with HTTP/WebSocket routes and session history 2026-09-25 20:31:20 +05:30
kstyagi caaea12163 feat: implement session management and terminal handling with UI updates and backend support 2026-09-25 20:22:56 +05:30
kstyagi d202f9e2f8 feat: add session management and terminal handling with UI updates and tests 2026-09-25 20:06:44 +05:30
kstyagi 6aa70133c5 Refactor code structure for improved readability and maintainability 2026-09-25 19:58:49 +05:30
kstyagi 73d85fefe4 feat: enhance terminal session handling with output acknowledgment and error handling 2026-09-25 19:54:07 +05:30
kstyagi 9541e29b76 feat: implement session management and terminal handling for VM sessions 2026-09-25 19:52:26 +05:30
kstyagi 3b94ac1b48 feat: add terminal support for guest execution agent and enhance lifecycle management 2026-09-25 19:51:35 +05:30
kstyagi 4663fd1923 Merge pull request 'Dev' (#5) from dev into main
Reviewed-on: #5
2026-09-25 14:20:33 +00:00
kstyagi b0bf99a6ee feat: Add guest execution agent and vsock support
- Introduced a standalone guest execution agent to facilitate command execution within the guest VM over Firecracker's vsock.
- Enhanced FirecrackerClient to support vsock configuration, validating guest CID and UDS path.
- Updated configuration to include vsock settings by default.
- Refactored FirecrackerProcessManager to manage runtime sockets more effectively.
- Implemented guest agent installation and provisioning in the image store.
- Added API endpoints for executing commands in the VM and installing the guest agent.
- Improved error handling for guest execution failures and agent availability.
2026-09-25 17:39:06 +05:30
kstyagi dca7f78dae feat: introduce Nest workspace for local Firecracker management
- Added a new server module for the Nest workspace, integrating with UVM's API.
- Updated project description to reflect the new Nest functionality.
- Created tests for the Nest server and CLI, ensuring proper functionality and error handling.
- Refactored existing UVM server code to separate Nest and UVM functionalities.
- Removed the old UVM dashboard assets package as it is now part of the Nest workspace.
- Enhanced error messages for clarity regarding server shutdown commands.
2026-09-25 13:45:57 +05:30
kstyagi 283eb4ecf7 Merge pull request 'Dev' (#4) from dev into main
Reviewed-on: #4
2026-09-23 02:53:57 +00:00
kstyagi 660ba9efee feat: enhance guest network configuration and recovery process for UVM 2026-09-23 08:20:35 +05:30
kstyagi dca3cbde2f feat: add host-backed entropy device support and update kernel configuration 2026-09-23 07:00:22 +05:30
kstyagi eb6119c797 feat: add graceful shutdown endpoint for UVM API server and update documentation 2026-09-23 06:30:58 +05:30
kstyagi 0f72ac693a fix: correct formatting and improve clarity in documentation and test descriptions 2026-09-23 06:25:35 +05:30
kstyagi ed9c5306af feat: enhance networking capabilities with improved egress handling and automatic host sharing rules 2026-09-23 06:23:49 +05:30
kstyagi 534a78f2a9 feat: enhance network management and lifecycle handling with improved egress setup and validation 2026-09-23 06:23:36 +05:30
kstyagi bd92573158 feat: enhance Ubuntu rootfs build process with improved logging and error handling 2026-09-22 09:30:55 +05:30
kstyagi 1d3644ab7c feat: update UVM installation and usage documentation; enhance image handling tests 2026-09-22 00:33:34 +05:30
kstyagi 4bf4c0853c Enhance UVM installation and configuration for Ubuntu 22.04 root filesystem
- Update README and USAGE documentation to reflect changes in rootfs handling, including the default local build from Ubuntu 22.04 packages.
- Modify the installer to support building the rootfs locally with authenticated packages and to require SHA-256 checksums for prebuilt images.
- Implement checks for setuid permissions on sudo and update the provisioning logic for guest users to ensure proper sudo access.
- Adjust tests to validate the new rootfs building process and ensure that existing functionality is preserved.
- Refactor code to improve clarity and maintainability, including the introduction of constants for Ubuntu release and guest packages.
2026-09-22 00:33:05 +05:30
kstyagi 10a58daa56 Merge pull request 'feat: enhance user account management during VM creation with automatic user creation and metadata preservation' (#3) from dev into main
Reviewed-on: #3
2026-09-21 18:46:55 +00:00
kstyagi 71425a98e3 feat: enhance user account management during VM creation with automatic user creation and metadata preservation 2026-09-22 00:07:37 +05:30
kstyagi 309f28018e Merge pull request 'Dev' (#2) from dev into main
Reviewed-on: #2
2026-09-21 15:50:24 +00:00
kstyagi c75c7d9baf feat: enhance known_hosts management with deletion cleanup and SSH cache handling 2026-09-21 21:04:41 +05:30
kstyagi 871afcf454 feat: add tests for known hosts management and enhance validation in known_hosts.py 2026-09-21 20:46:34 +05:30
kstyagi 4a46685a4c feat: implement resource resizing for VMs with storage management
- Added KnownHostsManager to handle cleanup of SSH known_hosts entries upon VM deletion.
- Introduced disk_size_bytes method in ImageStore to read logical disk capacity.
- Enhanced LifecycleService to manage VM resource resizing, including CPU, RAM, and storage.
- Updated application configuration to support known_hosts_files for SSH management.
- Modified dashboard to include resource resizing options in the UI.
- Improved error handling and validation for resource inputs in the dashboard.
- Removed obsolete .pyc files from the repository.
2026-09-21 20:37:52 +05:30
kstyagi fb80ce7c8a feat: Add storage management for VM creation and resizing
- Introduced storage parameter in VmCreateRequest and VmResizeRequest models.
- Implemented storage validation to ensure minimum and maximum limits.
- Enhanced CLI to support storage options during VM creation and resizing.
- Updated lifecycle management to handle pending resize operations for VM resources.
- Added methods for resizing VM disks and validating storage capacity.
- Updated API endpoints to accommodate new storage functionality.
- Refactored related validation and configuration logic to support storage management.
2026-09-21 19:16:33 +05:30
kstyagi 99f9ffa57d Merge pull request 'Dev' (#1) from dev into main
Reviewed-on: #1
2026-09-20 03:41:01 +00:00
kstyagi 781ee753ec Refactor code structure for improved readability and maintainability 2026-09-20 09:08:45 +05:30
kstyagi b3b1b527e1 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.
2026-09-19 10:50:29 +05:30
kstyagi 3d7af4adb8 Enhance documentation and validation for VM lifecycle and network management; add tests for legacy cleanup and address validation. 2026-09-09 19:21:00 +05:30
kstyagi ee26962902 Refactor documentation structure and improve validation checks; update VM state handling and error management. 2026-09-09 17:47:50 +05:30
kstyagi d8a25209b9 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.
2026-09-09 17:27:50 +05:30
83 changed files with 22107 additions and 1672 deletions
+5
View File
@@ -0,0 +1,5 @@
.pytest_cache/
.venv/
*.egg-info/
__pycache__/
**.pyc
+894 -328
View File
File diff suppressed because it is too large Load Diff
+735
View File
@@ -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-<suffix>
|
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)
+529 -101
View File
@@ -1,11 +1,17 @@
# uvm
# Nest
`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.
Nest is a local infrastructure workspace with a browser dashboard for your
machines, network, and host health. Launch it with `nest --serve`.
This directory is a self-contained project. All UVM code, tests, packaging
metadata, launcher scripts, and documentation live here.
Its `uvm` component is a small, local Firecracker microVM manager for Ubuntu.
UVM creates one Firecracker process per VM, attaches it to a host TAP device,
and keeps a private disk and local registry for named VMs. The CLI supports
create, start, stop, restart, resize, delete, inspection, SSH, logs, diagnostics,
host-shared IPv4 internet access, and enforced private-IPv4 TCP port allowlists.
This directory is a self-contained project. Nest owns the dashboard and
workspace server; UVM provides the machine-management CLI and backend services.
All code, tests, packaging metadata, launchers, and documentation live here.
For architecture, lifecycle invariants, extension guidance, and contributor
workflows, see [DEVELOPER.md](DEVELOPER.md).
@@ -13,17 +19,22 @@ 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
./nest.py --help
python3 -B -m nest --help
./uvm.py --help
python3 -m uvm --help
python3 -B -m uvm --help
```
The `pyproject.toml` file also defines an installable `uvm` console command.
The `pyproject.toml` file defines installable `nest` and `uvm` console commands.
The project is an application, not a reusable Python SDK.
## Requirements
@@ -32,39 +43,82 @@ 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`
- An x86_64 host for the default guest assets
- Working host networking, `iproute2`, `iptables`, `e2fsprogs`, `openssl`, and
`openssh-client`
- Linux 5.3 or newer with native bridge conntrack and safe pidfd support
- An x86_64 or ARM64 (`aarch64`) host, including NVIDIA DGX Spark
- Working host networking, `iproute2`, `iptables`, `nftables`, `conntrack`,
`coreutils`, `e2fsprogs`, `util-linux`, `openssl`, and `openssh-client`
- An STP-disabled software bridge; no offload path that bypasses bridge hooks
`uvm install` installs the required host packages. It validates `/dev/kvm`
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.
Firecracker and the guest kernel are selected automatically for the host
architecture. The default rootfs is built locally from Ubuntu 22.04 (Jammy)
packages with OpenSSH, sudo, and a complete apt/dpkg database. On NVIDIA DGX
Spark, UVM uses ARM64 assets and packages.
`arm64` and `amd64` architecture names are normalized to `aarch64` and
`x86_64`, respectively. Custom `UVM_KERNEL_URL` and `UVM_ROOTFS_URL` overrides
must point to assets compatible with the host architecture.
The default guest kernel is Linux 6.1.155 from Firecracker's published `v1.15`
CI artifacts, with built-in `virtio-rng` support. UVM attaches a host-backed
entropy device before each guest boot so services such as SSH can initialize
their cryptographic random generator. Custom kernels must include
`CONFIG_HW_RANDOM=y` and `CONFIG_HW_RANDOM_VIRTIO=y` for this device.
Older installs used a Linux 4.14 ARM demo kernel and supplied no entropy
device. If SSH stays at **Starting OpenBSD Secure Shell server** while ping
works, see [SSH startup and guest entropy](USAGE.md#ssh-startup-and-guest-entropy)
for the kernel-refresh and retained-VM recovery steps.
## Trusted Installation
Installation requires trusted SHA-256 digests by default. Supply digests from
an independently trusted source before running the privileged installer:
an independently trusted source for the selected architecture before running
the privileged installer:
```sh
cd uvm
sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install
```
Use `--force` to redownload Firecracker and guest assets when replacing an
image or refreshing artifacts:
The Ubuntu build uses `debootstrap`, authenticated Ubuntu archive signatures,
and native packages. It bootstraps the base release first, then uses apt to
apply updates/security fixes and install the guest packages. UVM installs the
builder prerequisites and records the built ext4 image's SHA-256 in the
integrity manifest. Building requires root
with mount/PID namespace support and internet access to Ubuntu mirrors.
Use `--force` to refresh downloads and rebuild the Ubuntu template:
```sh
sudo env \
UVM_FIRECRACKER_SHA256='<sha256-of-firecracker-archive>' \
UVM_KERNEL_SHA256='<sha256-of-vmlinux>' \
UVM_ROOTFS_SHA256='<sha256-of-rootfs>' \
./uvm.py install --force
```
To use a prebuilt ext4 image instead, set both `UVM_ROOTFS_URL` and its trusted
`UVM_ROOTFS_SHA256`. Leave both unset for the default Ubuntu build.
### Upgrade from the Stripped Bionic Demo Image
Run `install --force` with the updated code and with `UVM_ROOTFS_URL` and
`UVM_ROOTFS_SHA256` unset, then create a new VM. The replacement template
includes sudo and the package database that the old demo image lacked.
Existing VM disks retain their original OS and credentials; installation
updates the template used for future VMs. Restart a running API server to load
the updated provisioner before creating VMs through the dashboard.
If the build fails, its detailed bootstrap/package output is retained in
`/var/lib/uvm/images/ubuntu-build.log` (mode `0600`). Correct the build failure
and rerun installation before creating a custom-user VM; a failed build leaves
the previous template installed.
For local experimentation only, the old unverified quick-start behavior is
available through an explicit opt-out:
@@ -72,92 +126,328 @@ 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`, `restart`, and `resize`
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, 30 GiB storage, root/root, and private TCP/22.
# Use a unique non-default password for anything beyond a throwaway VM.
sudo ./uvm.py create --name web
# 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 --storage 50G \
--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 '<guest-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-<id>
# 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-<id>
sudo ./uvm.py destroy vm-<id>
# 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
# Stop, change resources, and boot the same VM with its existing identity/data.
sudo ./uvm.py resize app --cpu 2 --ram 4G --storage 80G
sudo ./uvm.py resize app --ram 2G
# Preview bulk operations before confirming them.
sudo ./uvm.py stop --all --dry-run --json
sudo ./uvm.py stop --all -y
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.
```
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.
`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.
## Management API
### Storage and Resizing
New VMs default to a **30 GiB private disk**, which is also the minimum.
`--storage` accepts values such as `30G`, `50G`, or `1T`; bare numbers use GiB.
As with RAM, `G`/`GB`/`GiB` are binary units. Capacities round up to whole MiB
and are returned as `storage_mib`. Disks are sparse: the configured capacity
does not immediately reserve that much physical host space.
`resize VM` accepts any combination of `--cpu`, `--ram`, and `--storage`, plus
`--timeout` and `--json`. It stops the VM, resizes the ext4 disk when needed,
updates the resource settings, and boots it. A previously stopped VM also boots
at the end. VM ID, IP/MAC, username/password, port rules, guest files, and SSH
host keys are retained. The dashboard exposes the same operation under
**Resize resources**; the API uses `POST /vms/{identifier}/resize`.
Storage can grow or shrink, provided ext4 can fit its existing data into the
requested capacity. Disk changes are prepared on a temporary sparse copy and
published after verification. Failed resizes retain a `pending_resize` target
for retry; `start` on the stopped VM completes that target before booting.
Older VMs report `storage_mib: null` until their first resize, which retains
their current capacity or grows it to the 30 GiB minimum when storage is omitted.
Stop sends SIGTERM and, if needed, SIGKILL to the Firecracker VMM. It is **not
a guest-coordinated shutdown**. Start boots the retained disk without resetting
guest data, SSH host keys, or credentials; it does not restore guest memory.
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. `--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.
Deletion removes matching local SSH `known_hosts` entries for the guest IP and
UVM's VM-ID host-key alias before releasing the IP for reuse. It checks the
effective user's files and, under `sudo`, the invoking user's files, including
hashed entries. Unrelated hosts and file ownership/permissions are preserved.
For API services or custom SSH cache locations, set `UVM_KNOWN_HOSTS_FILES` to a
colon-separated list of absolute paths on the UVM host. See the SSH section in
[USAGE.md](USAGE.md#7-connect-with-ssh) for configuration and one-time cleanup of
entries left by older versions.
### Private Ports
```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 inbound connectivity, host-port mappings,
DNS publication, 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.
## Nest Dashboard and 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 Nest workspace 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
nest --serve --host 127.0.0.1 --port 8000
# Equivalent when running the source checkout directly.
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
./uvm.py --serve --host 127.0.0.1 --port 8000
./nest.py --serve --host 127.0.0.1 --port 8000
```
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 Nest dashboard at `/dashboard`; enter the API token
there for the current browser session.
Nest serves the dashboard and UVM-backed management endpoints together, using
the existing `UVM_API_TOKEN` and `UVM_*` host configuration. The standalone UVM
API is available through `uvm --serve`, with its reference at `/docs`; the `/`
and `/dashboard` routes belong to the Nest server.
The Nest 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, launch terminal sessions, 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 |
|---|---|---|
| `GET` | `/` | Redirect to the dashboard (Nest server) |
| `GET` | `/dashboard` | Serve the Nest workspace dashboard (Nest server) |
| `GET` | `/health` | Unauthenticated liveness response |
| `POST` | `/exit` | Gracefully stop the management server |
| `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 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`, `storage`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions |
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/start` | Boot a retained private disk |
| `POST` | `/vms/{identifier}/restart` | Stop then boot a VM; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/resize` | Stop, resize CPU/RAM/storage, and boot; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID |
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID |
| `POST` | `/vms/{vm_id}/exec` | Execute a command/script inside a running VM over vsock, with optional root privileges |
| `POST` | `/vms/{vm_id}/agent/install` | Install/upgrade the execution agent on a stopped VM's private disk |
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log |
| `GET` | `/vms/{vm_id}/sessions` | List this VM's saved sessions (Nest) |
| `POST` | `/vms/{vm_id}/sessions` | Launch a guest terminal; body: `{}` or `{"rows":24,"cols":80}` (Nest) |
| `GET` | `/vms/{vm_id}/sessions/{session_id}` | Session metadata and status (Nest) |
| `POST` | `/vms/{vm_id}/sessions/{session_id}/close` | End the shell and retain its history (Nest) |
| `WS` | `/vms/{vm_id}/sessions/{session_id}/terminal` | Authenticated terminal input, output, resize, and replay (Nest) |
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`. Create also accepts `storage`,
defaulting to `"30G"`; resize accepts optional `cpu`, `ram`, and `storage` fields.
Port changes use
`{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be
stopped and use `set` to establish a managed policy. Responses include `name`,
`ports`, `policy_status`, and `operation`, but never passwords. See
[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.
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.
### MicroVM Terminal Sessions
Open **Machines → select a microVM → Sessions**, then click **Launch session**.
Each session opens an interactive guest shell in the dashboard, with terminal
colors, keyboard input, Ctrl+C, resizing, and persistent shell state such as the
working directory. Sessions run as the VM's configured guest user; normal guest
`sudo` commands can request that user's password in the terminal.
The history list belongs to the selected VM. Reopen an active session to
continue using the same shell, or select an ended session to view its saved
output. Navigation, browser reloads, and signing out leave the shell running.
**End session** closes the shell and its processes. Stopping/restarting the VM
or the Nest server ends live sessions; their saved history remains available.
Nest stores session metadata and the latest **8 MiB of output per session** in
`$UVM_BASE/nest/sessions.sqlite3` (default `/var/lib/uvm/nest/sessions.sqlite3`).
The viewer indicates truncated output. Raw keyboard input is not logged
separately; the transcript contains what the guest terminal outputs. History
files are private to the host account running Nest. One Nest server owns
sessions for a given VM registry at a time, with up to 32 active sessions; the
guest agent shares its eight connection slots between terminals and exec calls.
**Vsock and the guest agent are configured by default for every microVM.**
Creation installs and enables the agent on every compatible image, including
cached older templates and custom images. Each stopped-disk boot also installs
or updates it automatically; an already-current installation is a read-only
check. Existing running VMs receive the setup on their next restart.
The **Sessions → Guest terminal support → Repair / update terminal support**
control is available for manual repair on a stopped VM. The guest-agent/kernel
requirements described below apply to terminals.
Terminal support adds the Python `websockets` dependency. In your micromamba
environment, install the current project and launch with its interpreter:
```sh
micromamba activate nest
python -m pip install -e '.[test]'
sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \
"$CONDA_PREFIX/bin/python" -m nest --serve
```
### Execute Commands Without SSH
Use an exact VM ID from create/list:
```sh
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"command":"whoami","sudo":true,"timeout_seconds":60}' \
"http://127.0.0.1:8000/vms/<vm-id>/exec"
```
The response contains `exit_code`, `stdout`, `stderr`, `timed_out`, and
`truncated`. Commands and multiline scripts run through `/bin/sh` inside the
guest. `sudo: false` (the default) uses the VM's configured username; `true`
uses guest root without a password prompt. A VM configured with username `root`
still runs as root when `sudo` is false.
Execution uses a guest systemd agent over Firecracker vsock. It works with SSH
disabled and with an empty TCP allowlist. Every Firecracker boot attaches the
vsock device, and creation/start/restart/resize ensure the guest service is
installed and enabled before boot. Restart the management server to load this
behavior, then restart older VMs once to apply it automatically.
Installation stages and verifies a private disk copy when changes are needed,
preserving guest data and credentials. If Python is missing, the installed
bootstrap service acquires it inside the guest on boot; this one-time step needs
guest internet access. The default Ubuntu template already includes Python.
The guest needs Python 3.10+, systemd with cgroup v2, and a vsock-capable Linux
5.14+ kernel; UVM's default Linux 6.1 kernel provides the required drivers.
See [execution examples and limits](USAGE.md#execute-commands-without-ssh) for
programmatic calls, local script submission, timeout behavior, and upgrade steps.
### 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:
@@ -169,7 +459,7 @@ 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 8000
./nest.py --serve --host 0.0.0.0 --port 8000
```
Send the token on management requests:
@@ -180,6 +470,18 @@ curl \
http://127.0.0.1:8000/vms
```
Stop the API server without stopping its managed VMs:
```sh
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
http://127.0.0.1:8000/exit
```
The endpoint returns `202 {"status":"stopping"}` and then gracefully terminates
the serving Nest or UVM process. It is a protected management endpoint; it does
not stop or delete managed VMs.
Do not expose this server directly to an untrusted network. For remote access,
keep TLS enabled and consider placing it behind an additional authenticated
reverse proxy.
@@ -190,15 +492,51 @@ The installed kernel and rootfs are templates. `create` copies the rootfs to
the VM runtime directory before Firecracker opens it read-write, so VMs do not
share a mutable root disk.
During creation, UVM updates the private ext4 disk with a password for an
existing guest account and enables OpenSSH password authentication. The
During creation, UVM updates the private ext4 disk with a password for the
requested guest account and enables OpenSSH password authentication. Missing
custom usernames are created with an unused regular-user UID, a primary group,
a private `/home/USERNAME` directory, and `/bin/sh` as their login shell. The
defaults are username `root` and password `root`. A custom image must provide:
- `sshd`
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
- The requested user account; UVM does not create missing users
- `/etc/group` and `/bin/sh` when creating a new user
- A root-owned, setuid `/usr/bin/sudo` and `/etc/sudoers` for custom login users
- systemd and apt for the automatically installed vsock guest agent (Ubuntu 22.04+)
The known public demo key bundled in Firecracker's default bionic image is
Custom login users receive password-authenticated sudo access. Inside a new VM:
```sh
sudo -i # Enter the same password used for this user's SSH login.
sudo apt update
sudo apt install htop
```
`su -` asks for root's separate password; creating a custom login does not set
root's password. Use `sudo -i` for an administrative shell. The default Ubuntu
template retains apt's package metadata and enables `main` and `universe`,
including updates/security repositories. Guest DNS defaults to `1.1.1.1` and
`8.8.8.8` in `/etc/resolv.conf`.
UVM specializes the Ubuntu guest's `/etc/systemd/network/10-uvm.network` with
its assigned `Address=` and `Gateway=` before boot. `KeepConfiguration=yes`
also retains the kernel-created default route while systemd-networkd starts.
Older templates used `KeepConfiguration=static`, which could retain the guest
IP but remove the default route, producing `ping: connect: Network is unreachable`.
The updated CLI repairs an older VM's original, unedited UVM profile on its
next stopped-disk boot, including `restart`. The repair is prepared and checked
on a sparse copy before publication; guest-edited profiles and custom images
are preserved. New VMs also receive the fix when using a cached older Ubuntu
template. See [guest route recovery](USAGE.md#guest-reports-network-is-unreachable)
for immediate recovery inside a running guest.
Existing accounts retain their UID, groups, home directory, and shell. A new
user reuses a same-named group if present; otherwise UVM creates one with an
unused GID. `/etc/gshadow` is updated when present. The default `root` account
must already exist in the image.
The known public demo key bundled in Firecracker's older Bionic image is
removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
and Ed25519 SSH host keys in every private disk so cloned VMs have distinct
host identities. Never rely on SSH private keys baked into a shared template.
@@ -214,10 +552,57 @@ Bridge: uvm0
TAPs: uvm-<unique suffix>
```
It creates the bridge when absent, validates an existing bridge before using
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.
VMs share the host's IPv4 internet connection by default. When the host has
internet access, guests can initiate outbound connections through it. When
the host loses connectivity, guest internet access is lost too; the private
VM network remains usable. Reconnecting the host restores guest egress.
UVM creates the bridge when absent, validates an existing bridge before using
it, enables IPv4 forwarding, and installs subnet-scoped `iptables` masquerade
and forward/return rules. These rules are installed even while the host is
offline and follow its current routing table, so switching uplinks does not
require restarting VMs. Existing connections may need application retries
after an outage or upstream address change. Guest IPs, gateway, bridge name,
and guest boot netmask derive from the configured CIDR.
Forwarding exceptions are inserted ahead of existing host-chain rules and
allow guest-originated traffic and its replies. Masquerading excludes traffic
back onto the UVM bridge, preserving private peer addresses.
To apply updated sharing rules to an existing running VM, use:
```sh
sudo ./uvm.py start web
```
On a running VM, `start` refreshes its private policy and shared host networking
without rebooting it. New VM boots set up the same networking automatically.
For an older guest missing its default route, use `restart` once to apply the
offline guest-profile repair, or follow the guest route recovery steps above.
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
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 private policy, inspect its status and explicitly run `ports sync`.
Use `start` to restore shared bridge/NAT/forwarding rules as well. Read-only
commands do not repair rules; port 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
@@ -232,87 +617,130 @@ 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 `3`. Writes
are locked and atomic. Lifecycle, port-policy, and installation mutations
share an operation lock; bulk operations use a fixed snapshot of VM IDs.
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
replace the default `root` password immediately.
Credential provisioning applies only to VMs created by this version. Existing
VM disks are not modified automatically.
guest accounts, passwords, and SSH host keys are retained on subsequent boots.
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.
Schema 3 adds disk capacity and durable resize targets. Older registries migrate
on the next write; a recorded pending resize is completed before a stopped VM
can boot. A boot failure after a successful resize retains the new allocation
for a later start.
## Configuration
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 |
| `UVM_ROOTFS_URL` | Guest rootfs download URL |
| `UVM_KNOWN_HOSTS_FILES` | Override local SSH cache files cleaned on deletion; colon-separated absolute paths |
| `UVM_KERNEL_URL` | Override the host-architecture default guest kernel URL |
| `UVM_ROOTFS_URL` | Optional prebuilt ext4 image URL; unset to build Ubuntu 22.04 locally |
| `UVM_FIRECRACKER_SHA256` | Trusted Firecracker release archive SHA-256 |
| `UVM_KERNEL_SHA256` | Trusted guest kernel SHA-256 |
| `UVM_ROOTFS_SHA256` | Trusted guest rootfs SHA-256 |
| `UVM_ROOTFS_SHA256` | Trusted prebuilt rootfs SHA-256; use with `UVM_ROOTFS_URL` |
| `UVM_FIRECRACKER_BINARY_SHA256` | Optional trusted local Firecracker binary SHA-256 |
| `UVM_ALLOW_UNVERIFIED_DOWNLOADS` | Set to `1` only for unverified local development |
| `UVM_API_TOKEN` | Required for every API server; sent as `X-UVM-Token` |
| `UVM_API_TOKEN` | Required for Nest and standalone UVM servers; sent as `X-UVM-Token` |
| `UVM_API_TLS_CERT` | TLS certificate path required for a non-loopback API bind |
| `UVM_API_TLS_KEY` | TLS private-key path required for a non-loopback API bind |
## Project Layout
Selected files:
```text
uvm/
./
├── README.md
├── USAGE.md
├── DEVELOPER.md
├── pyproject.toml
├── uvm.py # Executable compatibility launcher
├── uvm/ # Private application implementation
├── nest.py # Executable Nest workspace launcher
├── nest/ # Nest workspace application
│ ├── cli.py # nest --serve entry point
│ ├── server.py # Workspace server, composing the UVM management API
│ └── dashboard/ # Nest document route and local HTML, CSS, JS, and fonts
├── uvm.py # Executable UVM compatibility launcher
├── uvm/ # Machine-management 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
│ ├── routers/ # UVM management API routes
│ └── 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, lifecycle rollback, dashboard delivery,
and API routes. API route tests run when the FastAPI and `httpx` test
dependencies are installed.
When `nft` and `conntrack` are installed, the suite also checks their command
parsers using deliberately invalid final commands/options that prevent mutations.
With e2fsprogs installed, offline disk tests grow and shrink temporary sparse ext4
images and check that data and credential/key fixtures survive; no VM is booted.
Opt-in packet checks run **only in disposable isolated user/network
namespaces**, which may require privileges and the installed network tools:
```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 simulated upstream
networks to check NAT, offline startup, reconnects, and uplink changes alongside
private port policies. It does not boot a real VM; validate KVM, TAP, 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 application health 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.
+1170 -339
View File
File diff suppressed because it is too large Load Diff
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Source-tree launcher for the Nest workspace."""
from nest.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Nest workspace application."""
+7
View File
@@ -0,0 +1,7 @@
"""Run the Nest workspace with ``python -m nest``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+44
View File
@@ -0,0 +1,44 @@
"""Command-line entry point for the Nest workspace server."""
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
from uvm.app import build_application
from uvm.errors import UvmError
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="nest",
description="Nest dashboard and local machine management workspace.",
allow_abbrev=False,
)
parser.add_argument("--serve", action="store_true", help="launch the Nest workspace server")
parser.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not args.serve:
parser.error("--serve is required to launch the Nest workspace")
if not 1 <= args.port <= 65535:
parser.error("--port must be between 1 and 65535")
try:
from .server import run_server
application = build_application(emit=lambda message: print(message, file=sys.stderr))
run_server(application, host=args.host, port=args.port)
return 0
except UvmError as error:
print(f"nest: error: {error}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("nest: interrupted", file=sys.stderr)
return 130
+1
View File
@@ -0,0 +1 @@
"""Packaged frontend assets for the Nest workspace."""
+17
View File
@@ -0,0 +1,17 @@
"""Nest dashboard document route."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
_DASHBOARD_HTML = Path(__file__).parent / "static" / "index.html"
@router.get("", response_class=HTMLResponse)
def get_dashboard() -> str:
return _DASHBOARD_HTML.read_text(encoding="utf-8")
+944
View File
@@ -0,0 +1,944 @@
@font-face {
font-family: Manrope;
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-latin.woff2') format('woff2');
}
:root {
--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);
}
*, *::before, *::after { box-sizing: border-box; }
html { min-height: 100%; background: #dad8e0; }
body {
margin: 0;
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, .resources-panel { margin-top: 15px; }
.storage-field { margin-top: 18px; }
.port-editor { padding-top: 5px; }
.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; }
/* 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; }
}
@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; }
}
@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%; }
}
@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; }
.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; }
}
@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, .resources-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child, .resources-panel > .section-heading > div:first-child { flex: 1; }
.auth-screen { padding: 20px; }
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
.auth-story { min-height: 240px; padding: 26px; border-right: 0; }
.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; }
}
+54
View File
@@ -0,0 +1,54 @@
.machine-tabs { display: flex; gap: 8px; margin: 0 0 24px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.machine-tab { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 22px; color: var(--muted); font-size: 11px; }
.machine-tab.active { background: var(--white); color: var(--ink); box-shadow: var(--shadow-card); }
.machine-tab .icon { width: 14px; height: 14px; }
.session-section-heading { margin-bottom: 14px; }
.session-workspace { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: 16px; align-items: stretch; }
.session-history { padding: 14px 10px; min-width: 0; display: flex; flex-direction: column; }
.session-history > header { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 10px; }
.session-history h3 { font-size: 11px; }
.session-history > .panel-footnote { margin-top: auto; padding: 15px 8px 0; }
.session-list { display: grid; gap: 6px; max-height: 510px; overflow-y: auto; }
.session-list-empty { padding: 24px 9px; color: var(--muted); font-size: 10px; line-height: 1.8; }
.session-item { display: grid; grid-template-columns: 1fr auto; gap: 7px; padding: 12px 10px; border: 1px solid transparent; border-radius: 12px; color: var(--ink-soft); }
.session-item:hover { background: #fff8; }
.session-item.active { background: var(--white); border-color: #d9d0e4; }
.session-item strong { overflow-wrap: anywhere; font-size: 11px; }
.session-item small { grid-column: 1 / -1; color: var(--muted); font-size: 9px; }
.session-state { align-self: center; width: 6px; height: 6px; border-radius: 50%; background: var(--lilac); }
.session-state.active { background: var(--sage); box-shadow: 0 0 0 3px var(--sage-soft); }
.session-state.failed, .session-state.interrupted { background: var(--peach); }
.terminal-panel { min-width: 0; overflow: hidden; border-radius: 17px; background: #25222d; color: #e8e3ef; box-shadow: var(--shadow-card); }
.terminal-toolbar { min-height: 56px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #ffffff12; background: #302c39; }
.terminal-toolbar > div:first-child { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.terminal-toolbar strong { font-size: 11px; }
.terminal-status { color: #b8afc6; font-size: 9px; }
.terminal-toolbar .button { color: #e8e3ef; background: #ffffff0c; border-color: #ffffff17; }
.terminal-toolbar .button:hover { background: #ffffff19; }
.terminal-lights { display: inline-flex; gap: 4px; margin-right: 3px; }
.terminal-lights i { height: 6px; width: 6px; border-radius: 50%; background: var(--peach); }
.terminal-lights i:nth-child(2) { background: var(--yellow); }
.terminal-lights i:nth-child(3) { background: var(--sage); }
.session-terminal { height: 480px; padding: 14px 12px 10px; overflow: hidden; }
.session-terminal .xterm { height: 100%; }
.session-terminal .xterm-viewport { scrollbar-width: thin; scrollbar-color: #766a88 #25222d; }
.terminal-blank { min-height: 480px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 17px; text-align: center; padding: 24px; }
.terminal-blank > .icon { width: 37px; height: 37px; color: #b3a5ca; }
.terminal-blank h3 { font-size: 20px; font-weight: 450; }
.terminal-blank p { color: #b2a9bf; font-size: 11px; line-height: 1.8; }
.terminal-footer { min-height: 43px; padding: 11px 16px; color: #b5abc4; font-size: 9px; border-top: 1px solid #ffffff0d; overflow-wrap: anywhere; }
.terminal-support { margin-top: 18px; padding: 15px 19px; font-size: 10px; color: var(--muted); }
.terminal-support summary { cursor: pointer; color: var(--ink-soft); }
.terminal-support p { margin: 12px 0; max-width: 680px; line-height: 1.8; }
.terminal-support .muted { margin-left: 12px; }
@media (max-width: 1100px) {
.session-workspace { grid-template-columns: minmax(0, 1fr); }
.session-list { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); max-height: 190px; }
.session-history > .panel-footnote { display: none; }
}
@media (max-width: 700px) {
.session-terminal { height: 400px; }
.terminal-blank { min-height: 330px; }
.session-section-heading { flex-wrap: wrap; }
.terminal-support .muted { display: block; margin: 10px 0 0; }
}
+92
View File
@@ -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.
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#e8e7ed" />
<meta name="color-scheme" content="light" />
<title>Nest</title>
<meta name="description" content="Nest — a local workspace for your machines, network, and infrastructure." />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 22V10l14 12V10' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/dashboard/static/vendor/xterm/css/xterm.css?v=5.5.0" />
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=3" />
<link rel="stylesheet" href="/dashboard/static/css/sessions.css?v=1" />
<script src="/dashboard/static/vendor/xterm/lib/xterm.js?v=5.5.0" defer></script>
<script src="/dashboard/static/vendor/xterm-fit/lib/addon-fit.js?v=0.10.0" defer></script>
<script src="/dashboard/static/js/sessions.js?v=2" defer></script>
<script src="/dashboard/static/js/dashboard.js?v=4" defer></script>
</head>
<body>
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="6" height="6" rx="1.7"/><rect x="14.5" y="3.5" width="6" height="6" rx="1.7"/><rect x="3.5" y="14.5" width="6" height="6" rx="1.7"/><rect x="14.5" y="14.5" width="6" height="6" rx="1.7"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01M15 7h3M15 17h3"/></symbol>
<symbol id="i-network" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.5"/><rect x="2" y="16" width="6" height="5" rx="1.5"/><rect x="16" y="16" width="6" height="5" rx="1.5"/><path d="M12 8v4M5 16v-4h14v4"/></symbol>
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M20.8 5.8a5.2 5.2 0 0 0-7.4 0L12 7.2l-1.4-1.4a5.2 5.2 0 0 0-7.4 7.4L12 22l8.8-8.8a5.2 5.2 0 0 0 0-7.4Z" transform="translate(0 -2) scale(1 0.95)"/><path d="M5 11h3l2-3 3 7 2-4h4"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5 5 5-5 5"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M19 12H5m5-5-5 5 5 5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 10a8 8 0 0 0-14-4L3 9m0-6v6h6M4 14a8 8 0 0 0 14 4l3-3m-6 0h6v6"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M12 5v15M12 5C8 2 3 4 3 4v15s5-2 9 1c4-3 9-1 9-1V4s-5-2-9 1Z"/></symbol>
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M10 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5M8 12h13m-5-5 5 5-5 5"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></symbol>
<symbol id="i-spark" viewBox="0 0 24 24"><path d="m12 3 2.7 6.3L21 12l-6.3 2.7L12 21l-2.7-6.3L3 12l6.3-2.7L12 3Z"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"/><path d="m8 12 3 3 5-6"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3"/></symbol>
<symbol id="i-memory" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M7 10h3v4H7zm7 0h3v4h-3zM7 18v3m5-3v3m5-3v3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 4 12 8-12 8V4Z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 9 3 3-3 3m6 0h4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M4 16v4h16v-4"/></symbol>
</svg>
<section id="auth-screen" class="auth-screen">
<div class="auth-window">
<div class="auth-story">
<a href="#overview" class="brand" aria-label="Nest workspace"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your workspace.<br />Right at home.</h1><p>A home for your machines,<br />your network, and your next idea.</p></div>
<div class="auth-sculpture" aria-hidden="true">
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
<div class="sculpture-card sculpture-middle"><span></span><i></i><i></i><i></i></div>
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
</div>
<span class="auth-story-footer">Your infrastructure, thoughtfully managed.</span>
</div>
<main class="auth-panel">
<div class="auth-panel-inner">
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
<span class="subtle-label">Your local workspace</span>
<h2>Welcome to Nest</h2>
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
<form id="auth-form">
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
<p id="auth-error" class="form-error" role="alert"></p>
<button id="auth-btn" class="button button-primary button-wide" type="submit"><span>Open workspace</span><svg class="icon"><use href="#i-arrow"/></svg></button>
</form>
<p class="auth-note"><svg class="icon"><use href="#i-shield"/></svg>Saved only for this browser session.</p>
</div>
<a class="quiet-link auth-help" href="/docs" target="_blank" rel="noopener">Explore the API <svg class="icon"><use href="#i-arrow"/></svg></a>
</main>
</div>
</section>
<section id="main-app" class="app-shell" hidden>
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
<div class="sidebar">
<a class="brand" href="#overview" aria-label="Nest workspace overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>nest<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Workspace</span><span id="sidebar-subtitle">Overview</span></h2></div>
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
<nav class="primary-nav" aria-label="Primary">
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
<a class="nav-item" href="#vms" data-nav="vms"><svg class="icon"><use href="#i-server"/></svg><span>Machines</span><span class="nav-count" id="nav-vm-count">—</span></a>
<a class="nav-item" href="#network" data-nav="network"><svg class="icon"><use href="#i-network"/></svg><span>Network & ports</span><span class="nav-indicator"></span></a>
<a class="nav-item" href="#system" data-nav="system"><svg class="icon"><use href="#i-heart"/></svg><span>Host health</span><span class="nav-health-dot" id="nav-health-dot"></span></a>
<a class="nav-item nav-create" href="#create" data-nav="create"><svg class="icon"><use href="#i-plus"/></svg><span>New machine</span><svg class="icon nav-end-icon"><use href="#i-plus"/></svg></a>
</nav>
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
<div class="sidebar-bottom">
<div class="workspace-identity"><span class="workspace-avatar">n</span><div><strong>Local Nest workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
</div>
</div>
<div class="context-rail" aria-label="Workspace at a glance">
<a href="#system" class="insight-card">
<div class="card-caption"><span>Workspace insights</span><svg class="icon"><use href="#i-spark"/></svg></div>
<p id="insight-copy">A fresh perspective on your local compute.</p>
<div class="insight-matrix" aria-hidden="true"><span></span></div>
<span class="insight-footer"><span class="dot dot-yellow"></span><span id="insight-footer">Looking at the details</span></span>
</a>
<a href="#vms" class="collection collection-machines">
<div class="collection-art machine-art" aria-hidden="true"><div class="paper-machine back-paper"><span></span><i></i><i></i></div><div class="paper-machine front-paper"><svg class="icon"><use href="#i-server"/></svg><i></i><i></i><span></span></div></div>
<div class="collection-label"><div><strong>Machines</strong><span id="rail-machines">Your private fleet</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
</a>
<a href="#network" class="collection collection-network">
<div class="collection-art network-art" aria-hidden="true"><div class="network-paper"><span class="network-line"></span><i></i><i></i><i></i><svg class="icon"><use href="#i-network"/></svg></div></div>
<div class="collection-label"><div><strong>Network</strong><span id="rail-network">Connections, considered</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
</a>
<a href="#system" class="collection collection-simple"><div class="collection-label"><div><strong>Host health</strong><span id="rail-health">Checking the essentials</span></div><svg class="icon"><use href="#i-chevron"/></svg></div></a>
<a href="#create" class="collection collection-simple collection-new"><div class="collection-label"><div><strong>Something new?</strong><span>Make room for an idea</span></div><svg class="icon"><use href="#i-plus"/></svg></div></a>
<span class="rail-footnote">A small cloud.<br />Entirely yours.</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Nest</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
<div class="global-search">
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
<div id="search-results" class="search-results" role="listbox" aria-label="Search results" hidden></div>
</div>
<a href="#create" class="icon-button topbar-create" aria-label="Create a new machine" title="Create a new machine"><svg class="icon"><use href="#i-plus"/></svg></a>
</header>
<main id="main-content" class="page-content">
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Nest overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
<div class="overview-metrics">
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
<article class="metric-card resources-metric"><div class="card-caption"><h2>Resource allocation</h2><svg class="icon caption-icon"><use href="#i-cpu"/></svg></div><div class="resource-line"><span id="metric-cpu">— vCPU</span><small id="cpu-active-label">Allocated</small></div><div class="resource-track"><span id="cpu-allocation-bar"></span></div><div class="resource-line second-resource"><span id="metric-ram">— MiB</span><small id="ram-active-label">Allocated</small></div><div class="resource-track memory-track"><span id="ram-allocation-bar"></span></div></article>
</div>
<section class="portfolio-section" aria-labelledby="portfolio-heading"><header class="section-heading"><div><h2 id="portfolio-heading">Machine portfolio</h2><span>A place for every workload.</span></div><a class="quiet-link" href="#vms">View all <svg class="icon"><use href="#i-arrow"/></svg></a></header><div id="overview-machines" class="portfolio-content"><div class="loading-state">Bringing your machines into view…</div></div></section>
<div class="overview-bottom">
<article class="activity-panel soft-panel"><header class="section-heading"><div><h2>Fleet activity</h2><span>A snapshot of your machine states.</span></div><svg class="icon caption-icon"><use href="#i-grid"/></svg></header><div class="activity-body"><div class="fleet-ring" role="img" id="fleet-ring" aria-label="Waiting for machine activity"><svg viewBox="0 0 200 200" aria-hidden="true"><circle class="ring-track" cx="100" cy="100" r="82"/><circle id="ring-running" class="ring-segment ring-running" cx="100" cy="100" r="82" pathLength="100"/><circle id="ring-stopped" class="ring-segment ring-stopped" cx="100" cy="100" r="82" pathLength="100"/><circle id="ring-other" class="ring-segment ring-other" cx="100" cy="100" r="82" pathLength="100"/></svg><div class="ring-center"><span>Running now</span><strong id="ring-percent">—<small>%</small></strong><span id="ring-total">Your fleet, in balance</span></div></div><div class="ring-legend"><div><i class="dot dot-yellow"></i><span>Running<small>Doing their thing</small></span><strong id="legend-running">—</strong></div><div><i class="dot dot-peach"></i><span>Stopped<small>Taking a breather</small></span><strong id="legend-stopped">—</strong></div><div><i class="dot dot-lilac"></i><span>Other states<small>A closer look</small></span><strong id="legend-other">—</strong></div></div></div></article>
<article class="notes-panel soft-panel"><header class="section-heading"><div><h2>Worth a little attention</h2><span>The useful details, together.</span></div><svg class="icon caption-icon"><use href="#i-spark"/></svg></header><div id="workspace-notes" class="workspace-notes"></div><a href="#system" class="notes-footer quiet-link">A closer look at your host <svg class="icon"><use href="#i-arrow"/></svg></a></article>
</div>
<footer class="page-footer"><span><i class="dot" id="footer-dot"></i>Live from your local control plane</span><span id="last-updated">Waiting for an update</span></footer>
</section>
<section id="page-vms" class="page" aria-labelledby="machines-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">A collection of possibilities</span><h1 id="machines-heading" tabindex="-1">Your machines<span class="heading-period">.</span></h1></div><a class="button button-primary" href="#create"><svg class="icon"><use href="#i-plus"/></svg>New machine</a></div>
<div id="fleet-summary" class="compact-metrics"></div>
<div class="fleet-toolbar"><div class="filter-tabs" role="group" aria-label="Filter machines by state"><button type="button" class="filter-tab active" data-filter="all" aria-pressed="true">All machines</button><button type="button" class="filter-tab" data-filter="running" aria-pressed="false">Running</button><button type="button" class="filter-tab" data-filter="stopped" aria-pressed="false">Stopped</button><button type="button" class="filter-tab" data-filter="attention" aria-pressed="false">Attention</button></div><label class="select-wrap"><span class="sr-only">Sort machines</span><select id="vm-sort"><option value="recent">Newest first</option><option value="name">Name A–Z</option><option value="status">Machine state</option></select></label></div>
<div class="fleet-list-panel"><div class="list-search-row"><label class="search-field"><svg class="icon"><use href="#i-search"/></svg><input id="vm-search" type="search" placeholder="Find by name, ID, or address" aria-label="Search machines" /></label><span id="visible-count" class="muted"></span></div><div id="bulk-controls" class="bulk-controls" hidden><span id="bulk-count"></span><label class="select-wrap"><span class="sr-only">Bulk action</span><select id="bulk-action"><option value="start">Start machines</option><option value="stop">Stop machines</option><option value="restart">Restart machines</option><option value="ports-sync">Sync port policies</option><option value="delete">Delete machines</option></select></label><button id="bulk-run-btn" class="button button-small button-primary" type="button">Apply</button><button class="icon-button" type="button" data-action="clear-selection" aria-label="Clear selection"><svg class="icon"><use href="#i-close"/></svg></button></div><div id="machine-table-wrap" class="table-scroll"><table class="machine-table"><thead><tr><th class="check-cell"><input id="vm-select-all" type="checkbox" aria-label="Select all visible machines" /></th><th>Machine</th><th>State</th><th>Allocation</th><th>Ingress</th><th>Policy</th><th class="row-controls-heading"><span class="sr-only">Actions</span></th></tr></thead><tbody id="machine-rows"></tbody></table></div><div id="machines-empty" hidden></div><div class="list-footer"><span id="fleet-list-caption">Your private machine registry.</span><span>Made for a small footprint.</span></div></div>
<div id="bulk-results" class="operation-results" aria-live="polite" hidden></div>
</section>
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
<div id="network-summary" class="compact-metrics"></div>
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>Nest control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
</section>
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
<a class="quiet-link back-link" href="#vms"><svg class="icon"><use href="#i-back"/></svg>Back to machines</a>
<div id="detail-loading" class="loading-state">Getting to know this machine…</div>
<div id="detail-content" hidden>
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div>
<nav class="machine-tabs" aria-label="Selected machine views">
<a id="vm-overview-tab" class="machine-tab" href="#vms">Overview</a>
<a id="vm-sessions-tab" class="machine-tab" href="#vms"><svg class="icon"><use href="#i-terminal"/></svg>Sessions <span id="vm-session-count" class="nav-count">—</span></a>
</nav>
<div id="vm-overview-panel">
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel resources-panel">
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
<p id="resize-note" class="panel-footnote">Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.</p>
<form id="resize-form" hidden>
<div class="form-grid"><label class="field" for="resize-cpu"><span>CPU capacity</span><input id="resize-cpu" type="number" min="0.01" step="any" placeholder="Keep current CPU" /></label><label class="field" for="resize-ram"><span>Memory</span><input id="resize-ram" type="text" placeholder="Keep current memory" /><small>At least 128 MiB. For example, 4G.</small></label></div>
<label class="field storage-field" for="resize-storage"><span>Private storage</span><input id="resize-storage" type="text" placeholder="Keep current size (at least 30G)" /><small>Minimum 30 GiB. A smaller disk must still fit the existing filesystem data.</small></label>
<p id="resize-error" class="form-error" role="alert"></p>
<div class="button-row"><button id="resize-submit" class="button button-primary button-small" type="submit">Resize &amp; restart</button><button class="button button-quiet button-small" type="button" data-action="cancel-resize">Cancel</button></div>
</form>
</article>
<article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article>
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
</div>
<section id="vm-sessions-panel" aria-label="Selected machine sessions" hidden>
<header class="section-heading session-section-heading"><div><h2>Sessions</h2><span>A shell for each task. A history for this machine.</span></div><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></header>
<p id="sessions-error" class="form-error" role="alert"></p>
<div class="session-workspace">
<aside class="session-history soft-panel" aria-label="Session history">
<header><h3>Session history</h3><button id="sessions-refresh" class="icon-button" type="button" aria-label="Refresh sessions"><svg class="icon"><use href="#i-refresh"/></svg></button></header>
<div id="session-list" class="session-list"></div>
<p class="panel-footnote">Saved for this microVM.</p>
</aside>
<article class="terminal-panel">
<header class="terminal-toolbar"><div><span class="terminal-lights" aria-hidden="true"><i></i><i></i><i></i></span><strong id="session-title">Terminal</strong><span id="session-status" class="terminal-status">Select a session</span></div><div class="button-row"><button id="session-reconnect" class="button button-small" type="button" hidden>Reconnect</button><button id="session-end" class="button button-small" type="button" disabled>End session</button></div></header>
<div id="session-blank" class="terminal-blank"><svg class="icon"><use href="#i-terminal"/></svg><h3>A little room to work.</h3><p>Select a saved session, or launch a fresh terminal<br />inside this microVM.</p><button class="button button-primary" type="button" data-action="launch-session"><svg class="icon"><use href="#i-plus"/></svg><span>Launch session</span></button></div>
<div id="session-terminal" class="session-terminal" aria-label="MicroVM terminal" hidden></div>
<footer id="session-meta" class="terminal-footer">Your sessions and terminal output are saved on the host.</footer>
</article>
</div>
<details id="terminal-support" class="terminal-support soft-panel">
<summary>Guest terminal support</summary>
<p>Vsock and the guest agent are set up automatically when a microVM is created or started. Restart an older VM once to apply the setup. Manual repair is available here while the machine is stopped.</p>
<button id="session-install-agent" class="button button-small" type="button">Repair / update terminal support</button>
<span id="session-install-hint" class="muted"></span>
</details>
</section>
</div>
</section>
<section id="page-create" class="page" aria-labelledby="create-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div>
<form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory and sudo access using their guest password. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
</form>
</section>
<section id="page-system" class="page" aria-labelledby="system-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
<div id="health-summary" class="compact-metrics"></div>
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install Firecracker, a guest kernel, and a root filesystem. The default Ubuntu 22.04 image includes SSH, sudo, and apt.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Refresh downloads and rebuild the Ubuntu image.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
</section>
</main>
</div>
</section>
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
<noscript><p class="noscript-message">Enable JavaScript to connect to your Nest workspace.</p></noscript>
</body>
</html>
File diff suppressed because it is too large Load Diff
+363
View File
@@ -0,0 +1,363 @@
(function () {
'use strict';
const $ = (selector) => document.querySelector(selector);
const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`;
const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`;
const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
const encoded = (data) => btoa(String.fromCharCode(...data));
window.NestSessions = class {
constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) {
Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction });
this.vm = null;
this.records = [];
this.current = null;
this.selectedId = null;
this.visible = false;
this.sequence = 0;
this.generation = 0;
this.launching = new Set();
this.upgrading = new Set();
this.offset = 0;
this.renderPending = Promise.resolve();
$('#sessions-refresh').addEventListener('click', () => this.refresh());
$('#session-end').addEventListener('click', () => this.end());
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
$('#session-install-agent').addEventListener('click', () => this.installAgent());
}
sync(vm, selectedId, visible) {
if (this.vm?.id !== vm.id) {
this.leave();
this.vm = vm;
$('#vm-session-count').textContent = '—';
}
this.vm = vm;
if (this.selectedId !== selectedId || this.visible !== visible) {
this.detachTerminal();
this.showError('');
}
this.selectedId = selectedId;
this.visible = visible;
this.updateControls();
if (this.pendingError?.vmId === vm.id && visible) {
this.showError(this.pendingError.message);
$('#terminal-support').open = this.pendingError.setup;
this.pendingError = null;
}
this.renderList();
this.openSelected();
if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh();
}
showError(message) {
$('#sessions-error').textContent = message;
}
updateControls() {
const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running'
&& !this.vm.operation && !this.vm.pending_resize;
document.querySelectorAll('[data-action="launch-session"]').forEach((button) => {
button.disabled = !running || this.launching.has(this.vm?.id);
button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session';
button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session';
});
const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
&& !this.vm.operation && !this.vm.pending_resize;
$('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id);
$('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Repair / update terminal support';
$('#session-install-hint').textContent = stopped ? 'Setup runs automatically at startup. Manual repair is available now.' : 'Vsock is set up automatically on boot. Restart to apply updates.';
this.renderStatus();
}
renderList() {
const markup = this.records.length ? this.records.map((record) => `
<a class="session-item${record.id === this.selectedId ? ' active' : ''}" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
</a>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
}
async refresh() {
if (!this.vm) return;
const vmId = this.vm.id;
const sequence = ++this.sequence;
this.loading = true;
$('#sessions-refresh').disabled = true;
try {
const records = await this.api(path(vmId));
if (sequence !== this.sequence || this.vm?.id !== vmId) return;
if (!Array.isArray(records)) throw new Error('The session history response was not recognized.');
this.records = records;
if (this.current) {
const latest = records.find((record) => record.id === this.current.id);
if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
}
this.lastRefresh = Date.now();
$('#vm-session-count').textContent = records.length;
this.openSelected();
this.renderStatus();
} catch (error) {
if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message);
} finally {
if (sequence === this.sequence) {
this.loading = false;
$('#sessions-refresh').disabled = false;
this.renderList();
}
}
}
async launch(vm) {
if (!vm || this.launching.has(vm.id)) return;
const token = this.getToken();
this.launching.add(vm.id);
this.showError('');
this.updateControls();
try {
const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } });
if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id);
} catch (error) {
if (token === this.getToken() && error.name !== 'AbortError') {
this.toast(error.message, true);
if (this.isSelectedVm(vm.id)) {
this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 };
location.hash = href(vm.id);
if (this.visible) this.sync(vm, null, true);
}
}
} finally {
this.launching.delete(vm.id);
this.lastRefresh = 0;
this.updateControls();
}
}
async installAgent() {
const vm = this.vm;
if (!vm || this.upgrading.has(vm.id)) return;
this.upgrading.add(vm.id);
this.showError('');
this.updateControls();
try {
await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' });
if (this.vm?.id === vm.id) {
this.toast('Terminal support updated. Start this machine, then launch a session.');
$('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.';
}
} catch (error) {
if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message);
} finally {
this.upgrading.delete(vm.id);
this.updateControls();
}
}
openSelected() {
if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return;
const record = this.records.find((item) => item.id === this.selectedId);
if (!record) {
if (this.lastRefresh) this.showError('This session was not found for the selected machine.');
return;
}
this.showError('');
this.detachTerminal();
this.current = record;
$('#session-blank').hidden = true;
$('#session-terminal').hidden = false;
this.term = new window.Terminal({
cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
scrollback: 10000, disableStdin: true,
theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' },
});
this.fit = new window.FitAddon.FitAddon();
this.term.loadAddon(this.fit);
this.term.open($('#session-terminal'));
this.term.onData((text) => this.input(new TextEncoder().encode(text)));
this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0))));
this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols));
this.observer = new ResizeObserver(() => this.fitTerminal());
this.observer.observe($('#session-terminal'));
this.fitTerminal();
this.connectSocket();
}
fitTerminal() {
if (this.term && this.visible && $('#session-terminal').clientWidth > 0) {
this.fit.fit();
}
}
input(data) {
if (!this.canType()) return;
if (data.length > 65536 || this.socket.bufferedAmount > 65536) {
this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.');
return;
}
for (let offset = 0; offset < data.length; offset += 8192) {
this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) }));
}
}
canType() {
return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp;
}
sendSize(rows, cols) {
if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) }));
}
async connectSocket() {
if (!this.current || !this.visible || !this.getToken()) return;
const generation = this.generation;
await this.renderPending;
if (generation !== this.generation || !this.current || !this.visible) return;
window.clearTimeout(this.reconnectTimer);
const previous = this.socket;
this.socket = null;
previous?.close();
this.caughtUp = false;
this.replayEnd = null;
this.connection = 'Connecting…';
const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin);
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(url);
this.socket = socket;
let rejected = false;
const current = () => generation === this.generation && this.socket === socket;
this.renderStatus();
socket.onopen = () => {
if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset }));
};
socket.onmessage = (event) => {
if (!current()) return;
try {
const message = JSON.parse(event.data);
if (message.type === 'session') {
const wasReady = this.caughtUp;
this.current = message.session;
if (message.offset > this.offset) {
this.term.reset();
this.offset = message.offset;
}
if (this.replayEnd == null) this.replayEnd = message.session.output_end;
this.caughtUp = this.offset >= this.replayEnd;
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
const index = this.records.findIndex((record) => record.id === message.session.id);
if (index >= 0) this.records[index] = message.session;
this.renderList();
this.renderStatus();
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
} else if (message.type === 'output') {
const data = bytes(message.data);
if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.');
this.renderPending = new Promise((resolve) => {
this.finishRender = resolve;
this.term.write(data, () => {
if (generation === this.generation) {
this.offset += data.length;
const wasReady = this.caughtUp;
this.caughtUp = this.offset >= this.replayEnd;
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset }));
this.renderStatus();
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
this.finishRender = null;
}
resolve();
});
});
} else if (message.type === 'error') {
rejected = true;
this.showError(message.message);
}
} catch (error) {
rejected = true;
this.showError(error.message);
socket.close();
}
};
socket.onclose = (event) => {
if (!current()) return;
this.connection = 'Disconnected';
this.caughtUp = false;
this.renderStatus();
if (event.code === 4401) { this.onExpired(); return; }
if (!rejected && ['active', 'starting'].includes(this.current?.status)) {
this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000);
}
};
}
renderStatus() {
const record = this.current;
const active = record?.status === 'active';
$('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
$('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session';
$('#session-end').disabled = !active || this.ending === record?.id;
$('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
if (this.term) this.term.options.disableStdin = !this.canType();
$('#session-meta').textContent = record
? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}`
: 'Your sessions and terminal output are saved on the host.';
}
async end() {
const record = this.current;
if (!record || this.ending) return;
const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session');
if (!confirmed) return;
this.ending = record.id;
this.renderStatus();
try {
const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
if (this.current?.id === record.id) this.current = ended;
if (this.vm?.id === record.vm_id) await this.refresh();
} catch (error) {
if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
} finally {
this.ending = null;
this.renderStatus();
}
}
detachTerminal() {
this.generation += 1;
window.clearTimeout(this.reconnectTimer);
this.observer?.disconnect();
this.observer = null;
const socket = this.socket;
this.socket = null;
socket?.close();
this.finishRender?.();
this.finishRender = null;
this.renderPending = Promise.resolve();
this.term?.dispose();
this.term = null;
this.current = null;
this.offset = 0;
this.caughtUp = false;
$('#session-terminal').replaceChildren();
$('#session-terminal').hidden = true;
$('#session-blank').hidden = false;
this.renderStatus();
}
leave() {
this.sequence += 1;
this.loading = false;
this.lastRefresh = 0;
this.detachTerminal();
this.visible = false;
this.vm = null;
this.records = [];
this.selectedId = null;
this.showError('');
$('#terminal-support').open = false;
}
};
})();
+9
View File
@@ -0,0 +1,9 @@
# Local terminal assets
Runtime files from the published npm packages:
- `@xterm/xterm` **5.5.0** — `xterm/lib/xterm.js`, `xterm/css/xterm.css`
- `@xterm/addon-fit` **0.10.0** — `xterm-fit/lib/addon-fit.js`
Both use the MIT license; the original `LICENSE` is included in each directory.
Assets are served locally. Running Nest requires no npm install or frontend build.
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
+58
View File
@@ -0,0 +1,58 @@
"""Compose the Nest dashboard with its local UVM management backend."""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from uvm.app import Application
from uvm.errors import UvmError
from uvm.server import create_api as create_uvm_api, run_server as run_api_server
def create_app(application: Application | None = None, *, host: str | None = None) -> Any:
"""Build the Nest workspace after validating the intended server binding."""
if host is None:
raise UvmError("create_app requires an explicit host. Start the workspace with nest --serve.")
api = create_uvm_api(application, host=host)
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .dashboard.router import router as dashboard_router
from .session_routes import router as session_router
from .sessions import SessionManager
sessions = SessionManager(api.state.uvm_application)
api.state.nest_sessions = sessions
previous_lifespan = api.router.lifespan_context
@asynccontextmanager
async def lifespan(app):
async with previous_lifespan(app):
try:
yield
finally:
await sessions.shutdown()
api.router.lifespan_context = lifespan
api.title = "Nest"
api.description = "Nest workspace API, with local Firecracker machine management powered by UVM."
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.include_router(session_router)
@api.get("/")
async def root() -> Any:
return RedirectResponse(url="/dashboard")
return api
def run_server(application: Application, *, host: str, port: int) -> None:
"""Launch Nest using the shared authenticated, TLS-aware server runner."""
run_api_server(application, host=host, port=port, api_factory=create_app)
+171
View File
@@ -0,0 +1,171 @@
"""VM-scoped session history, creation, and authenticated browser terminal streams."""
from __future__ import annotations
import asyncio
import base64
import hmac
import json
from contextlib import suppress
from typing import Literal
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from uvm.app import Application
from uvm.errors import UvmError
from uvm.guest_agent import MAX_TERMINAL_FRAME, ProtocolError, terminal_data
from uvm.routers.dependencies import get_authorized_application
from .sessions import SessionManager
router = APIRouter(prefix="/vms/{vm_id}/sessions", tags=["sessions"])
class SessionCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
rows: StrictInt = Field(default=24, ge=2, le=200)
cols: StrictInt = Field(default=80, ge=2, le=400)
class SessionResponse(BaseModel):
id: str
vm_id: str
name: str
username: str
status: Literal["starting", "active", "closed", "interrupted", "failed"]
created_at: float
updated_at: float
ended_at: float | None
rows: int
cols: int
exit_code: int | None
error: str | None
output_start: int
output_end: int
truncated: bool
def manager(request: Request, _application: Application = Depends(get_authorized_application)) -> SessionManager:
return request.app.state.nest_sessions
@router.get("", response_model=list[SessionResponse])
async def list_sessions(vm_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.list(vm_id)
@router.post("", response_model=SessionResponse, status_code=201)
async def create_session(vm_id: str, body: SessionCreate, sessions: SessionManager = Depends(manager)):
return await sessions.create(vm_id, body.rows, body.cols)
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.get(vm_id, session_id)
@router.post("/{session_id}/close", response_model=SessionResponse)
async def close_session(vm_id: str, session_id: str, sessions: SessionManager = Depends(manager)):
return await sessions.end(vm_id, session_id)
def _same_origin(websocket: WebSocket) -> bool:
origin = websocket.headers.get("origin")
if origin is None:
return True # Non-browser API clients must still authenticate.
try:
parsed = urlsplit(origin)
except ValueError:
return False
scheme = "https" if websocket.url.scheme == "wss" else "http"
return parsed.scheme == scheme and parsed.netloc.lower() == websocket.headers.get("host", "").lower() and not parsed.path
async def _message(websocket: WebSocket, *, limit: int = MAX_TERMINAL_FRAME) -> dict:
text = await websocket.receive_text()
if len(text) > limit:
raise ProtocolError("terminal message exceeds its size limit")
message = json.loads(text)
if not isinstance(message, dict):
raise ProtocolError("terminal message must be an object")
return message
@router.websocket("/{session_id}/terminal")
async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
if not _same_origin(websocket):
await websocket.close(code=1008)
return
await websocket.accept()
tasks = []
try:
# Browser WebSockets cannot set X-UVM-Token. Authenticate in the first
# frame so tokens never appear in URLs, access logs, or session history.
auth = await asyncio.wait_for(_message(websocket, limit=4096), timeout=10)
token = auth.get("token")
expected = websocket.app.state.uvm_application.settings.api_token
if (auth.get("type") != "auth" or not isinstance(token, str) or not token.isascii()
or not expected or not hmac.compare_digest(token, expected)):
await websocket.close(code=4401)
return
sessions = websocket.app.state.nest_sessions
await sessions.get(vm_id, session_id)
offset = auth.get("offset", 0)
acknowledgements = asyncio.Queue(maxsize=1)
async def output():
cursor = offset
previous = None
while True:
record, start, data = await sessions.read(vm_id, session_id, cursor)
if previous != record or start != cursor:
await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start})
previous = record
cursor = start
if data:
await websocket.send_json({"type": "output", "offset": cursor, "data": base64.b64encode(data).decode("ascii")})
cursor += len(data)
# Only one output chunk may be waiting for the terminal renderer.
acknowledged = await asyncio.wait_for(acknowledgements.get(), timeout=30)
if type(acknowledged) is not int or acknowledged != cursor:
raise ProtocolError("invalid terminal output acknowledgement")
continue
if record["status"] not in {"active", "starting"}:
return
await sessions.wait(session_id)
async def controls():
while True:
message = await _message(websocket)
if set(message) == {"type", "data"} and message["type"] == "input":
await sessions.write(vm_id, session_id, terminal_data(message["data"]))
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
await sessions.resize(vm_id, session_id, message["rows"], message["cols"])
elif set(message) == {"type", "offset"} and message["type"] == "ack":
if acknowledgements.full():
raise ProtocolError("unexpected terminal output acknowledgement")
acknowledgements.put_nowait(message["offset"])
else:
raise ProtocolError("invalid terminal control message")
tasks = [asyncio.create_task(output()), asyncio.create_task(controls())]
done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
except WebSocketDisconnect:
pass
except (ValueError, KeyError, TypeError, UnicodeError, RecursionError, TimeoutError):
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": "Invalid terminal message or authentication timeout."})
except UvmError as error:
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_json({"type": "error", "message": str(error)})
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
with suppress(WebSocketDisconnect, RuntimeError):
await websocket.close()
+172
View File
@@ -0,0 +1,172 @@
"""Private, durable session metadata and bounded PTY transcripts, separate from VM state."""
from __future__ import annotations
import fcntl
import os
import sqlite3
import stat
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from uvm.errors import ConflictError, NotFoundError, StateError, ValidationError
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
class SessionStore:
def __init__(self, path: Path, *, max_output: int = MAX_TRANSCRIPT_BYTES) -> None:
self.path = path
self.max_output = max_output
self._mutex = threading.RLock()
self._owner = None
try:
path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
if path.parent.is_symlink():
raise StateError("session history directory must not be a symlink")
path.parent.chmod(0o700)
self._owner = self._private_file(path.with_suffix(".lock"))
try:
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise ConflictError("Another Nest server is already managing sessions for this VM registry.") from error
descriptor = self._private_file(path)
os.close(descriptor)
with self._db() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, vm_id TEXT NOT NULL, name TEXT NOT NULL,
username TEXT NOT NULL, status TEXT NOT NULL,
created_at REAL NOT NULL, updated_at REAL NOT NULL, ended_at REAL,
rows INTEGER NOT NULL, cols INTEGER NOT NULL, exit_code INTEGER, error TEXT,
output_start INTEGER NOT NULL DEFAULT 0, output_end INTEGER NOT NULL DEFAULT 0,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS sessions_vm ON sessions(vm_id, created_at);
CREATE TABLE IF NOT EXISTS output (
session_id TEXT NOT NULL REFERENCES sessions(id), offset INTEGER NOT NULL,
data BLOB NOT NULL, PRIMARY KEY (session_id, offset)
);
""")
now = time.time()
db.execute("""UPDATE sessions SET status='interrupted', ended_at=?, updated_at=?,
error='Nest restarted; this shell is no longer connected.'
WHERE status IN ('starting', 'active')""", (now, now))
except BaseException:
self.close()
raise
@staticmethod
def _private_file(path: Path) -> int:
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
raise StateError("session history must use private regular files")
os.fchmod(descriptor, 0o600)
return descriptor
except BaseException:
os.close(descriptor)
raise
@contextmanager
def _db(self):
with self._mutex:
db = None
try:
db = sqlite3.connect(self.path, timeout=5)
db.row_factory = sqlite3.Row
with db:
yield db
except sqlite3.Error as error:
raise StateError("Could not read or save terminal session history.") from error
finally:
if db is not None:
db.close()
@staticmethod
def _record(row) -> dict:
if row is None:
raise NotFoundError("Session not found for this VM.")
result = dict(row)
result["truncated"] = bool(result["truncated"])
return result
def create(self, vm_id: str, rows: int, cols: int) -> dict:
session_id = "session-" + uuid.uuid4().hex
now = time.time()
with self._db() as db:
number = db.execute("SELECT COUNT(*) FROM sessions WHERE vm_id=?", (vm_id,)).fetchone()[0] + 1
db.execute("""INSERT INTO sessions
(id, vm_id, name, username, status, created_at, updated_at, rows, cols)
VALUES (?, ?, ?, '', 'starting', ?, ?, ?, ?)""",
(session_id, vm_id, f"Session {number}", now, now, rows, cols))
return self.get(vm_id, session_id)
def list(self, vm_id: str) -> list[dict]:
with self._db() as db:
return [self._record(row) for row in db.execute(
"SELECT * FROM sessions WHERE vm_id=? ORDER BY created_at DESC, id DESC", (vm_id,),
)]
def get(self, vm_id: str, session_id: str) -> dict:
with self._db() as db:
return self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
def activate(self, session_id: str, username: str) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET status='active', username=?, updated_at=? WHERE id=?",
(username, time.time(), session_id))
def resize(self, session_id: str, rows: int, cols: int) -> None:
with self._db() as db:
db.execute("UPDATE sessions SET rows=?, cols=? WHERE id=?", (rows, cols, session_id))
def finish(self, session_id: str, status: str, exit_code: int | None = None, error: str | None = None) -> None:
now = time.time()
with self._db() as db:
db.execute("""UPDATE sessions SET status=?, ended_at=?, updated_at=?, exit_code=?, error=?
WHERE id=? AND status IN ('starting', 'active')""", (status, now, now, exit_code, error, session_id))
def append(self, session_id: str, data: bytes) -> None:
if not data:
return
with self._db() as db:
row = db.execute("SELECT output_end FROM sessions WHERE id=?", (session_id,)).fetchone()
if row is None:
raise NotFoundError("Session not found.")
offset = row[0]
end = offset + len(data)
start = max(0, end - self.max_output)
db.execute("INSERT INTO output VALUES (?, ?, ?)", (session_id, offset, data))
db.execute("DELETE FROM output WHERE session_id=? AND offset + length(data)<=?", (session_id, start))
first = db.execute("SELECT offset, data FROM output WHERE session_id=? AND offset<?", (session_id, start)).fetchone()
if first is not None:
db.execute("UPDATE output SET offset=?, data=? WHERE session_id=? AND offset=?",
(start, first["data"][start - first["offset"]:], session_id, first["offset"]))
db.execute("UPDATE sessions SET output_start=?, output_end=?, truncated=?, updated_at=? WHERE id=?",
(start, end, int(start > 0), time.time(), session_id))
def read(self, vm_id: str, session_id: str, offset: int, limit: int = 65536) -> tuple[dict, int, bytes]:
with self._db() as db:
session = self._record(db.execute("SELECT * FROM sessions WHERE vm_id=? AND id=?", (vm_id, session_id)).fetchone())
if type(offset) is not int or not 0 <= offset <= session["output_end"]:
raise ValidationError("invalid terminal output offset")
start = max(offset, session["output_start"])
data = bytearray()
for row in db.execute("""SELECT offset, data FROM output
WHERE session_id=? AND offset + length(data)>? ORDER BY offset""", (session_id, start)):
part = row["data"][max(0, start - row["offset"]):]
data.extend(part[:limit - len(data)])
if len(data) >= limit:
break
return session, start, bytes(data)
def close(self) -> None:
if self._owner is not None:
os.close(self._owner)
self._owner = None
+188
View File
@@ -0,0 +1,188 @@
"""Nest-owned live terminal sessions; browser attachments do not own guest shells."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from uvm.app import Application
from uvm.errors import ConflictError, GuestExecutionError, NotFoundError, StateError, UvmError
from uvm.guest_terminal import GuestTerminal, validate_size
from .session_store import SessionStore
async def _persist(function, *arguments):
# Cancellation cannot release the history-owner lock while a worker still
# has an outstanding write to that database.
task = asyncio.create_task(asyncio.to_thread(function, *arguments))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
await task
raise
@dataclass
class _LiveSession:
vm_id: str
terminal: GuestTerminal
changed: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task | None = None
class SessionManager:
def __init__(self, application: Application) -> None:
self.application = application
self._store: SessionStore | None = None
self._live: dict[str, _LiveSession] = {}
self._pending: set[asyncio.Task] = set()
self._create_lock = asyncio.Lock()
self._closing = False
def _history(self) -> SessionStore:
if self._closing:
raise ConflictError("Nest is shutting down.")
if self._store is None:
try:
self._store = SessionStore(Path(self.application.settings.base) / "nest" / "sessions.sqlite3")
except OSError as error:
raise StateError("Could not open private terminal history; run Nest with access to the VM registry.") from error
return self._store
async def _vm(self, vm_id: str) -> None:
# Exact IDs prevent a session from following a reused VM name or IP.
await asyncio.to_thread(self.application.state_store.get, vm_id)
async def list(self, vm_id: str) -> list[dict]:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().list, vm_id)
async def get(self, vm_id: str, session_id: str) -> dict:
await self._vm(vm_id)
return await asyncio.to_thread(self._history().get, vm_id, session_id)
async def create(self, vm_id: str, rows: int, cols: int) -> dict:
validate_size(rows, cols)
await self._vm(vm_id)
history = self._history()
# A dropped HTTP request must not abandon a just-created guest connection.
task = asyncio.create_task(self._create(history, vm_id, rows, cols))
self._pending.add(task)
def completed(future):
self._pending.discard(future)
if not future.cancelled():
future.exception()
task.add_done_callback(completed)
return await asyncio.shield(task)
async def _create(self, history: SessionStore, vm_id: str, rows: int, cols: int) -> dict:
async with self._create_lock:
if self._closing:
raise ConflictError("Nest is shutting down.")
if len(self._live) >= 32:
raise ConflictError("There are already 32 active terminal sessions. End a session before launching another.")
record = await asyncio.to_thread(history.create, vm_id, rows, cols)
terminal = None
try:
terminal, username = await asyncio.to_thread(self.application.lifecycle.open_terminal, vm_id, rows=rows, cols=cols)
await asyncio.to_thread(history.activate, record["id"], username)
live = _LiveSession(vm_id, terminal)
self._live[record["id"]] = live
live.task = asyncio.create_task(self._read(history, record["id"], live))
except BaseException as error:
if terminal is not None:
terminal.close()
message = str(error) if isinstance(error, UvmError) else "Could not launch the guest terminal."
await asyncio.to_thread(history.finish, record["id"], "failed", None, message)
raise
return await asyncio.to_thread(history.get, vm_id, record["id"])
async def _read(self, history: SessionStore, session_id: str, live: _LiveSession) -> None:
status, exit_code, error = "interrupted", None, None
try:
while True:
message = await live.terminal.read()
if message["type"] == "exit":
status, exit_code = "closed", message["exit_code"]
break
await _persist(history.append, session_id, message["data"])
live.changed.set()
except asyncio.CancelledError:
error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted."
except (UvmError, OSError) as exception:
error = str(exception) if isinstance(exception, UvmError) else "The guest terminal connection was interrupted."
finally:
live.terminal.close()
try:
await _persist(history.finish, session_id, status, exit_code, error)
finally:
live.changed.set()
self._live.pop(session_id, None)
async def read(self, vm_id: str, session_id: str, offset: int) -> tuple[dict, int, bytes]:
return await asyncio.to_thread(self._history().read, vm_id, session_id, offset)
async def wait(self, session_id: str) -> None:
live = self._live.get(session_id)
if live is None:
await asyncio.sleep(0.1)
return
try:
await asyncio.wait_for(live.changed.wait(), timeout=1)
except TimeoutError:
pass
live.changed.clear()
async def _active(self, vm_id: str, session_id: str) -> _LiveSession:
live = self._live.get(session_id)
if live is None:
await self.get(vm_id, session_id)
raise ConflictError("This session has ended. Launch a new session to run commands.")
if live.vm_id != vm_id:
raise NotFoundError("Session not found for this VM.")
return live
async def write(self, vm_id: str, session_id: str, data: bytes) -> None:
live = await self._active(vm_id, session_id)
try:
await live.terminal.write(data)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def resize(self, vm_id: str, session_id: str, rows: int, cols: int) -> None:
validate_size(rows, cols)
live = await self._active(vm_id, session_id)
try:
await live.terminal.resize(rows, cols)
await asyncio.to_thread(self._history().resize, session_id, rows, cols)
except OSError as error:
live.task.cancel()
raise GuestExecutionError("The terminal connection was interrupted.", outcome_unknown=True) from error
async def end(self, vm_id: str, session_id: str) -> dict:
record = await self.get(vm_id, session_id)
live = self._live.get(session_id)
if live is not None:
try:
await live.terminal.end()
await asyncio.wait_for(asyncio.shield(live.task), timeout=8)
except (OSError, UvmError):
live.task.cancel()
await asyncio.gather(live.task, return_exceptions=True)
record = await self.get(vm_id, session_id)
return record
async def shutdown(self) -> None:
self._closing = True
await asyncio.gather(*self._pending, return_exceptions=True)
tasks = [live.task for live in self._live.values()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if self._store is not None:
self._store.close()
+9 -2
View File
@@ -5,18 +5,25 @@ build-backend = "setuptools.build_meta"
[project]
name = "uvm-cli"
version = "0.1.0"
description = "Local Firecracker microVM command-line manager"
description = "Nest workspace and local Firecracker microVM management"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<1",
"pydantic>=2,<3",
"uvicorn>=0.30,<1",
"websockets>=13,<17",
]
[project.optional-dependencies]
test = ["httpx>=0.27,<1"]
[project.scripts]
nest = "nest.cli:main"
uvm = "uvm.cli:main"
[tool.setuptools]
packages = ["uvm", "uvm.firecracker", "uvm.routers"]
packages = ["nest", "nest.dashboard", "uvm", "uvm.firecracker", "uvm.routers"]
include-package-data = false
[tool.setuptools.package-data]
"nest.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt", "static/vendor/*.md", "static/vendor/*/LICENSE", "static/vendor/*/lib/*.js", "static/vendor/*/css/*.css"]
+82
View File
@@ -0,0 +1,82 @@
"""Isolated browser-test server: all VM and terminal operations are fakes."""
from __future__ import annotations
import argparse
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import uvicorn
from nest.server import create_app
from tests.test_server import FakeLifecycle, fake_application
from tests.test_sessions import FakeTerminal
from uvm.errors import ConflictError, NotFoundError
from uvm.lifecycle import ListedVm
class BrowserTerminal(FakeTerminal):
def __init__(self):
super().__init__()
self.command = bytearray()
async def write(self, data):
await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")})
for byte in data:
if byte == 13:
await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "})
self.command.clear()
elif byte == 3:
self.command.clear()
else:
self.command.append(byte)
class BrowserLifecycle(FakeLifecycle):
def __init__(self):
super().__init__()
self.vm.username = "worker"
self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")}
def get(self, vm_id):
try:
return self.records[vm_id]
except KeyError as error:
raise NotFoundError("VM not found") from error
def inspect(self, identifier):
for vm in self.records.values():
if identifier in {vm.id, vm.name, vm.guest_ip}:
return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive")
raise NotFoundError("VM not found")
def list_vms(self):
return [self.inspect(vm_id) for vm_id in self.records]
def open_terminal(self, vm_id, **_size):
if self.get(vm_id).status != "running":
raise ConflictError("Start the machine first")
return BrowserTerminal(), "worker"
def install_guest_agent(self, vm_id):
return self.get(vm_id)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", type=Path, required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
application = fake_application(api_token="browser-test-token")
application.settings.base = args.base
application.lifecycle = BrowserLifecycle()
application.state_store = SimpleNamespace(get=application.lifecycle.get)
api = create_app(application, host="127.0.0.1")
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
api.state.uvicorn_server = server
server.run()
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
// Optional integration test: requires Playwright + Chromium and the project's test extra.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { mkdtemp, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { chromium } = require('playwright');
(async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
const origin = `http://127.0.0.1:${port}`;
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
let logs = '';
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
let browser;
let page;
try {
let ready = false;
for (let attempt = 0; attempt < 100; attempt++) {
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
try {
const response = await fetch(`${origin}/health`);
if (response.ok) { ready = true; break; }
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, `Fixture server did not start: ${logs}`);
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
await page.goto(`${origin}/dashboard#vm/vm-test`);
await page.locator('#token-input').fill('browser-test-token');
await page.locator('#auth-btn').click();
await page.locator('#detail-actions [data-action="launch-session"]').click();
await connected();
const firstUrl = page.url();
assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
await page.keyboard.type('echo alpha');
await page.keyboard.press('Enter');
await output('result:echo alpha');
await page.reload();
await connected();
await output('result:echo alpha');
assert.equal(await page.locator('.session-item').count(), 1);
await page.locator('#detail-actions [data-action="launch-session"]').click();
await page.waitForURL((url) => url.href !== firstUrl);
await connected();
assert.equal(await page.locator('.session-item').count(), 2);
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
await page.keyboard.type('echo beta');
await page.keyboard.press('Enter');
await output('result:echo beta');
await page.locator('.session-item').filter({ hasText: 'Session 1' }).click();
await connected();
await output('result:echo alpha');
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
assert.equal(await page.locator('.session-item').count(), 0);
assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
await page.goto(firstUrl);
await connected();
await output('result:echo alpha');
await page.locator('#session-end').click();
await page.locator('#confirm-ok').click();
await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
await page.reload();
await output('result:echo alpha');
assert.ok(await page.locator('#session-end').isDisabled());
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(300);
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
assert.ok(fits, 'mobile layout overflows the viewport');
assert.deepEqual(errors, []);
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
console.log('Browser sessions passed: launch, terminal input, reload/reconnect, multiple sessions, VM isolation, closed history, and mobile layout.');
} catch (error) {
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
console.error(logs);
throw error;
} finally {
await browser?.close();
server.kill('SIGTERM');
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
await rm(temporary, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+89
View File
@@ -0,0 +1,89 @@
"""Opt-in checks against an already-provisioned disposable VM on an isolated host."""
from __future__ import annotations
import json
import os
import unittest
from urllib.parse import quote
from urllib.request import Request, urlopen
@unittest.skipUnless(os.environ.get("UVM_EXEC_INTEGRATION") == "1", "set UVM_EXEC_INTEGRATION=1 on an isolated VM host")
class GuestExecIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.api_url = os.environ.get("UVM_API_URL", "http://127.0.0.1:8000").rstrip("/")
cls.token = os.environ["UVM_API_TOKEN"]
cls.vm_id = os.environ["UVM_EXEC_VM_ID"]
cls.path = f"/vms/{quote(cls.vm_id, safe='')}"
vm = cls.request("GET", cls.path)
if (vm["id"] != cls.vm_id or vm["observed_status"] != "running"
or vm["username"] == "root" or vm["ports"] != []):
raise RuntimeError("provide a running disposable VM with a non-root login, installed agent, and empty TCP allowlist")
cls.username = vm["username"]
@classmethod
def request(cls, method, path, body=None):
request = Request(
cls.api_url + path, method=method,
data=json.dumps(body).encode("utf-8") if body is not None else None,
headers={"X-UVM-Token": cls.token, "Content-Type": "application/json"},
)
with urlopen(request, timeout=30) as response:
return json.load(response)
def execute(self, command, *, sudo=False, timeout=10):
return self.request("POST", self.path + "/exec", {
"command": command, "sudo": sudo, "timeout_seconds": timeout,
})
def test_user_and_root_execution_work_with_all_tcp_ports_closed(self) -> None:
ordinary = self.execute("id -u; whoami")
self.assertEqual(ordinary["exit_code"], 0)
uid, username = ordinary["stdout"].splitlines()
self.assertNotEqual(uid, "0")
self.assertEqual(username, self.username)
elevated = self.execute("id -u", sudo=True)
self.assertEqual(elevated["exit_code"], 0)
self.assertEqual(elevated["stdout"], "0\n")
def test_multiline_script_returns_output_and_nonzero_status(self) -> None:
result = self.execute("printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7")
self.assertEqual(result, {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
def test_timeout_kills_a_descendant_that_started_a_new_session(self) -> None:
result = self.execute("""python3 - <<'PY'
import os, time
child = os.fork()
if child == 0:
os.setsid()
else:
print(child, flush=True)
time.sleep(30)
PY""", timeout=2)
self.assertTrue(result["timed_out"])
self.assertIsNone(result["exit_code"])
child = int(result["stdout"].strip())
checked = self.execute(f"""python3 - <<'PY'
from pathlib import Path
try:
state = Path('/proc/{child}/stat').read_text().rsplit(') ', 1)[1].split()[0]
except FileNotFoundError:
state = 'gone'
assert state in ('gone', 'Z'), state
PY""")
self.assertEqual(checked["exit_code"], 0, checked["stderr"])
def test_output_is_bounded_while_both_pipes_are_drained(self) -> None:
result = self.execute("""python3 - <<'PY'
import os
for _ in range(32):
os.write(1, b'o' * 65536)
os.write(2, b'e' * 65536)
PY""")
self.assertEqual(result["exit_code"], 0)
self.assertTrue(result["truncated"])
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), 1024 * 1024)
+481
View File
@@ -0,0 +1,481 @@
"""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, iptables, 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 namespace with two
uplinks tests NAT, offline startup, reconnects and host route changes without
using the real internet. Ordinary test runs skip without executing commands.
"""
from __future__ import annotations
import contextlib
import errno
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 OSError as error:
sock.close()
if operation == "connect" and error.errno in {
errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, errno.ECONNREFUSED,
}:
return False
raise
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.network import NetworkManager
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")
# All changes are inside the freshly verified namespace. A pre-existing
# terminal drop catches regressions that append ineffective forward rules.
run("iptables", "-P", "FORWARD", "DROP")
run("iptables", "-A", "FORWARD", "-j", "DROP")
assert not run("ip", "route", "show", "default").strip()
NetworkManager(settings, runner).ensure_bridge()
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)])
run("ip", "link", "add", "fw-up2", "type", "veth", "peer", "name", "fw-end2")
run("ip", "link", "set", "fw-end2", "netns", str(outside.process.pid))
run("ip", "addr", "add", "172.30.74.1/24", "dev", "fw-up2")
run("ip", "link", "set", "fw-up2", "up")
outside.request("run", command=["ip", "link", "set", "fw-end2", "name", "eth1"])
outside.request("run", command=["ip", "addr", "add", "172.30.74.2/24", "dev", "eth1"])
outside.request("run", command=["ip", "link", "set", "eth1", "up"])
internet_ip = "203.0.113.73"
outside.request("run", command=["ip", "addr", "add", f"{internet_ip}/32", "dev", "lo"])
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", "-L", "--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"
assert query[1][0] == "172.30.73.1", "guest egress must use the host uplink's address"
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)
# No UVM reconfiguration after this point: the kernel must follow the
# host routes for both TCP and UDP/53, including after offline creation.
outside.request("listen", name="internet", ip=internet_ip, port=9443)
outside.request("udp_bind", name="internet-dns", ip=internet_ip, port=53)
def offline(label):
assert not host.request("connect", name=f"host-{label}", ip=internet_ip, port=9443)
assert not guest.request("connect", name=f"guest-{label}", ip=internet_ip, port=9443)
assert guest.request("ping", ip=str(settings.gateway)), "local networking must still work offline"
def online(label, source):
for endpoint, role in ((host, "host"), (guest, "guest")):
name = f"{role}-{label}"
assert endpoint.request("connect", name=name, ip=internet_ip, port=9443)
assert outside.request("accept", listener="internet", name=name)
transfer(endpoint, outside, name, "request")
transfer(outside, endpoint, name, "response")
guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0)
guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label)
query = outside.request("udp_recv", name="internet-dns")
assert query[0] == label and query[1][0] == source, query
outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name=label)[0] == "dns-reply"
offline("initially-offline")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("first-uplink", "172.30.73.1")
run("ip", "route", "replace", "default", "via", "172.30.74.2", "dev", "fw-up2")
online("changed-uplink", "172.30.74.1")
run("ip", "route", "del", "default")
run("ip", "link", "set", "fw-up", "down")
run("ip", "link", "set", "fw-up2", "down")
offline("disconnected")
run("ip", "link", "set", "fw-up", "up")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("reconnected", "172.30.73.1")
print("PASS: host/guest NAT egress follows offline/online, uplink changes and reconnects", 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", "iptables", "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()
+12
View File
@@ -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:
@@ -102,9 +108,15 @@ class CliCreateTests(unittest.TestCase):
command="create",
cpu="1",
ram="512",
storage="30G",
host_ip=None,
username="admin",
password="custom-secret",
name=None,
ports=[],
no_default_ports=False,
password_stdin=False,
json=False,
)
output = io.StringIO()
+917
View File
@@ -0,0 +1,917 @@
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, storage_mib=30 * 1024,
)
self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True)
self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced")
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, storage_mib=spec.storage_mib,
)
self.lifecycle.resize.return_value = self.vm
self.application = SimpleNamespace(
settings=Settings(), lifecycle=self.lifecycle,
state_store=Mock(spec_set=("operation_lock",)),
)
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 CliResizeCommandTests(CliTestCase):
def test_resize_forwards_resources_and_timeout_without_changing_identity(self) -> None:
self.lifecycle.resize.return_value = replace(self.vm, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
code, stdout, stderr = self.invoke([
"resize", "web", "--cpu", "2", "--ram", "4G", "--storage", "50G", "--timeout", "0.5", "--json",
])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=2, ram_mib=4096, storage_mib=51200, timeout=0.5)
result = json.loads(stdout)
self.assertEqual(result["storage_mib"], 51200)
self.assertEqual(result["guest_ip"], self.vm.guest_ip)
self.assertEqual(result["username"], self.vm.username)
self.assertNotIn("password", result)
self.assertNotIn(self.vm.password, stdout + stderr)
def test_partial_resize_preserves_omitted_fields(self) -> None:
code, _stdout, stderr = self.invoke(["resize", "web", "--storage", "60G"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resize.assert_called_once_with("web", cpu=None, ram_mib=None, storage_mib=61440, timeout=None)
def test_invalid_resize_requests_are_rejected_before_lifecycle_calls(self) -> None:
for options in ([], ["--storage", "29G"], ["--ram", "127M"], ["--cpu", "nan"],
["--cpu", "0"], ["--cpu", "2", "--timeout", "-1"], ["--username", "new-user"]):
with self.subTest(options=options):
self.assertEqual(self.invoke(["resize", "web", *options])[0], 2)
self.lifecycle.resize.assert_not_called()
class CliCreateCommandTests(CliTestCase):
def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
code, stdout, stderr = self.invoke([
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G", "--storage", "50G",
"--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin",
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
], stdin=io.StringIO(" stdin-secret \r\n"))
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.storage_mib, 50 * 1024)
self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8"))
self.assertEqual(spec.username, "admin")
self.assertEqual(spec.password, " stdin-secret ")
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.assertEqual(self.lifecycle.create.call_args.args[0].storage_mib, 30 * 1024)
self.assertNotIn("WARNING", stdout)
self.assertIn("default password", stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
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)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import ConfigurationError
class ArchitectureSettingsTests(unittest.TestCase):
def test_resolves_native_defaults_when_settings_are_created(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("AMD64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
for factory in (Settings, Settings.from_environment):
with (
self.subTest(machine=machine, factory=factory),
patch.dict(os.environ, {}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = factory()
base = (
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
f"{architecture}"
)
self.assertEqual(settings.architecture, architecture)
self.assertEqual(settings.kernel_url, f"{base}/vmlinux-6.1.155")
self.assertEqual(settings.rootfs_url, "")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
base = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/aarch64"
for overrides in (
{"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs},
{"UVM_KERNEL_URL": kernel, "UVM_ROOTFS_URL": rootfs},
):
with (
self.subTest(overrides=overrides),
patch.dict(os.environ, overrides, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="aarch64")),
):
settings = Settings.from_environment()
self.assertEqual(
settings.kernel_url,
overrides.get("UVM_KERNEL_URL", f"{base}/vmlinux-6.1.155"),
)
self.assertEqual(
settings.rootfs_url,
overrides.get("UVM_ROOTFS_URL", ""),
)
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
with (
patch.dict(os.environ, {
"UVM_KERNEL_URL": "https://example.com/kernel",
"UVM_ROOTFS_URL": "https://example.com/rootfs.ext4",
}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="riscv64")),
self.assertRaisesRegex(ConfigurationError, "unsupported host architecture: riscv64"),
):
Settings.from_environment()
class KnownHostsSettingsTests(unittest.TestCase):
def test_known_hosts_files_are_loaded_as_explicit_absolute_paths(self) -> None:
with patch.dict(os.environ, {
"UVM_KNOWN_HOSTS_FILES": "/root/.ssh/known_hosts:/home/operator/.ssh/known_hosts",
}, clear=True):
settings = Settings.from_environment()
self.assertEqual(settings.known_hosts_files, (
Path("/root/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts"),
))
with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(Settings.from_environment().known_hosts_files)
def test_relative_or_empty_path_entries_are_rejected(self) -> None:
for value in ("known_hosts", "/tmp/known_hosts:", ":/tmp/known_hosts"):
with self.subTest(value=value), patch.dict(os.environ, {"UVM_KNOWN_HOSTS_FILES": value}, clear=True):
with self.assertRaisesRegex(ConfigurationError, "UVM_KNOWN_HOSTS_FILES"):
Settings.from_environment()
+93
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
import unittest
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import VmRecord
from uvm.errors import FirecrackerError
from uvm.firecracker.api import FirecrackerClient
from uvm.firecracker.config import build_config, vcpu_count
@@ -19,6 +21,38 @@ class RecordingFirecrackerClient(FirecrackerClient):
class FirecrackerConfigTests(unittest.TestCase):
def test_uses_arm_boot_console_arguments_only_on_arm64(self) -> None:
vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
)
for architecture in ("x86_64", "aarch64", "arm64"):
with self.subTest(architecture=architecture):
config = build_config(
Settings(architecture=architecture),
vm,
Path("/images/vmlinux"),
Path("/vm/rootfs.ext4"),
)
arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments)
self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
client = RecordingFirecrackerClient()
client.configure_and_start(config)
self.assertEqual(client.calls[-2], ("PUT", "/entropy", {}))
self.assertEqual(client.calls[-1], ("PUT", "/actions", {"action_type": "InstanceStart"}))
def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord(
id="vm-test",
@@ -45,6 +79,7 @@ class FirecrackerConfigTests(unittest.TestCase):
self.assertEqual(config["drives"][0]["path_on_host"], "/vm/rootfs.ext4")
self.assertIn("ip=10.42.0.2::10.42.0.1", config["boot-source"]["boot_args"])
self.assertNotIn("password", str(config))
self.assertEqual(config["vsock"], {"guest_cid": 3, "uds_path": "/var/lib/uvm/vms/vm-test/vsock.sock"})
def test_configures_firecracker_in_required_order_before_start(self) -> None:
vm = VmRecord(
@@ -74,6 +109,8 @@ class FirecrackerConfigTests(unittest.TestCase):
"/boot-source",
"/drives/rootfs",
"/network-interfaces/eth0",
"/vsock",
"/entropy",
"/actions",
],
)
@@ -83,6 +120,62 @@ class FirecrackerConfigTests(unittest.TestCase):
)
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
def test_entropy_failure_prevents_guest_boot(self) -> None:
config = {
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512},
"boot-source": {},
"drives": [{}],
"network-interfaces": [{}],
"entropy": {},
"vsock": {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"},
}
client = RecordingFirecrackerClient()
def request(method: str, path: str, body: object) -> None:
client.calls.append((method, path, body))
if path == "/entropy":
raise FirecrackerError("entropy device unavailable")
with patch.object(client, "_request", side_effect=request):
with self.assertRaisesRegex(FirecrackerError, "entropy device unavailable"):
client.configure_and_start(config)
self.assertEqual(client.calls[-1], ("PUT", "/entropy", {}))
self.assertNotIn("/actions", [path for _method, path, _body in client.calls])
for entropy in (None, [], False):
with self.subTest(entropy=entropy):
client.calls.clear()
config["entropy"] = entropy
with self.assertRaisesRegex(FirecrackerError, "no valid entropy device"):
client.configure_and_start(config)
self.assertEqual(client.calls, [])
def test_missing_invalid_or_failed_vsock_configuration_prevents_guest_boot(self) -> None:
config = {
"machine-config": {"vcpu_count": 1, "mem_size_mib": 512}, "boot-source": {},
"drives": [{}], "network-interfaces": [{}], "entropy": {},
}
for vsock in (None, {}, {"guest_cid": True, "uds_path": "/tmp/vsock.sock"},
{"guest_cid": 2, "uds_path": "/tmp/vsock.sock"}, {"guest_cid": 3, "uds_path": ""}):
with self.subTest(vsock=vsock):
client = RecordingFirecrackerClient()
config["vsock"] = vsock
with self.assertRaisesRegex(FirecrackerError, "vsock"):
client.configure_and_start(config)
self.assertEqual(client.calls, [])
config["vsock"] = {"guest_cid": 3, "uds_path": "/tmp/vsock.sock"}
client = RecordingFirecrackerClient()
def request(method, path, body):
client.calls.append((method, path, body))
if path == "/vsock":
raise FirecrackerError("vsock unavailable")
with patch.object(client, "_request", side_effect=request), self.assertRaises(FirecrackerError):
client.configure_and_start(config)
self.assertNotIn("/actions", [path for _method, path, _body in client.calls])
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
settings = Settings(
network=IPv4Network("10.50.0.0/16"),
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import hashlib
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.images import GUEST_AGENT_PREPARE, ImageStore, guest_agent_files
from uvm.system import CommandRunner
class GuestRuntimeBootstrapTests(unittest.TestCase):
def test_runtime_install_is_guest_only_skipped_when_ready_and_stops_on_apt_failure(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
python = directory / "python3"
apt = directory / "apt-get"
ready = directory / "ready"
python.write_text(
f"#!{sys.executable}\nimport os, sys\nfrom pathlib import Path\n"
"sys.exit(0 if Path(os.environ['READY']).exists() else 1)\n", encoding="utf-8",
)
apt.write_text(
f"#!{sys.executable}\nimport json, os, sys\nfrom pathlib import Path\n"
"print(json.dumps(sys.argv[1:]))\n"
"if sys.argv[-1] == 'update' and os.environ['FAIL_UPDATE'] == '1': sys.exit(100)\n"
"if sys.argv[-1] == 'python3': Path(os.environ['READY']).touch()\n", encoding="utf-8",
)
python.chmod(0o755)
apt.chmod(0o755)
script = GUEST_AGENT_PREPARE.replace("/usr/bin/python3", str(python)).replace("/usr/bin/apt-get", str(apt))
for installed, fail in ((True, False), (False, False), (False, True)):
with self.subTest(installed=installed, fail=fail):
ready.unlink(missing_ok=True)
if installed:
ready.touch()
result = CommandRunner(emit=None).run((
"env", f"READY={ready}", f"FAIL_UPDATE={int(fail)}", "/bin/sh", "-c", script,
), check=False, capture=True)
calls = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(result.returncode, 100 if fail else 0)
self.assertEqual(len(calls), 0 if installed else 1 if fail else 2)
if calls:
self.assertEqual(calls[0][-1], "update")
self.assertIn("APT::Update::Error-Mode=any", calls[0])
if not installed and not fail:
self.assertEqual(calls[-1][-2:], ["install", "python3"])
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
"e2fsprogs and coreutils are required for offline guest agent checks")
class GuestAgentImageTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.settings = Settings(base=self.base / "uvm")
self.settings.images_dir.mkdir(parents=True)
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
self.disk.parent.mkdir()
self.runner = CommandRunner(emit=None)
self.images = ImageStore(self.settings, self.runner)
self.retained = {
"/etc/shadow": "worker:$6$retained-hash:1:0:99999:7:::\n",
"/etc/ssh/ssh_host_ed25519_key": "retained private SSH key\n",
"/home/worker/data": "retained application data\n",
}
def make_disk(self, marker="uvm-ubuntu-jammy-v3\n") -> None:
root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
payloads = {
**self.retained,
"/usr/lib/systemd/systemd": "systemd fixture", "/usr/bin/apt-get": "apt fixture",
}
if marker is not None:
payloads["/etc/uvm-image"] = marker
for path, content in payloads.items():
target = root / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
target.chmod(0o644)
for directory in (root, *(path for path in root.rglob("*") if path.is_dir())):
directory.chmod(0o755)
with self.settings.rootfs_image.open("wb") as image:
image.truncate(32 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
# mkfs -d retains the creating user's ownership. Model the root-built
# guest while allowing this test itself to run without root.
paths = {"/"}
for path in payloads:
paths.add(path)
paths.update(str(parent) for parent in Path(path).parents)
commands = "\n".join(f"set_inode_field {path} {field} 0" for path in paths for field in ("uid", "gid"))
self.runner.run(("debugfs", "-w", self.settings.rootfs_image), input_text=commands + "\n", capture=True)
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
self.disk.chmod(0o600)
def guest(self, command: str, *, disk: Path | None = None) -> str:
return self.runner.run(("debugfs", "-R", command, disk or self.disk), capture=True).stdout
def snapshot(self) -> tuple[int, bytes]:
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
def replace_guest_file(self, path: str, contents: str, *, disk: Path | None = None) -> None:
source = self.base / "edited"
source.write_text(contents, encoding="utf-8")
source.chmod(0o644)
self.runner.run(("debugfs", "-w", disk or self.disk), input_text=(
f"rm {path}\nwrite {source} {path}\nset_inode_field {path} uid 0\nset_inode_field {path} gid 0\n"
), capture=True)
def test_existing_guest_is_upgraded_atomically_with_private_identity_and_retained_data(self) -> None:
self.make_disk()
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"Mode:\s+0?600")
self.assertRegex(self.guest("stat /etc/uvm-agent.json"), r"User:\s+0\s+Group:\s+0")
for path, content in guest_agent_files().items():
self.assertEqual(self.guest(f"cat {path}"), content.decode("utf-8"))
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
self.assertEqual(self.guest("cat /etc/uvm-image"), "uvm-ubuntu-jammy-v3\n")
for path, content in self.retained.items():
self.assertEqual(self.guest(f"cat {path}"), content)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.guest("cat /home/worker/data"), self.retained["/home/worker/data"])
def test_all_compatible_images_get_vsock_agent_setup_without_opt_in(self) -> None:
for marker in ("uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v3\n", "uvm-ubuntu-jammy-v4\n",
"uvm-ubuntu-jammy-v5\n", "custom-image\n", None):
with self.subTest(marker=marker):
self.make_disk(marker)
template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-test", "username": "worker"})
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
for path, content in self.retained.items():
self.assertEqual(self.guest(f"cat {path}"), content)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), template)
def test_current_agent_setup_is_read_only_on_later_boots_and_tracks_vm_identity(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
before = self.snapshot()
with patch.object(self.images, "_copy_sparse") as copy:
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-recreated", "worker"))
self.assertEqual(json.loads(self.guest("cat /etc/uvm-agent.json")), {"vm_id": "vm-recreated", "username": "worker"})
def test_default_setup_reenables_an_existing_agent_service(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.runner.run(("debugfs", "-w", "-R", "rm /etc/systemd/system/multi-user.target.wants/uvm-agent.service", self.disk), capture=True)
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
self.assertIn('Fast link dest: "/etc/systemd/system/uvm-agent.service"',
self.guest("stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service"))
def test_incompatible_images_fail_default_setup_without_changing_the_disk(self) -> None:
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
with self.subTest(binary=binary):
self.make_disk(marker=None)
self.runner.run(("debugfs", "-w", "-R", f"rm {binary}", self.disk), capture=True)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "automatic vsock guest-agent setup requires a systemd/apt image"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertEqual(self.snapshot(), before)
def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True)
old_payload = "previous packaged agent\n"
self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "customized guest agent"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()):
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
updated = guest_agent_files()
updated["/usr/lib/uvm/agent.py"] += b"\n# next packaged revision\n"
with patch("uvm.images.guest_agent_files", return_value=updated):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertTrue(self.guest("cat /usr/lib/uvm/agent.py").endswith("# next packaged revision\n"))
self.replace_guest_file("/usr/lib/uvm/agent.py", "guest customization\n")
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "customized guest agent file"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_failure_during_provisioning_keeps_the_original_disk(self) -> None:
self.make_disk()
before = self.snapshot()
write = self.images._write_guest_file
def fail(candidate, path, source):
self.assertNotEqual(candidate, self.disk)
write(candidate, path, source)
raise UvmError("simulated write failure")
with patch.object(self.images, "_write_guest_file", side_effect=fail), self.assertRaisesRegex(UvmError, "write failure"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_guest_edits_revealed_by_journal_replay_are_checked_before_replacement(self) -> None:
self.make_disk()
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
before = self.snapshot()
run = self.runner.run
def replay(command, **options):
result = run(command, **options)
if command[:3] == ("e2fsck", "-f", "-p"):
self.replace_guest_file("/usr/lib/uvm/agent.py", "journal-committed guest edit\n", disk=command[-1])
return result
with patch.object(self.runner, "run", side_effect=replay), self.assertRaisesRegex(UvmError, "customized"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
def test_unsafe_guest_directory_and_shared_disk_are_rejected(self) -> None:
self.make_disk()
with self.assertRaisesRegex(UvmError, "unsafe"):
self.images.provision_guest_agent(self.settings.rootfs_image, "vm-test", "worker", install=True)
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /etc mode 040777", self.disk), capture=True)
before = self.snapshot()
with self.assertRaisesRegex(UvmError, "unsafe guest agent directory"):
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
self.assertEqual(self.snapshot(), before)
+289
View File
@@ -0,0 +1,289 @@
from __future__ import annotations
import os
import pwd
import shlex
import socket
import struct
import sys
import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from uvm import guest_agent as agent
from uvm.guest_exec import GuestSession, validate_exec
class FakeScope:
"""Use real subprocesses/process groups without touching the host's cgroups."""
def __init__(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.path = Path(self.temporary.name)
(self.path / "cgroup.procs").touch()
self.kills = 0
self.joined_pid = None
def kill(self) -> None:
self.kills += 1
self.joined_pid = (self.path / "cgroup.procs").read_text()
def close(self) -> None:
self.temporary.cleanup()
class GuestCommandTests(unittest.TestCase):
def setUp(self) -> None:
self.account = pwd.getpwuid(os.geteuid())
self.scope = FakeScope()
self.addCleanup(self.scope.temporary.cleanup)
def run_command(self, command, timeout=5):
return agent.run_command(command, self.account.pw_name, timeout, scope_factory=lambda: self.scope)
def test_multiline_script_has_separate_output_exit_status_and_no_stdin(self) -> None:
result = self.run_command("printf 'hello\\n'\nprintf 'problem\\n' >&2\nread input || exit 7")
self.assertEqual(result, {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
self.assertTrue(self.scope.joined_pid.isdigit())
self.assertGreater(self.scope.kills, 0)
def test_command_enters_scope_before_running_and_uses_account_home_and_environment(self) -> None:
with patch.dict(os.environ, {"UVM_API_TOKEN": "must-not-reach-guest-command"}):
result = self.run_command('printf "%s\\n" "$$" "$HOME" "$USER" "$UVM_API_TOKEN"; pwd; id -u')
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["stdout"].splitlines(), [
self.scope.joined_pid, self.account.pw_dir, self.account.pw_name, "", self.account.pw_dir, str(os.geteuid()),
])
def test_timeout_kills_the_shell_and_children_that_ignore_sigterm(self) -> None:
start = time.monotonic()
result = self.run_command("trap '' TERM; sleep 30 & printf '%s\\n' \"$!\"; wait", timeout=0.25)
self.assertTrue(result["timed_out"])
self.assertIsNone(result["exit_code"])
self.assertLess(time.monotonic() - start, 4)
child = int(result["stdout"])
try:
state = Path(f"/proc/{child}/stat").read_text().split(") ", 1)[1].split()[0]
except FileNotFoundError:
state = "gone"
self.assertIn(state, {"gone", "Z"})
def test_large_stdout_and_stderr_are_drained_without_unbounded_capture(self) -> None:
script = "import os; [(os.write(1, b'o'*65536), os.write(2, b'e'*65536)) for _ in range(32)]"
result = self.run_command(f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}")
self.assertEqual(result["exit_code"], 0)
self.assertEqual(len(result["stdout"]) + len(result["stderr"]), agent.MAX_OUTPUT_BYTES)
self.assertTrue(result["truncated"])
self.assertFalse(result["timed_out"])
def test_non_utf8_output_is_replaced_without_losing_the_exit_code(self) -> None:
result = self.run_command("printf '\\377\\000'; exit 255")
self.assertEqual(result["stdout"], "\ufffd\0")
self.assertEqual(result["exit_code"], 255)
def test_cgroup_cleanup_failure_is_an_unknown_outcome_and_still_reaps_the_process(self) -> None:
with patch.object(self.scope, "kill", side_effect=OSError("cannot kill scope")):
with self.assertRaises(agent.ExecutionFailure) as raised:
self.run_command("exit 0")
self.assertTrue(raised.exception.outcome_unknown)
def test_privilege_drop_initializes_groups_before_uid_and_exec(self) -> None:
account = Mock(pw_name="worker", pw_uid=1001, pw_gid=1002, pw_dir="/home/worker")
events = []
with (
patch.object(agent.pwd, "getpwnam", return_value=account),
patch.object(agent.os, "geteuid", return_value=0),
patch.object(agent.os, "initgroups", side_effect=lambda *args: events.append(("groups", args))),
patch.object(agent.os, "setgid", side_effect=lambda value: events.append(("gid", value))),
patch.object(agent.os, "setuid", side_effect=lambda value: events.append(("uid", value))),
patch.object(agent.os, "chdir") as chdir,
patch.object(agent.os, "umask"),
patch.object(agent.os, "execve") as execute,
):
agent.child_main(self.scope.path, "worker", "printf x > file")
self.assertEqual(events, [("groups", ("worker", 1002)), ("gid", 1002), ("uid", 1001)])
chdir.assert_called_once_with("/home/worker")
self.assertEqual(execute.call_args.args[:2], ("/bin/sh", ["sh", "-c", "printf x > file"]))
self.assertEqual(execute.call_args.args[2]["HOME"], "/home/worker")
class GuestProtocolTests(unittest.TestCase):
def test_validates_byte_limits_types_finite_deadlines_and_text_before_transport(self) -> None:
for command, sudo, timeout in (
("", False, 1), (" \n", False, 1), ("a\0b", False, 1), ("\ud800", False, 1),
("x" * (agent.MAX_COMMAND_BYTES + 1), False, 1),
("\u00e9" * agent.MAX_COMMAND_BYTES, False, 1),
("id", "false", 1), ("id", 0, 1), ("id", False, True),
("id", False, 0), ("id", False, -1), ("id", False, float("nan")),
("id", False, float("inf")), ("id", False, 3601), ("id", False, "60"),
):
with self.subTest(command=command[:20], sudo=sudo, timeout=timeout), self.assertRaises(ValidationError):
validate_exec(command, sudo, timeout)
validate_exec("id", False, 0.1)
def test_oversized_frame_is_rejected_before_reading_a_body(self) -> None:
left, right = socket.socketpair()
with left, right:
left.sendall(struct.pack("!I", agent.MAX_REQUEST_BYTES + 1))
with self.assertRaises(agent.ProtocolError):
agent.receive_frame(right, agent.MAX_REQUEST_BYTES, time.monotonic() + 1)
def test_read_deadline_applies_to_the_whole_frame(self) -> None:
left, right = socket.socketpair()
with left, right:
left.sendall(struct.pack("!I", 5) + b"{")
start = time.monotonic()
with self.assertRaises(TimeoutError):
agent.receive_frame(right, 1024, time.monotonic() + 0.05)
self.assertLess(time.monotonic() - start, 0.5)
def test_agent_maps_sudo_to_root_and_rejects_bad_requests_without_launching(self) -> None:
config = {"vm_id": "vm-test", "username": "worker"}
for sudo in (False, True, "true"):
with self.subTest(sudo=sudo):
left, right = socket.socketpair()
result = {"exit_code": 0, "stdout": "", "stderr": "", "timed_out": False, "truncated": False}
runner = Mock(return_value=result)
thread = threading.Thread(target=agent.serve_connection, args=(right, config), kwargs={"runner": runner})
thread.start()
with left:
deadline = time.monotonic() + 2
self.assertEqual(agent.receive_frame(left, 4096, deadline), {"version": 1, **config})
agent.send_frame(left, {"version": 1, "command": "id", "sudo": sudo, "timeout_seconds": 2}, 4096, deadline)
reply = agent.receive_frame(left, 4096, deadline)
thread.join(2)
self.assertFalse(thread.is_alive())
if type(sudo) is bool:
self.assertEqual(reply, result)
runner.assert_called_once_with("id", "root" if sudo else "worker", 2)
else:
self.assertFalse(reply["outcome_unknown"])
runner.assert_not_called()
def test_guest_local_connections_are_rejected_before_a_worker_is_started(self) -> None:
connection = Mock()
listener = Mock()
listener.__enter__ = Mock(return_value=listener)
listener.__exit__ = Mock(return_value=False)
listener.accept.side_effect = [(connection, (3, 9999)), KeyboardInterrupt()]
with (
patch.object(agent.sys, "argv", ["agent.py"]), patch.object(agent.os, "geteuid", return_value=0),
patch.object(agent, "load_config", return_value={"vm_id": "vm-test", "username": "root"}),
patch.object(agent.socket, "socket", return_value=listener), patch.object(agent.threading, "Thread") as worker,
self.assertRaises(KeyboardInterrupt),
):
agent.main()
connection.close.assert_called_once()
worker.assert_not_called()
class GuestTransportTests(unittest.TestCase):
@contextmanager
def firecracker(self, handler):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "vsock.sock"
errors = []
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
listener.bind(str(path))
listener.listen(1)
listener.settimeout(3)
def serve():
try:
connection, _peer = listener.accept()
with connection:
handler(connection)
except (BrokenPipeError, EOFError, ConnectionResetError):
pass
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=serve, daemon=True)
thread.start()
try:
yield path
finally:
thread.join(4)
self.assertFalse(thread.is_alive())
if errors:
raise errors[0]
def handshake(self, connection, **changes):
deadline = time.monotonic() + 2
self.assertEqual(agent.receive_exact(connection, len(b"CONNECT 1070\n"), deadline), b"CONNECT 1070\n")
# The acknowledgement and JSON frame can be coalesced on the wire.
connection.sendall(b"OK 1234\n")
agent.send_frame(connection, {"version": 1, "vm_id": "vm-test", "username": "worker", **changes}, 4096, deadline)
def test_round_trip_preserves_command_text_and_a_nonzero_exit(self) -> None:
command = "printf '%s\\n' 'quotes; $(literal)'\nexit 7"
expected = {"exit_code": 7, "stdout": "quotes; $(literal)\n", "stderr": "", "timed_out": False, "truncated": False}
def handle(connection):
self.handshake(connection)
request = agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
self.assertEqual(request, {"version": 1, "command": command, "sudo": True, "timeout_seconds": 3})
agent.send_frame(connection, expected, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
try:
result = session.execute(command, sudo=True, timeout_seconds=3)
finally:
session.close()
self.assertEqual(result.exit_code, 7)
self.assertEqual(result.stdout, expected["stdout"])
def test_wrong_vm_or_process_identity_prevents_command_submission(self) -> None:
for changes, pid in (({"vm_id": "vm-other"}, os.getpid()), ({"version": 2}, os.getpid()),
({"username": "root"}, os.getpid()), ({}, os.getpid() + 100000)):
with self.subTest(changes=changes, pid=pid):
def handle(connection):
self.handshake(connection, **changes)
self.assertEqual(connection.recv(1), b"")
with self.firecracker(handle) as path, self.assertRaises(GuestAgentUnavailableError):
GuestSession.connect(path, "vm-test", "worker", pid)
def test_connection_loss_after_submission_has_unknown_outcome_without_retry(self) -> None:
submissions = []
def handle(connection):
self.handshake(connection)
submissions.append(agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2))
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
with self.assertRaises(GuestExecutionError) as raised:
try:
session.execute("touch example", sudo=False, timeout_seconds=1)
finally:
session.close()
self.assertTrue(raised.exception.outcome_unknown)
self.assertEqual(len(submissions), 1)
def test_malformed_result_is_not_reported_as_success(self) -> None:
for result in ({"exit_code": True, "stdout": "", "stderr": "", "timed_out": False, "truncated": False},
{"error": "bad", "outcome_unknown": "false"}, {"error": "\ud800", "outcome_unknown": False},
{"exit_code": 0}):
with self.subTest(result=result):
def handle(connection):
self.handshake(connection)
agent.receive_frame(connection, agent.MAX_REQUEST_BYTES, time.monotonic() + 2)
agent.send_frame(connection, result, agent.MAX_RESPONSE_BYTES, time.monotonic() + 2)
with self.firecracker(handle) as path:
session = GuestSession.connect(path, "vm-test", "worker", os.getpid())
with self.assertRaises(GuestExecutionError) as raised:
try:
session.execute("id", sudo=False, timeout_seconds=1)
finally:
session.close()
self.assertTrue(raised.exception.outcome_unknown)
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError, UvmError
from uvm.guest_exec import ExecResult
class GuestExecLifecycleTests(LifecycleTestCase):
def test_create_provisions_agent_before_the_disk_is_ready_or_the_guest_is_started(self) -> None:
def provision(disk, vm_id, username):
self.host.call("images.provision_agent", vm_id)
vm = self.store.get(vm_id)
self.assertFalse(vm.disk_ready)
self.assertEqual(self.process.observe(vm), "gone")
self.assertEqual(disk, Path(vm.disk))
self.assertEqual(username, "worker")
self.provision_agent.side_effect = provision
vm = self.create_vm(username="worker")
self.assert_call_order(vm, "images.provision_agent", "process.start")
def test_stopped_boots_automatically_provision_agent_before_starting_the_vmm(self) -> None:
vm = self.create_vm(username="worker", password="retained-password")
identity = (vm.id, vm.guest_ip, vm.mac, vm.username, vm.password)
def provision(disk, vm_id, username):
self.host.call("images.provision_agent", vm_id)
record = self.store.get(vm_id)
self.assertTrue(self.store.operation_active)
self.assertEqual(self.process.observe(record), "gone")
self.assertEqual((record.id, record.guest_ip, record.mac, record.username, record.password), identity)
self.assertEqual((disk, username), (Path(vm.disk), "worker"))
self.provision_agent.side_effect = provision
for action in ("start", "restart", "resize"):
with self.subTest(action=action):
if action == "start":
self.service.stop(vm.id)
self.host.events.clear()
self.provision_agent.reset_mock()
self.provision.reset_mock()
if action == "resize":
started = self.service.resize(vm.id, cpu=2)
else:
started = getattr(self.service, action)(vm.id)
self.assertEqual(started.status, "running")
self.provision_agent.assert_called_once_with(Path(vm.disk), vm.id, "worker")
self.provision.assert_not_called()
self.assert_call_order(vm, "images.provision_agent", "process.start")
def test_live_or_ambiguous_vms_never_run_offline_agent_provisioning(self) -> None:
vm = self.create_vm()
self.provision_agent.reset_mock()
self.service.start(vm.id)
self.process.unknown.add(vm.id)
with self.assertRaises(ConflictError):
self.service.start(vm.id)
self.provision_agent.assert_not_called()
def test_default_agent_setup_failure_blocks_boot_and_keeps_the_retained_disk(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
before = Path(vm.disk).read_bytes()
count = len(self.process.started)
self.provision_agent.side_effect = UvmError("agent setup failed")
with self.assertRaisesRegex(UvmError, "agent setup failed"):
self.service.start(vm.id)
self.assertEqual(len(self.process.started), count)
self.assertEqual(Path(vm.disk).read_bytes(), before)
self.assertEqual(self.store.get(vm.id).status, "failed")
self.provision_agent.side_effect = None
self.assertEqual(self.service.start(vm.id).status, "running")
def test_default_agent_setup_failure_rolls_back_a_new_vm_before_boot(self) -> None:
self.provision_agent.side_effect = UvmError("unsupported guest image")
with self.assertRaisesRegex(UvmError, "unsupported guest image"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertEqual(self.process.started, [])
def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None:
vm = self.create_vm(name="worker", ports=())
result = ExecResult(7, "output", "problem", False, False)
session = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return session
def execute(command, **options):
self.assertFalse(self.store.operation_active)
self.assertFalse(self.store.transaction_active)
self.service.stop(vm.id)
return result
self.service._guest_connector = Mock(side_effect=connect)
session.execute.side_effect = execute
self.assertEqual(self.service.execute(vm.id, "exit 7", sudo=True, timeout_seconds=2), result)
session.execute.assert_called_once_with("exit 7", sudo=True, timeout_seconds=2)
session.close.assert_called_once()
def test_execution_rejects_aliases_stopped_unknown_and_pending_vms_before_connecting(self) -> None:
vm = self.create_vm(name="worker")
self.service._guest_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.execute(alias, "id")
for changes in ({"status": "stopped"}, {"operation": "policy"}, {"operation": "delete"}):
self.persist(vm, **changes)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.persist(vm, status="running", operation=None)
self.process.unknown.add(vm.id)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_not_called()
def test_unavailable_or_lost_agent_does_not_retry_or_mutate_vm_state(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.service._guest_connector = Mock(side_effect=GuestAgentUnavailableError("not ready"))
with self.assertRaises(GuestAgentUnavailableError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_called_once()
session = Mock()
session.execute.side_effect = GuestExecutionError("connection lost", outcome_unknown=True)
self.service._guest_connector = Mock(return_value=session)
with self.assertRaises(GuestExecutionError):
self.service.execute(vm.id, "id")
session.execute.assert_called_once()
session.close.assert_called_once()
self.assertEqual(self.settings.state_path.read_bytes(), before)
def test_agent_upgrade_requires_a_stopped_proven_offline_disk_and_retains_identity(self) -> None:
vm = self.create_vm(username="worker")
self.provision_agent.reset_mock()
with self.assertRaises(ConflictError):
self.service.install_guest_agent(vm.id)
self.provision_agent.assert_not_called()
stopped = self.service.stop(vm.id)
before = stopped.to_dict()
def provision(disk, vm_id, username, *, install):
self.assertTrue(self.store.operation_active)
self.assertEqual(self.process.observe(self.store.get(vm_id)), "gone")
self.assertEqual((disk, username, install), (Path(vm.disk), "worker", True))
self.provision_agent.side_effect = provision
self.assertEqual(self.service.install_guest_agent(vm.id).to_dict(), before)
self.provision_agent.assert_called_once()
self.assertEqual(self.store.get(vm.id).to_dict(), before)
self.assertEqual(len(self.process.started), 1)
+215
View File
@@ -0,0 +1,215 @@
from __future__ import annotations
import configparser
import hashlib
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import GUEST_NETWORK_TEMPLATE, ImageStore
from uvm.system import CommandResult, CommandRunner
PROFILE = "/etc/systemd/network/10-uvm.network"
# The shipped v1/v2 profile that retained the IP but lost the proto-boot route.
LEGACY_PROFILE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
class GuestNetworkValidationTests(unittest.TestCase):
def test_invalid_guest_addresses_fail_before_disk_access(self) -> None:
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
for address in ("invalid", "::1", "192.0.2.2", "10.42.0.0", "10.42.0.255", "10.42.0.1"):
with self.subTest(address=address), self.assertRaises(ValidationError):
store.configure_guest_network(Path("/nonexistent/rootfs.ext4"), address)
runner.run.assert_not_called()
def test_shared_or_linked_disks_are_rejected_before_guest_commands(self) -> None:
with tempfile.TemporaryDirectory() as name:
settings = Settings(base=Path(name))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"shared template")
disk = settings.base / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = settings.base / "linked.ext4"
linked.hardlink_to(disk)
symlink = settings.base / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
for candidate in (settings.rootfs_image, disk, linked, symlink):
with self.subTest(candidate=candidate), self.assertRaisesRegex(UvmError, "unsafe"):
store.configure_guest_network(candidate, "10.42.0.2")
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
"e2fsprogs and coreutils are required for offline guest networking checks")
class OfflineGuestNetworkTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.settings = Settings(base=self.base / "uvm")
self.settings.images_dir.mkdir(parents=True)
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
self.disk.parent.mkdir()
self.runner = CommandRunner(emit=None)
self.store = ImageStore(self.settings, self.runner)
self.retained = {
"/etc/shadow": "adm:$6$original-password-hash:1:0:99999:7:::\n",
"/etc/ssh/ssh_host_ed25519_key": "original private host key\n",
"/home/adm/data": "retained guest application data\n",
}
def make_disk(self, *, marker="uvm-ubuntu-jammy-v2\n", profile=LEGACY_PROFILE, symlink=False) -> None:
root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
files = dict(self.retained)
if marker is not None:
files["/etc/uvm-image"] = marker
if profile is not None:
files["/etc/alternate.network" if symlink else PROFILE] = profile
for path, contents in files.items():
local = root / path.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text(contents, encoding="utf-8")
local.chmod(0o644)
if symlink:
local = root / PROFILE.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.symlink_to("/etc/alternate.network")
with self.settings.rootfs_image.open("wb") as image:
image.truncate(32 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
if profile is not None:
for field in ("uid", "gid"):
self.runner.run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0",
self.settings.rootfs_image), capture=True)
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
self.disk.chmod(0o600)
def guest_file(self, path: str) -> str:
return self.runner.run(("debugfs", "-R", f"cat {path}", self.disk), capture=True).stdout
def snapshot(self) -> tuple[int, bytes]:
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE),
("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE),
("uvm-ubuntu-jammy-v5\n", GUEST_NETWORK_TEMPLATE)):
with self.subTest(marker=marker):
self.make_disk(marker=marker, profile=profile)
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.store.configure_guest_network(self.disk, "10.42.0.2"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Match"]["Name"], "eth0")
self.assertEqual(config["Network"]["Address"], "10.42.0.2/24")
self.assertEqual(config["Network"]["Gateway"], "10.42.0.1")
self.assertTrue(config["Network"].getboolean("KeepConfiguration"))
self.assertFalse(config["Network"].getboolean("DHCP"))
for path, contents in self.retained.items():
self.assertEqual(self.guest_file(path), contents)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), original_template)
self.assertEqual(self.disk.stat().st_size, 32 * 1024 * 1024)
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_custom_subnets_and_gateways_are_used_in_the_guest_profile(self) -> None:
settings = replace(self.settings, network=IPv4Network("10.50.0.0/16"), gateway=IPv4Address("10.50.1.1"))
self.make_disk()
self.assertTrue(ImageStore(settings, self.runner).configure_guest_network(self.disk, "10.50.8.9"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Network"]["Address"], "10.50.8.9/16")
self.assertEqual(config["Network"]["Gateway"], "10.50.1.1")
def test_custom_images_missing_profiles_and_guest_edits_are_left_intact(self) -> None:
for marker, profile, symlink in (
(None, LEGACY_PROFILE, False), ("custom-image\n", LEGACY_PROFILE, False),
("uvm-ubuntu-jammy-v2\n", None, False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "# guest customization\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "Gateway=10.42.0.9\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE, True),
):
with self.subTest(marker=marker, profile=profile, symlink=symlink):
self.make_disk(marker=marker, profile=profile, symlink=symlink)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_a_failed_or_interrupted_profile_write_keeps_the_original_disk(self) -> None:
for error in (UvmError("profile readback failed"), KeyboardInterrupt()):
with self.subTest(error=type(error).__name__):
self.make_disk()
before = self.snapshot()
write = self.store._write_guest_file
def fail(candidate, path, contents):
self.assertNotEqual(candidate, self.disk)
write(candidate, path, contents)
raise error
with patch.object(self.store, "_write_guest_file", side_effect=fail), self.assertRaises(type(error)):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_filesystem_check_failure_prevents_publication(self) -> None:
self.make_disk()
before = self.snapshot()
run = self.runner.run
def check_failure(command, **options):
if command[0] == "e2fsck":
return CommandResult(tuple(map(str, command)), 4, stderr="unrepaired filesystem errors")
return run(command, **options)
with patch.object(self.runner, "run", side_effect=check_failure):
with self.assertRaisesRegex(UvmError, "unrepaired filesystem errors"):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_guest_edits_revealed_after_journal_replay_prevent_migration(self) -> None:
self.make_disk()
before = self.snapshot()
edited = self.base / "edited.network"
edited.write_text(LEGACY_PROFILE + "Gateway=10.42.0.9\n", encoding="utf-8")
run = self.runner.run
def replay(command, **options):
result = run(command, **options)
if command[:3] == ("e2fsck", "-f", "-p"):
run(("debugfs", "-w", "-R", f"rm {PROFILE}", command[-1]), capture=True)
run(("debugfs", "-w", "-R", f"write {edited} {PROFILE}", command[-1]), capture=True)
for field in ("uid", "gid"):
run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", command[-1]), capture=True)
return result
with patch.object(self.runner, "run", side_effect=replay):
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import asyncio
import os
import pwd
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from tests.test_guest_exec import FakeScope
from tests.test_lifecycle import LifecycleTestCase
from uvm import guest_agent as agent
from uvm.errors import ConflictError, GuestAgentUnavailableError, NotFoundError
from uvm.guest_terminal import GuestTerminal
class GuestTerminalTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.scope = FakeScope()
self.addCleanup(self.scope.temporary.cleanup)
self.home = self.enterContext(tempfile.TemporaryDirectory())
username = pwd.getpwuid(os.geteuid()).pw_name
original_popen = subprocess.Popen
# Exercise the actual guest child entry point with a temporary account
# home, so interactive shells never read/write the developer's dotfiles.
script = f"""
import os, runpy
from pathlib import Path
from types import SimpleNamespace
agent = runpy.run_path({agent.__file__!r})
agent['pwd'].getpwnam = lambda _: SimpleNamespace(pw_name={username!r}, pw_uid=os.geteuid(),
pw_gid=os.getegid(), pw_dir={self.home!r}, pw_shell='/bin/sh')
agent['child_main'](Path({str(self.scope.path)!r}), {username!r}, None)
"""
def launch(_args, **kwargs):
return original_popen([sys.executable, "-I", "-c", script], **kwargs)
self.enterContext(patch.object(agent.subprocess, "Popen", side_effect=launch))
left, right = socket.socketpair()
self.terminal = GuestTerminal(left)
self.addCleanup(self.terminal.close)
def runner(connection, account, rows, cols):
return agent.run_terminal(connection, account, rows, cols, scope_factory=lambda: self.scope)
self.thread = threading.Thread(target=agent.serve_connection,
args=(right, {"vm_id": "vm-test", "username": username}),
kwargs={"terminal_runner": runner}, daemon=True)
self.thread.start()
self.addCleanup(self.thread.join, 3)
agent.receive_frame(left, 4096, time.monotonic() + 2)
await asyncio.to_thread(self.terminal.start, 24, 80)
self.prompt = b"# " if os.geteuid() == 0 else b"$ "
await self.until(self.prompt)
async def until(self, wanted):
data = bytearray()
try:
async with asyncio.timeout(5):
while wanted not in data:
message = await self.terminal.read()
self.assertEqual(message["type"], "output", message)
data.extend(message["data"])
except TimeoutError:
self.fail(f"Missing {wanted!r} in terminal output: {bytes(data)!r}")
return bytes(data)
async def asyncTearDown(self):
self.terminal.close()
await asyncio.to_thread(self.thread.join, 3)
self.assertFalse(self.thread.is_alive())
async def test_terminal_has_tty_persistent_shell_state_and_exit_code(self):
await self.terminal.write(b"test -t 0 && printf '\\nREAL-TTY\\n'\r")
await self.until(b"\r\nREAL-TTY\r\n")
await self.terminal.write(b"export NEST_TEST=hello; cd /\r")
await self.terminal.write(b"printf 'STATE:%s:%s\\n' \"$NEST_TEST\" \"$PWD\"\r")
await self.until(b"STATE:hello:/\r\n")
await self.terminal.write(b"exit 7\r")
async with asyncio.timeout(5):
while True:
message = await self.terminal.read()
if message["type"] == "exit":
self.assertEqual(message["exit_code"], 7)
break
async def test_resize_and_control_c_reach_the_guest_foreground_job(self):
await self.terminal.resize(40, 120)
await self.terminal.write(b"stty size\r")
await self.until(b"40 120\r\n")
await self.terminal.write(b"sh -c 'printf \"\\nJOB-STARTED\\n\"; exec sleep 20'\r")
await self.until(b"\r\nJOB-STARTED\r\n")
await self.terminal.write(b"\x03")
await self.until(self.prompt)
await self.terminal.write(b"printf '\\nINTERRUPTED\\n'\r")
await self.until(b"\r\nINTERRUPTED\r\n")
async def test_explicit_end_terminates_the_shell(self):
await self.terminal.write(b"printf '\\nREADY\\n'\r")
await self.until(b"\r\nREADY\r\n")
await self.terminal.end()
async with asyncio.timeout(5):
while (message := await self.terminal.read())["type"] != "exit":
pass
self.assertIsInstance(message["exit_code"], int)
self.assertGreater(self.scope.kills, 0)
class TerminalCompatibilityTests(unittest.TestCase):
def test_old_agent_rejection_explains_how_to_upgrade(self):
left, right = socket.socketpair()
terminal = GuestTerminal(left)
self.addCleanup(terminal.close)
def old_agent():
with right:
agent.receive_frame(right, 4096, time.monotonic() + 2)
agent.send_frame(right, {"error": "invalid execution request fields", "outcome_unknown": False},
4096, time.monotonic() + 2)
thread = threading.Thread(target=old_agent)
thread.start()
try:
with self.assertRaisesRegex(GuestAgentUnavailableError, "Restart this VM to apply automatic guest-agent setup"):
terminal.start(24, 80)
finally:
thread.join(3)
class TerminalLifecycleTests(LifecycleTestCase):
def test_terminal_connects_under_lock_and_opens_without_holding_the_lock(self):
vm = self.create_vm(name="worker", ports=())
terminal = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return terminal
def start(rows, cols):
self.assertFalse(self.store.operation_active)
self.assertEqual((rows, cols), (40, 120))
self.service._terminal_connector = Mock(side_effect=connect)
terminal.start.side_effect = start
self.assertEqual(self.service.open_terminal(vm.id, rows=40, cols=120), (terminal, vm.username))
terminal.close.assert_not_called()
def test_aliases_and_stopped_vms_cannot_create_a_terminal(self):
vm = self.create_vm(name="worker")
self.service._terminal_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.open_terminal(alias)
self.service.stop(vm.id)
with self.assertRaises(ConflictError):
self.service.open_terminal(vm.id)
self.service._terminal_connector.assert_not_called()
+261 -2
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
import hashlib
import shutil
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.errors import UvmError, ValidationError
from uvm.images import (
_FIRECRACKER_DEMO_PUBLIC_KEY,
_GuestFile,
@@ -17,7 +19,7 @@ from uvm.images import (
ImageStore,
)
from uvm.integrity import write_manifest
from uvm.system import CommandResult
from uvm.system import CommandResult, CommandRunner
class PasswordHashRunner:
@@ -69,6 +71,16 @@ class ImageStoreTests(unittest.TestCase):
self.assertEqual(runner.options["input_text"], "secret-value\n")
self.assertTrue(runner.options["sensitive"])
def test_rejects_invalid_usernames_before_accessing_the_guest_disk(self) -> None:
runner = PasswordHashRunner()
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
for username in ("../root", "user\nroot", "user:1000", "user name"):
with self.subTest(username=username), self.assertRaises(ValidationError):
store.provision_credentials(Path("/tmp/rootfs.ext4"), username, "secret")
self.assertEqual(runner.calls, [])
def test_updates_only_the_requested_shadow_entry(self) -> None:
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
@@ -118,6 +130,44 @@ class ImageStoreTests(unittest.TestCase):
self.assertIn("Match User service\n PasswordAuthentication yes", updated)
self.assertNotIn("ChallengeResponseAuthentication yes", updated)
def test_full_ubuntu_sshd_global_directives_stay_outside_match_blocks(self) -> None:
updated = _enable_ssh_password_authentication(
"Include /etc/ssh/sshd_config.d/*.conf\n"
"UsePAM yes\nSubsystem sftp /usr/lib/openssh/sftp-server\n"
"Match Group restricted\n X11Forwarding no\n PasswordAuthentication no\n",
"codemate-user",
)
self.assertLess(updated.index("UsePAM yes"), updated.index("Match User codemate-user"))
self.assertLess(updated.index("Subsystem sftp"), updated.index("Match User codemate-user"))
self.assertLess(updated.index("Match User codemate-user"), updated.index("Match Group restricted"))
self.assertIn("Match Group restricted\n X11Forwarding no", updated)
@unittest.skipUnless(
shutil.which("sshd") and shutil.which("ssh-keygen") and Path("/run/sshd").is_dir(),
"OpenSSH server tools and the privilege-separation directory are required",
)
def test_openssh_accepts_the_full_config_and_limits_password_auth_to_the_guest_user(self) -> None:
runner = CommandRunner(emit=None)
with tempfile.TemporaryDirectory() as temporary:
key = Path(temporary) / "host_key"
runner.run(("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key), capture=True)
contents = _enable_ssh_password_authentication(
"UsePAM yes\nSubsystem sftp internal-sftp\nPermitRootLogin no\n",
"codemate-user",
)
for key_type in ("rsa", "ecdsa", "ed25519"):
contents = contents.replace(f"/etc/ssh/ssh_host_{key_type}_key", str(key))
config = Path(temporary) / "sshd_config"
config.write_text(contents, encoding="utf-8")
for username, expected in (("codemate-user", "yes"), ("root", "no")):
with self.subTest(username=username):
result = runner.run((
shutil.which("sshd"), "-T", "-f", config,
"-C", f"user={username},host=localhost,addr=127.0.0.1",
), capture=True)
self.assertIn(f"passwordauthentication {expected}\n", result.stdout)
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
own_key = "ssh-ed25519 AAAA-own-key developer@example"
@@ -187,3 +237,212 @@ class ImageStoreTests(unittest.TestCase):
)
self.assertEqual(runner.calls, [])
def test_directory_creation_checks_effects_even_when_debugfs_reports_success(self) -> None:
class FailedMkdirRunner:
@staticmethod
def run(command, **_options) -> CommandResult:
return CommandResult(
args=tuple(map(str, command)),
returncode=0,
stderr="File not found by ext2_lookup" if "stat " in str(command[2]) else "",
)
store = ImageStore(Settings(), runner=FailedMkdirRunner()) # type: ignore[arg-type]
with self.assertRaisesRegex(UvmError, "could not verify guest rootfs directory"):
store._create_guest_directory(Path("/tmp/rootfs.ext4"), "/home/user", 0o700, 1000, 1000)
@unittest.skipUnless(
all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp", "openssl", "ssh-keygen")),
"e2fsprogs, coreutils, OpenSSL, and OpenSSH are required for offline credential checks",
)
class OfflineGuestCredentialsTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.template = self.base / "template.ext4"
self.disk = self.base / "rootfs.ext4"
self.runner = CommandRunner(emit=None)
self.store = ImageStore(Settings(base=self.base), self.runner)
self.files = {
"/etc/passwd": (
"root:x:0:0:root:/root:/bin/bash\n"
"service:x:999:1002::/nonexistent:/usr/sbin/nologin\n"
"ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash\n"
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n"
),
"/etc/shadow": (
"root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
"ubuntu:!:2:0:99999:7:::\nnobody:*:1:0:99999:7:::\n"
),
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n",
"/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n",
"/etc/sudoers": "Defaults env_reset\nroot ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n",
"/usr/bin/sudo": "sudo binary fixture\n",
"/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n",
"/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
}
def make_disk(self) -> None:
root = self.base / "guest"
for guest_path, contents in self.files.items():
path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
mode = {"/etc/shadow": 0o640, "/etc/gshadow": 0o640, "/etc/sudoers": 0o440, "/usr/bin/sudo": 0o4755}
path.chmod(mode.get(guest_path, 0o644))
with self.template.open("wb") as image:
image.truncate(16 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
for guest_path in self.files:
gid = 42 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0
for field, value in (("uid", 0), ("gid", gid)):
self.runner.run(
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", self.template),
capture=True,
)
self.store._copy_sparse(self.template, self.disk)
def contents(self, guest_path: str) -> str:
return self.runner.run(("debugfs", "-R", f"cat {guest_path}", self.disk), capture=True).stdout
def entry(self, guest_path: str, username: str) -> list[str]:
return next(line.split(":") for line in self.contents(guest_path).splitlines()
if line.split(":", 1)[0] == username)
def assert_metadata(self, guest_path: str, mode: int, uid: int, gid: int) -> None:
result = self.runner.run(("debugfs", "-R", f"stat {guest_path}", self.disk), capture=True)
self.assertRegex(result.stdout, rf"Mode:\s+0*{mode:o}\b")
self.assertRegex(result.stdout, rf"User:\s+{uid}\s+Group:\s+{gid}\b")
def assert_password(self, username: str, password: str) -> None:
password_hash = self.entry("/etc/shadow", username)[1]
self.assertTrue(password_hash.startswith("$6$"))
result = self.runner.run(
("openssl", "passwd", "-6", "-salt", password_hash.split("$")[2], "-stdin"),
capture=True, input_text=f"{password}\n", sensitive=True,
)
self.assertEqual(password_hash, result.stdout.strip())
def test_custom_username_creates_a_login_account_on_the_private_disk(self) -> None:
self.make_disk()
template_digest = hashlib.sha256(self.template.read_bytes()).hexdigest()
day_before = int(time.time() // 86400)
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
# UID 1000 and GIDs 1000/1001 are occupied; GID 1002 is used only in passwd.
self.assertEqual(
self.contents("/etc/passwd"),
self.files["/etc/passwd"] + "codemate-user:x:1001:1003::/home/codemate-user:/bin/sh\n",
)
self.assertEqual(self.contents("/etc/group"), self.files["/etc/group"] + "codemate-user:x:1003:\n")
self.assertEqual(self.contents("/etc/gshadow"), self.files["/etc/gshadow"] + "codemate-user:!::\n")
self.assertTrue(self.contents("/etc/shadow").startswith(self.files["/etc/shadow"]))
shadow = self.entry("/etc/shadow", "codemate-user")
self.assertLessEqual(day_before, int(shadow[2]))
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
self.assert_password("codemate-user", "custom-password")
self.assertIn("codemate-user ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
self.assertIn("Defaults:codemate-user !rootpw, !runaspw, !targetpw\n", self.contents("/etc/sudoers"))
self.assert_metadata("/etc/sudoers", 0o440, 0, 0)
self.assert_metadata("/usr/bin/sudo", 0o4755, 0, 0)
if shutil.which("visudo"):
sudoers = self.base / "sudoers"
sudoers.write_text(self.contents("/etc/sudoers"), encoding="utf-8")
sudoers.chmod(0o440)
self.runner.run(("visudo", "-c", "-f", sudoers), capture=True)
for guest_path in ("/etc/passwd", "/etc/group"):
self.assert_metadata(guest_path, 0o644, 0, 0)
for guest_path in ("/etc/shadow", "/etc/gshadow"):
self.assert_metadata(guest_path, 0o640, 0, 42)
self.assert_metadata("/home", 0o755, 0, 0)
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1003)
sshd_config = self.contents("/etc/ssh/sshd_config")
self.assertIn("Match User codemate-user\n PasswordAuthentication yes\n", sshd_config)
self.assertIn("PermitRootLogin no", sshd_config)
self.assertEqual(self.contents("/root/.ssh/authorized_keys"), "ssh-ed25519 AAAA-own-key\n")
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
self.assertEqual(hashlib.sha256(self.template.read_bytes()).hexdigest(), template_digest)
def test_existing_root_and_non_root_accounts_keep_their_identity_and_home(self) -> None:
self.files["/home/ubuntu/data"] = "existing guest data\n"
self.make_disk()
for username in ("root", "ubuntu"):
with self.subTest(username=username):
self.store._copy_sparse(self.template, self.disk)
original_shadow = self.entry("/etc/shadow", username)
self.store.provision_credentials(self.disk, username, "changed-password")
for guest_path in ("/etc/passwd", "/etc/group", "/etc/gshadow", "/home/ubuntu/data"):
self.assertEqual(self.contents(guest_path), self.files[guest_path])
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
self.assert_password(username, "changed-password")
if username != "root":
self.assertIn(f"{username} ALL=(ALL:ALL) PASSWD: ALL\n", self.contents("/etc/sudoers"))
else:
self.assertEqual(self.contents("/etc/sudoers"), self.files["/etc/sudoers"])
for line in self.files["/etc/shadow"].splitlines():
if line.split(":", 1)[0] != username:
self.assertIn(line + "\n", self.contents("/etc/shadow"))
def test_new_user_reuses_an_existing_named_group(self) -> None:
self.files["/etc/group"] += "codemate-user:x:1100:ubuntu\n"
self.files["/etc/gshadow"] += "codemate-user:!::ubuntu\n"
self.make_disk()
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1100"])
for guest_path in ("/etc/group", "/etc/gshadow"):
self.assertEqual(self.contents(guest_path), self.files[guest_path])
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1100)
def test_new_user_supports_images_without_gshadow_and_without_final_newlines(self) -> None:
del self.files["/etc/gshadow"]
for guest_path in ("/etc/passwd", "/etc/shadow", "/etc/group"):
self.files[guest_path] = self.files[guest_path].rstrip("\n")
self.files["/home/retained"] = "existing home directory contents\n"
self.make_disk()
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1003"])
self.assertEqual(self.entry("/etc/group", "codemate-user"), ["codemate-user", "x", "1003", ""])
self.assert_password("codemate-user", "custom-password")
self.assertEqual(self.contents("/home/retained"), self.files["/home/retained"])
self.assertIsNone(self.store._read_guest_file(
self.disk, "/etc/gshadow", self.base / "gshadow", required=False,
))
def test_existing_home_path_is_not_overwritten(self) -> None:
self.files["/home/codemate-user"] = "existing guest file\n"
self.make_disk()
with self.assertRaisesRegex(UvmError, "cannot create guest rootfs directory: /home/codemate-user"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"])
self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"])
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
def test_custom_users_fail_with_an_actionable_error_on_stripped_images(self) -> None:
del self.files["/usr/bin/sudo"]
del self.files["/etc/sudoers"]
self.make_disk()
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo.*uvm install --force"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
def test_sudo_without_setuid_permission_does_not_produce_a_broken_administrator(self) -> None:
self.make_disk()
self.runner.run(("debugfs", "-w", "-R", "set_inode_field /usr/bin/sudo mode 0100755", self.disk), capture=True)
with self.assertRaisesRegex(UvmError, "guest image needs setuid-root sudo"):
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
+373
View File
@@ -0,0 +1,373 @@
from __future__ import annotations
import hashlib
import io
import json
import os
import shutil
import sys
import tarfile
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.images import ImageStore
from uvm.install import Installer, _GUEST_APT_SCRIPT, _ROOTFS_RECIPE
from uvm.integrity import load_manifest, write_manifest
from uvm.state import StateStore
from uvm.system import CommandResult, CommandRunner
class InstallerArchitectureTests(unittest.TestCase):
def test_installs_matching_native_artifacts_with_a_verified_manifest(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
with (
self.subTest(machine=machine),
tempfile.TemporaryDirectory() as temporary_directory,
patch.dict(os.environ, {"UVM_BASE": temporary_directory}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = Settings.from_environment()
version = settings.firecracker_version
binaries = {
"firecracker": f"firecracker for {architecture}".encode(),
"jailer": f"jailer for {architecture}".encode(),
}
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
for name, content in binaries.items():
member = tarfile.TarInfo(
f"release-{version}-{architecture}/{name}-{version}-{architecture}"
)
member.size = len(content)
member.mode = 0o755
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
kernel_bytes = f"kernel for {architecture}".encode()
rootfs_bytes = f"rootfs for {architecture}".encode()
settings = replace(
settings,
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
rootfs_url=(
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}/rootfs/bionic.rootfs.ext4"
),
)
guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
"https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15/"
f"{architecture}/vmlinux-6.1.155": kernel_bytes,
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
}
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm"),
patch(
"uvm.install.urllib.request.urlopen",
side_effect=lambda url, timeout: io.BytesIO(payloads[url]),
) as download,
):
firecracker, assets = installer.install()
self.assertEqual(firecracker.read_bytes(), binaries["firecracker"])
self.assertEqual(settings.jailer_binary.read_bytes(), binaries["jailer"])
self.assertEqual(assets.kernel.read_bytes(), kernel_bytes)
self.assertEqual(assets.rootfs.read_bytes(), rootfs_bytes)
self.assertEqual(download.call_count, 3)
self.assertEqual({call.args[0] for call in download.call_args_list}, set(payloads))
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums, {
name: hashlib.sha256(content).hexdigest()
for name, content in {
**binaries, "kernel": kernel_bytes, "rootfs": rootfs_bytes,
}.items()
})
def test_arm64_install_requires_checksums_before_host_commands_or_downloads(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
settings = Settings(base=Path(temporary_directory), architecture="aarch64")
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm") as check_kvm,
patch("uvm.install.urllib.request.urlopen") as download,
self.assertRaisesRegex(UvmError, "Firecracker archive checksum is required"),
):
installer.install()
runner.run.assert_not_called()
check_kvm.assert_not_called()
download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists())
def test_default_build_records_the_authenticated_local_image_digest(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
settings = Settings(
base=Path(temporary), firecracker_sha256="1" * 64,
kernel_sha256=hashlib.sha256(b"kernel").hexdigest(),
)
settings.bin_dir.mkdir()
settings.firecracker_binary.write_bytes(b"firecracker")
settings.jailer_binary.write_bytes(b"jailer")
installer = Installer(settings, Mock(spec=CommandRunner), StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"), patch("uvm.install.check_kvm"),
patch.object(installer, "install_apt_packages"),
patch.object(installer, "install_firecracker_binary", return_value=settings.firecracker_binary),
patch.object(installer, "download", side_effect=lambda _url, path, **_kwargs: path.write_bytes(b"kernel")),
patch.object(installer, "_build_ubuntu_rootfs", side_effect=lambda path: path.write_bytes(b"full Ubuntu")) as build,
):
installer.install()
build.assert_called_once_with(settings.rootfs_image)
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums["rootfs"], hashlib.sha256(b"full Ubuntu").hexdigest())
self.assertEqual(ImageStore(settings).installed_assets().rootfs, settings.rootfs_image)
def test_old_rootfs_checksum_requires_an_explicit_prebuilt_url_before_mutations(self) -> None:
settings = Settings(firecracker_sha256="1" * 64, kernel_sha256="2" * 64, rootfs_sha256="3" * 64)
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, Mock(spec=StateStore), emit=None)
with self.assertRaisesRegex(UvmError, "unset UVM_ROOTFS_SHA256"):
installer._validate_integrity_policy()
runner.run.assert_not_called()
def test_only_verified_current_recipe_images_are_reused(self) -> None:
for verified, marker, force in (
(True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False),
(True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True),
(True, "uvm-ubuntu-jammy-v2\n", False),
):
with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"image")
write_manifest(settings.integrity_manifest_path,
{"rootfs": hashlib.sha256(b"image").hexdigest()}, verified=verified)
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("debugfs",), 0, stdout=marker)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with patch.object(installer, "_build_ubuntu_rootfs") as build:
installer._install_ubuntu_rootfs(settings.rootfs_image, force=force)
self.assertEqual(build.called, not (verified and marker == _ROOTFS_RECIPE and not force))
class UbuntuBuildRunner:
"""Simulate package installation; exercise real ext4 formatting and readback."""
def __init__(self, *, failure: str | None = None) -> None:
self.calls: list[tuple[str, ...]] = []
self.failure = failure
self.runner = CommandRunner(emit=None)
def run(self, command, **options) -> CommandResult:
args = tuple(map(str, command))
self.calls.append(args)
if "debootstrap" in args:
root = Path(args[-2])
if self.failure == "bootstrap":
self.write_files(root, {"debootstrap/debootstrap.log": "dpkg: --install needs at least one package archive file argument\n"})
return CommandResult(args, 1, stderr="W: Failure while unpacking the base system\n")
self.write_files(root, {
"etc/passwd": "root:x:0:0::/root:/bin/bash\n",
"etc/shadow": "root:*:1:0:99999:7:::\n", "etc/group": "root:x:0:\n",
"etc/apt/sources.list": "deb https://ports.ubuntu.com/ubuntu-ports jammy main\n",
"usr/bin/apt-get": "apt binary",
"var/lib/dpkg/status": "Package: apt\nStatus: install ok installed\nVersion: 1.0\n",
"var/lib/dpkg/info/apt.list": "/usr/bin/apt-get\n",
"var/log/bootstrap.log": "Base system installed successfully.\n",
})
for relative in ("dev", "proc", "sys", "usr/sbin"):
(root / relative).mkdir(parents=True, exist_ok=True)
(root / "etc/resolv.conf").symlink_to("../run/systemd/resolve/stub-resolv.conf")
# Ubuntu's real script removes this, even if it existed before bootstrap.
(root / "usr/sbin/policy-rc.d").unlink(missing_ok=True)
return CommandResult(args, 0, stdout="I: Base system installed successfully.\n")
if "uvm-guest-packages" in args:
root = Path(args[args.index("uvm-guest-packages") + 1])
if (root / "usr/sbin/policy-rc.d").read_text() != "#!/bin/sh\nexit 101\n":
raise AssertionError("guest services must be disabled during apt provisioning")
sources = (root / "etc/apt/sources.list").read_text()
if "jammy-updates main universe" not in sources or "jammy-security main universe" not in sources:
raise AssertionError("apt must see updates and security before installing guest packages")
if self.failure == "packages":
return CommandResult(args, 100, stdout="Reading package lists...\n", stderr="E: unmet guest dependencies\n")
status = "\n\n".join(
f"Package: {package}\nStatus: install ok installed\nVersion: 1.0"
for package in ("apt", "sudo", "openssh-server", "systemd-sysv", "python3")
) + "\n"
files = {
"etc/sudoers": "root ALL=(ALL:ALL) ALL\n",
"etc/ssh/sshd_config": "UsePAM yes\n",
"etc/ssh/ssh_host_rsa_key": "template host key",
"usr/bin/sudo": "sudo binary",
"usr/bin/python3": "python binary",
"usr/sbin/sshd": "sshd binary", "usr/lib/systemd/systemd": "init binary",
"var/lib/dpkg/status": status,
"var/lib/dpkg/info/sudo.list": "/usr/bin/sudo\n",
}
if self.failure == "metadata":
files["var/lib/dpkg/status"] = ""
self.write_files(root, files)
(root / "usr/bin/sudo").chmod(0o4755)
return CommandResult(args, 0, stdout="Configured guest packages.\n")
if args[0] == "systemctl":
return CommandResult(args, 0)
return self.runner.run(command, **options)
@staticmethod
def write_files(root: Path, files: dict[str, str]) -> None:
for relative, contents in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
class UbuntuAptStageTests(unittest.TestCase):
def test_apt_script_preserves_arguments_and_stops_after_an_update_failure(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
root = base / "guest with spaces"
policy = root / "usr/sbin/policy-rc.d"
policy.parent.mkdir(parents=True)
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
tools = base / "tools"
tools.mkdir()
for name in ("mount", "chroot"):
tool = tools / name
tool.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\nfrom pathlib import Path\n"
"name = Path(sys.argv[0]).name\n"
"print(json.dumps([name, *sys.argv[1:]]))\n"
"if name == 'chroot':\n"
" assert (Path(sys.argv[1]) / 'usr/sbin/policy-rc.d').is_file()\n"
" if os.environ.get('FAIL_UPDATE') == '1' and sys.argv[-1] == 'update':\n"
" sys.exit(100)\n",
encoding="utf-8",
)
tool.chmod(0o755)
runner = CommandRunner(emit=None)
for fail in (False, True):
with self.subTest(fail=fail):
result = runner.run((
"env", f"PATH={tools}", f"FAIL_UPDATE={int(fail)}",
"/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
"uvm-guest-packages", root, "sudo", "openssh-server",
), check=False, capture=True)
events = [json.loads(line) for line in result.stdout.splitlines()]
mounts = [event for event in events if event[0] == "mount"]
self.assertEqual([event[-1] for event in mounts], [
str(root / "dev"), str(root / "dev"), str(root / "proc"), str(root / "sys"),
])
calls = [event for event in events if event[0] == "chroot"]
self.assertEqual(result.returncode, 100 if fail else 0)
self.assertEqual(len(calls), 1 if fail else 3)
self.assertEqual(calls[0][-3:], ["-o", "APT::Update::Error-Mode=any", "update"])
for call in calls:
self.assertEqual(call[1:6], [str(root), "/usr/bin/env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get"])
if not fail:
self.assertEqual(calls[1][-1], "dist-upgrade")
self.assertEqual(calls[2][-3:], ["install", "sudo", "openssh-server"])
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "debugfs")),
"e2fsprogs is required for offline Ubuntu image checks")
class UbuntuRootfsBuildTests(unittest.TestCase):
def test_build_keeps_package_metadata_and_sudo_permissions_with_native_signed_bootstrap(self) -> None:
for architecture, deb_arch, mirror in (
("aarch64", "arm64", "https://ports.ubuntu.com/ubuntu-ports"),
("x86_64", "amd64", "https://archive.ubuntu.com/ubuntu"),
):
with self.subTest(architecture=architecture), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary), architecture=architecture)
runner = UbuntuBuildRunner()
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
installer._build_ubuntu_rootfs(settings.rootfs_image)
bootstrap = next(call for call in runner.calls if "debootstrap" in call)
self.assertIn(f"--arch={deb_arch}", bootstrap)
self.assertEqual(bootstrap[-1], mirror)
self.assertIn("--force-check-gpg", bootstrap)
self.assertIn("--components=main", bootstrap)
self.assertIn("--include=ca-certificates,ubuntu-keyring", bootstrap)
self.assertFalse(any(part.startswith("--extra-suites") for part in bootstrap))
self.assertIn("--mount-proc", bootstrap)
self.assertIn("--kill-child", bootstrap)
packages = next(call for call in runner.calls if "uvm-guest-packages" in call)
self.assertLess(runner.calls.index(bootstrap), runner.calls.index(packages))
self.assertIn("sudo", packages)
self.assertIn("openssh-server", packages)
self.assertIn("python3", packages)
self.assertIn("--mount-proc", packages)
self.assertIn("--kill-child", packages)
self.assertEqual(settings.rootfs_image.stat().st_mode & 0o777, 0o600)
log = settings.images_dir / "ubuntu-build.log"
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
self.assertIn("Base system installed successfully.", log.read_text())
self.assertIn("Configured guest packages.", log.read_text())
def guest(command):
return runner.runner.run(("debugfs", "-R", command, settings.rootfs_image), capture=True)
self.assertIn("Package: apt\nStatus: install ok installed", guest("cat /var/lib/dpkg/status").stdout)
self.assertEqual(guest("cat /var/lib/dpkg/info/sudo.list").stdout, "/usr/bin/sudo\n")
self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755")
self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE)
self.assertIn("socket.AF_VSOCK", guest("cat /usr/lib/uvm/agent.py").stdout)
self.assertIn("Delegate=yes", guest("cat /etc/systemd/system/uvm-agent.service").stdout)
self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout)
self.assertIn("KeepConfiguration=yes", guest("cat /etc/systemd/network/10-uvm.network").stdout)
self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout)
for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"):
self.assertIn("File not found", guest(f"stat {path}").stderr)
def test_failed_build_preserves_the_old_template_and_retains_diagnostics(self) -> None:
for failure in ("bootstrap", "packages", "metadata"):
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"old template")
runner = UbuntuBuildRunner(failure=failure)
installer = Installer(settings, runner, StateStore(settings), emit=None) # type: ignore[arg-type]
log = settings.images_dir / "ubuntu-build.log"
with self.assertRaisesRegex(UvmError, "details saved to .*ubuntu-build.log"):
installer._build_ubuntu_rootfs(settings.rootfs_image)
self.assertEqual(settings.rootfs_image.read_bytes(), b"old template")
self.assertEqual(set(settings.images_dir.iterdir()), {settings.rootfs_image, log})
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
details = log.read_text()
if failure == "bootstrap":
self.assertIn("--install needs at least one package archive", details)
self.assertIn("Failure while unpacking", details)
self.assertFalse(any("uvm-guest-packages" in call for call in runner.calls))
elif failure == "packages":
self.assertIn("Reading package lists...", details)
self.assertIn("E: unmet guest dependencies", details)
self.assertLess(details.index("Base system installed successfully."), details.index("E: unmet guest dependencies"))
else:
self.assertIn("incomplete apt/dpkg package metadata", details)
self.assertFalse(any(call[0] == "mkfs.ext4" for call in runner.calls))
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
import errno
import os
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.test_cli import make_vm
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import Settings
from uvm.domain import PortRule
from uvm.errors import FirecrackerError, UvmError, ValidationError
from uvm.known_hosts import KnownHostsManager, _without_hosts
from uvm.system import CommandRunner
class KnownHostsTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
self.path = self.directory / "known_hosts"
self.vm = replace(make_vm(), ports=(PortRule(22), PortRule(2222)))
self.manager = KnownHostsManager(Settings(known_hosts_files=(self.path,)))
def test_removes_all_vm_key_types_and_aliases_but_preserves_other_hosts(self) -> None:
self.path.write_bytes(
b"# Known hosts\r\n"
b"10.42.0.2 ssh-ed25519 AAAAed first key\n"
b"10.42.0.2 ecdsa-sha2-nistp256 AAAAecdsa second key\n"
b"10.42.0.2 ssh-rsa AAAArsa third key\n"
b"uvm-vm-test ssh-ed25519 AAAAalias\n"
b"[10.42.0.2]:22 ssh-ed25519 AAAAport22\n"
b"[10.42.0.2]:2222 ssh-ed25519 AAAAport2222\n"
b"[10.42.0.2]:8022 ssh-ed25519 AAAAold-port\n"
b"[uvm-vm-test]:2222 ssh-ed25519 AAAAportalias\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,10.42.0.2,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority 10.42.0.2,example.com ssh-ed25519 AAAAca\n"
b"@revoked 10.42.0.2 ssh-ed25519 AAAArevoked\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(),
b"# Known hosts\r\n"
b"10.42.0.20 ssh-ed25519 AAAAother same prefix\n"
b"some-host,another-host ssh-ed25519 AAAAshared\r\n"
b"@cert-authority example.com ssh-ed25519 AAAAca\n"
b"10.42.* ssh-ed25519 AAAAwildcard\n"
b"!10.42.0.2,* ssh-ed25519 AAAAnegated\n"
b"|1|not-base64|invalid ssh-ed25519 AAAAmalformed\n"
b"# non-UTF8 comment: \xff"
)
def test_missing_files_and_unmatched_hosts_are_noops(self) -> None:
missing = self.directory / "missing" / ".ssh" / "known_hosts"
KnownHostsManager(Settings(known_hosts_files=(missing, self.path))).remove(self.vm)
self.assertEqual(list(self.directory.iterdir()), [])
self.path.write_bytes(b"10.42.0.3 ssh-ed25519 AAAAother\n")
before = self.path.stat()
with patch("uvm.known_hosts.os.replace") as publish:
self.manager.remove(self.vm)
publish.assert_not_called()
self.assertEqual(self.path.stat().st_ino, before.st_ino)
self.assertEqual(self.path.stat().st_mtime_ns, before.st_mtime_ns)
def test_rewrite_preserves_ownership_and_file_mode_and_is_idempotent(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.path.chmod(0o640)
before = self.path.stat()
self.manager.remove(self.vm)
after = self.path.stat()
self.assertEqual((after.st_uid, after.st_gid, after.st_mode), (before.st_uid, before.st_gid, before.st_mode))
self.assertEqual(self.path.read_bytes(), b"10.42.0.3 ssh-ed25519 AAAAkeep\n")
self.manager.remove(self.vm)
self.assertEqual(self.path.stat().st_ino, after.st_ino)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_root_rewrite_restores_the_file_owners_uid_and_gid(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
inode = self.path.stat().st_ino
real_fstat, real_stat = os.fstat, os.stat
def ownership(metadata):
if metadata.st_ino != inode:
return metadata
values = list(metadata)
values[4:6] = [12345, 23456]
return os.stat_result(values)
with (
patch("uvm.known_hosts.os.fstat", side_effect=lambda fd: ownership(real_fstat(fd))),
patch("uvm.known_hosts.os.stat", side_effect=lambda *args, **kwargs: ownership(real_stat(*args, **kwargs))),
patch("uvm.known_hosts.os.fchown") as chown,
):
self.manager.remove(self.vm)
self.assertEqual(chown.call_count, 1)
self.assertEqual(chown.call_args.args[1:], (12345, 23456))
self.assertEqual(self.path.read_bytes(), b"")
def test_extended_file_attributes_survive_replacement(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
try:
os.setxattr(self.path, "user.uvm-fixture", b"preserved")
except OSError as error:
if error.errno in (errno.ENOTSUP, errno.EPERM):
self.skipTest("extended file attributes are unavailable")
raise
self.manager.remove(self.vm)
self.assertEqual(os.getxattr(self.path, "user.uvm-fixture"), b"preserved")
def test_default_files_include_root_and_sudo_user_from_passwd_not_home(self) -> None:
root = SimpleNamespace(pw_uid=0, pw_dir="/root")
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", side_effect=lambda uid: {0: root, 1000: user}[uid]),
patch.dict(os.environ, {"HOME": "/wrong-home", "SUDO_UID": "1000"}, clear=True),
):
paths = KnownHostsManager(Settings())._files()
self.assertEqual(paths, {
Path("/root/.ssh/known_hosts"): 0, Path("/root/.ssh/known_hosts2"): 0,
Path("/home/operator/.ssh/known_hosts"): 1000, Path("/home/operator/.ssh/known_hosts2"): 1000,
})
def test_sudo_user_fallback_and_nonroot_context_are_scoped_to_the_launcher(self) -> None:
user = SimpleNamespace(pw_uid=1000, pw_dir="/home/operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=0),
patch("uvm.known_hosts.pwd.getpwuid", return_value=SimpleNamespace(pw_uid=0, pw_dir="/root")),
patch("uvm.known_hosts.pwd.getpwnam", return_value=user) as lookup,
patch.dict(os.environ, {"SUDO_USER": "operator"}, clear=True),
):
self.assertIn(Path("/home/operator/.ssh/known_hosts"), KnownHostsManager(Settings())._files())
lookup.assert_called_once_with("operator")
with (
patch("uvm.known_hosts.os.geteuid", return_value=1000),
patch("uvm.known_hosts.pwd.getpwuid", return_value=user),
patch.dict(os.environ, {"SUDO_UID": "9999"}, clear=True),
):
self.assertEqual(set(KnownHostsManager(Settings())._files()), {
Path("/home/operator/.ssh/known_hosts"), Path("/home/operator/.ssh/known_hosts2"),
})
def test_publication_failure_preserves_original_and_removes_temporary_file(self) -> None:
contents = b"10.42.0.2 ssh-ed25519 AAAAold\n"
self.path.write_bytes(contents)
with patch("uvm.known_hosts.os.replace", side_effect=OSError("publish failed")):
with self.assertRaisesRegex(UvmError, "publish failed"):
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), contents)
self.assertEqual(list(self.directory.iterdir()), [self.path])
def test_concurrent_append_is_not_lost_and_cleanup_can_be_retried(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
addition = b"10.42.0.4 ssh-ed25519 AAAAconcurrent\n"
def append(contents, hosts, candidates):
with self.path.open("ab") as output:
output.write(addition)
return _without_hosts(contents, hosts, candidates)
with patch("uvm.known_hosts._without_hosts", side_effect=append):
with self.assertRaisesRegex(UvmError, "changed during cleanup"):
self.manager.remove(self.vm)
self.assertTrue(self.path.read_bytes().endswith(addition))
self.manager.remove(self.vm)
self.assertEqual(self.path.read_bytes(), addition)
def test_temporary_name_collision_never_removes_a_foreign_file(self) -> None:
self.path.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
foreign = self.directory / ".known_hosts.uvm-collision"
foreign.write_bytes(b"keep this")
with patch("uvm.known_hosts.uuid.uuid4", return_value=SimpleNamespace(hex="collision")):
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(foreign.read_bytes(), b"keep this")
self.assertIn(b"10.42.0.2", self.path.read_bytes())
def test_symlink_and_hardlinked_files_are_not_rewritten(self) -> None:
target = self.directory / "target"
target.write_bytes(b"10.42.0.2 ssh-ed25519 AAAAold\n")
for kind in ("symlink", "hardlink"):
with self.subTest(kind=kind):
self.path.unlink(missing_ok=True)
if kind == "symlink":
self.path.symlink_to(target)
else:
self.path.hardlink_to(target)
with self.assertRaises(UvmError):
self.manager.remove(self.vm)
self.assertEqual(target.read_bytes(), b"10.42.0.2 ssh-ed25519 AAAAold\n")
@unittest.skipUnless(shutil.which("ssh-keygen"), "OpenSSH is required for hashed-host interoperability")
def test_removes_real_openssh_hashed_hosts_for_all_guest_keys_and_keeps_other_hosts(self) -> None:
runner = CommandRunner(emit=None)
lines = []
for key_type in ("ed25519", "ecdsa"):
key = self.directory / key_type
runner.run(("ssh-keygen", "-q", "-t", key_type, "-N", "", "-f", key), capture=True)
public = key.with_suffix(".pub").read_text().strip()
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222", "10.42.0.3"):
lines.append(f"{host} {public}\n")
self.path.write_text("".join(lines))
runner.run(("ssh-keygen", "-H", "-f", self.path), capture=True)
self.assertNotIn(b"10.42.0.2", self.path.read_bytes())
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True)
self.manager.remove(self.vm)
for host in ("10.42.0.2", "uvm-vm-test", "[10.42.0.2]:2222", "[uvm-vm-test]:2222"):
result = runner.run(("ssh-keygen", "-F", host, "-f", self.path), capture=True, check=False)
self.assertEqual(result.returncode, 1)
runner.run(("ssh-keygen", "-F", "10.42.0.3", "-f", self.path), capture=True)
class DeleteKnownHostsTests(LifecycleTestCase):
def test_deletion_cleans_keys_after_teardown_before_releasing_the_ip(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.destroy(vm.id)
self.assert_call_order(vm, "process.gone", "network.delete_vm_tap", "firewall.remove", "known_hosts.remove")
self.assertEqual(self.host.records["known_hosts.remove", vm.id].operation, "delete")
self.assertNotIn(vm.id, self.store.load().vms)
def test_stop_restart_and_resize_retain_host_keys(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
self.service.start(vm.id)
self.service.restart(vm.id)
self.service.resize(vm.id, ram_mib=1024)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_failed_stop_does_not_remove_host_keys(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("still alive")
with self.assertRaises(UvmError):
self.service.destroy(vm.id)
self.assertNotIn("known_hosts.remove", self.host.actions(vm.id))
def test_cleanup_error_retains_the_ip_reservation_and_delete_retries(self) -> None:
vm = self.create_vm()
self.host.failures["known_hosts.remove", vm.id] = UvmError("known_hosts is not writable")
with self.assertRaisesRegex(UvmError, "known_hosts is not writable"):
self.service.destroy(vm.id)
self.assertEqual(self.store.get(vm.id).operation, "delete")
self.assertFalse(Path(vm.disk).exists())
with self.assertRaises(ValidationError):
self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.service.destroy(vm.id)
created = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(created.guest_ip, vm.guest_ip)
def test_delete_removes_real_cache_entries_and_allows_reuse_without_stale_keys(self) -> None:
vm = self.create_vm()
cache = self.settings.base / "known_hosts"
cache.write_text(f"{vm.guest_ip} ssh-ed25519 AAAAold\n10.42.0.99 ssh-ed25519 AAAAother\n")
self.service._known_hosts = KnownHostsManager(replace(self.settings, known_hosts_files=(cache,)))
self.service.destroy(vm.id)
self.assertEqual(cache.read_text(), "10.42.0.99 ssh-ed25519 AAAAother\n")
replacement = self.create_vm(guest_ip=IPv4Address(vm.guest_ip))
self.assertEqual(replacement.guest_ip, vm.guest_ip)
def test_bulk_delete_uses_the_same_known_hosts_cleanup(self) -> None:
vms = [self.create_vm(), self.create_vm()]
results = self.service.run_many("delete", [vm.id for vm in vms])
self.assertTrue(all(result.outcome == "succeeded" for result in results))
for vm in vms:
self.assertIn("known_hosts.remove", self.host.actions(vm.id))
+778 -66
View File
@@ -1,110 +1,372 @@
from __future__ import annotations
import json
import shutil
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 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 FakeKnownHosts:
def __init__(self, host: FakeHost) -> None:
self.host = host
def remove(self, vm: VmRecord) -> None:
self.host.call("known_hosts.remove", vm.id)
if Path(vm.socket).parent.exists():
raise AssertionError("known_hosts cleanup must follow VM runtime deletion")
class FakeFirewall:
def __init__(self, host: FakeHost, network: FakeNetwork) -> None:
self.host = host
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.disk_sizes: dict[Path, int] = {}
self.enterContext(patch.object(self.images, "_copy_sparse", side_effect=shutil.copy2))
self.filesystem_resize = self.enterContext(patch.object(
self.images, "_resize_filesystem",
side_effect=lambda disk, capacity: self.disk_sizes.update({disk: capacity * 1024 * 1024}),
))
self.enterContext(patch.object(
self.images, "disk_size_bytes",
side_effect=lambda disk: self.disk_sizes.get(disk, disk.stat().st_size),
))
self.resize_disk = self.enterContext(patch.object(
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
))
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
self.configure_network = self.enterContext(patch.object(
self.images, "configure_guest_network", side_effect=self.configure_guest_network,
))
self.provision_agent = self.enterContext(patch.object(self.images, "provision_guest_agent"))
self.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
))
self.network = FakeNetwork(self.settings, self.host)
self.process = FakeProcess(self.host)
self.known_hosts = FakeKnownHosts(self.host)
self.clients: dict[str, FakeClient] = {}
self.service = LifecycleService(
self.settings,
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]
known_hosts=self.known_hosts, # 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 resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
vm_id = disk.parent.name
self.host.call("images.resize_disk", vm_id)
if self.process.observe(self.store.get(vm_id)) != "gone":
raise AssertionError("cannot resize a running or ambiguous VM")
self.disk_sizes[disk] = storage_mib * 1024 * 1024
def configure_guest_network(self, disk: Path, guest_ip: str) -> None:
vm = self.store.get(disk.parent.name)
self.host.call("images.configure_network", vm.id)
if self.process.observe(vm) != "gone":
raise AssertionError("cannot configure a running or ambiguous VM disk")
if guest_ip != vm.guest_ip:
raise AssertionError("guest network must use the VM's retained address")
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 +377,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 +398,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 +430,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 +451,447 @@ 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, "images.configure_network", "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_guest_network_configuration_failure_rolls_back_before_marking_the_disk_ready(self) -> None:
self.host.failures["images.configure_network", None] = UvmError("guest network verification failed")
with self.assertRaisesRegex(UvmError, "guest network verification failed"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertFalse(self.network.created_taps)
self.assertFalse(self.process.started)
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_retained_guest_network_is_configured_after_exit_and_before_boot(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.restart(vm.id)
self.assert_call_order(vm, "images.configure_network", "process.start", "client.configure")
actions = self.host.actions(vm.id)
self.assertLess(max(index for index, action in enumerate(actions) if action == "process.gone"),
actions.index("images.configure_network"))
self.configure_network.assert_called_with(Path(vm.disk), vm.guest_ip)
self.assertEqual(self.provision.call_count, 1)
def test_live_start_does_not_modify_the_guest_disk(self) -> None:
vm = self.create_vm()
self.configure_network.reset_mock()
self.service.start(vm.id)
self.configure_network.assert_not_called()
def test_failed_retained_network_configuration_keeps_disk_for_retry(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
disk = Path(vm.disk)
disk.write_bytes(b"retained data and credentials")
self.host.failures["images.configure_network", vm.id] = UvmError("network verification failed")
with self.assertRaisesRegex(UvmError, "network verification failed"):
self.service.start(vm.id)
self.assertEqual(disk.read_bytes(), b"retained data and credentials")
self.assertEqual(self.process.observe(self.store.get(vm.id)), "gone")
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.service.start(vm.id).status, "running")
self.assertEqual(self.provision.call_count, 1)
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_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,
known_hosts=self.known_hosts,
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,
known_hosts=self.known_hosts,
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"):
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.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")):
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)
+660
View File
@@ -0,0 +1,660 @@
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}, "must not impersonate generated VM IDs"),
({"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_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")
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_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):
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_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()
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_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)
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.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.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)))
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_and_egress_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.assertIn(("network.ensure_bridge", None), self.host.events)
self.assertLess(self.host.events.index(("firewall.apply", vm.id)),
self.host.events.index(("network.ensure_bridge", None)))
self.assertEqual(self.provision.call_count, 1)
self.assertEqual(self.copy_disk.call_count, 1)
def test_live_start_rejects_changed_network_settings_before_repairing_network(self) -> None:
vm = self.create_vm()
self.service._settings = replace(self.settings, bridge="another-bridge")
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaisesRegex(ConflictError, "retained network"):
self.service.start(vm.id)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertNotIn(("network.ensure_bridge", None), self.host.events)
self.assertNotIn("firewall.apply", self.host.actions(vm.id))
def test_live_start_can_retry_an_egress_setup_failure_with_the_same_process(self) -> None:
vm = self.create_vm()
self.host.failures["network.ensure_bridge", None] = UvmError("egress setup failed")
with self.assertRaisesRegex(UvmError, "egress setup failed"):
self.service.start(vm.id)
self.assertEqual(self.process.observe(self.store.get(vm.id)), "alive")
self.assertEqual(self.network.firewall.status(vm), "enforced")
self.assertEqual(self.service.start(vm.id).pid, vm.pid)
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.provision.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:
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)
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,
known_hosts=self.known_hosts)
self.assertEqual(service.list_vms(), [])
self.assertEqual(service.resolve_targets([], all_vms=True), [])
for method in (service.inspect, service.find_for_ssh, service.log_path):
with self.assertRaises(NotFoundError):
method("missing")
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")
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)])
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from html.parser import HTMLParser
from types import SimpleNamespace
from unittest.mock import Mock, patch
from nest.cli import main
from nest.server import create_app, run_server
from tests.test_server import FASTAPI_AVAILABLE, FakeLifecycle, fake_application
from uvm.errors import UvmError
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
class NestCliTests(unittest.TestCase):
def test_serve_launches_the_nest_workspace(self) -> None:
application = fake_application(api_token="test-token")
with (
patch("nest.cli.build_application", return_value=application),
patch("nest.server.run_server") as launch,
):
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
self.assertEqual(result, 0)
launch.assert_called_once_with(application, host="127.0.0.1", port=8123)
def test_invalid_invocations_do_not_build_host_services(self) -> None:
for arguments in (
[], ["--serve", "list"], ["--serve", "--port", "0"],
["--serve", "--port", "65536"], ["--serve", "--port", "1.5"],
):
with self.subTest(arguments=arguments), redirect_stderr(io.StringIO()):
with patch("nest.cli.build_application") as build:
with self.assertRaises(SystemExit) as raised:
main(arguments)
self.assertEqual(raised.exception.code, 2)
build.assert_not_called()
def test_startup_errors_are_reported_as_nest_errors(self) -> None:
output = io.StringIO()
with (
patch("nest.cli.build_application", side_effect=UvmError("invalid settings")),
redirect_stderr(output),
):
result = main(["--serve"])
self.assertEqual(result, 1)
self.assertEqual(output.getvalue(), "nest: error: invalid settings\n")
def test_factory_requires_an_explicit_host(self) -> None:
with self.assertRaisesRegex(UvmError, "nest --serve"):
create_app(fake_application(api_token="test-token"))
def test_workspace_enforces_backend_token_and_tls_requirements(self) -> None:
for token, host in ((None, "127.0.0.1"), ("test-token", "0.0.0.0")):
for launch in (create_app, run_server):
with self.subTest(token=token, host=host, launch=launch.__name__):
options = {"port": 8000} if launch is run_server else {}
with self.assertRaises(UvmError):
launch(fake_application(api_token=token), host=host, **options)
class _DashboardAssets(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.urls: list[str] = []
def handle_starttag(self, tag, attrs) -> None:
attributes = dict(attrs)
if tag == "link" and attributes.get("rel") in {"preload", "stylesheet"}:
self.urls.append(attributes["href"])
if tag == "script" and attributes.get("src"):
self.urls.append(attributes["src"])
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for workspace tests")
class NestWorkspaceTests(unittest.TestCase):
def setUp(self) -> None:
self.application = fake_application(api_token="test-token")
self.application.lifecycle = FakeLifecycle()
self.application.installer = SimpleNamespace(install=Mock())
self.headers = {"X-UVM-Token": "test-token"}
self.api = create_app(self.application, host="127.0.0.1")
self.client = TestClient(self.api)
self.addCleanup(self.client.close)
def test_nest_dashboard_and_its_linked_assets_are_public(self) -> None:
root = self.client.get("/", follow_redirects=False)
self.assertEqual(root.status_code, 307)
self.assertEqual(root.headers["location"], "/dashboard")
dashboard = self.client.get(root.headers["location"])
self.assertEqual(dashboard.status_code, 200)
self.assertIn("<title>Nest</title>", dashboard.text)
self.assertIn("Welcome to Nest", dashboard.text)
self.assertNotIn("test-token", dashboard.text)
assets = _DashboardAssets()
assets.feed(dashboard.text)
self.assertTrue(assets.urls)
for url in assets.urls:
with self.subTest(url=url):
self.assertTrue(url.startswith("/dashboard/static/"))
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.content)
self.assertNotIn("text/html", response.headers["content-type"])
font = self.client.get("/dashboard/static/fonts/manrope-latin.woff2")
self.assertTrue(font.content.startswith(b"wOF2"))
license_response = self.client.get("/dashboard/static/fonts/OFL.txt")
self.assertEqual(license_response.status_code, 200)
self.assertIn("SIL OPEN FONT LICENSE", license_response.text)
self.assertEqual(self.client.get("/dashboard/static/missing.js").status_code, 404)
def test_machine_controls_use_the_authenticated_backend(self) -> None:
for method, path in (
("GET", "/vms"), ("GET", "/diagnostics"),
("POST", "/vms/vm-test/stop"), ("POST", "/install"), ("POST", "/exit"),
):
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.application.installer.install.assert_not_called()
self.assertEqual(self.application.lifecycle.vm.status, "running")
listed = self.client.get("/vms", headers=self.headers)
self.assertEqual(listed.status_code, 200)
self.assertEqual(listed.json()[0]["id"], "vm-test")
self.assertNotIn("password", listed.json()[0])
stopped = self.client.post("/vms/vm-test/stop", headers=self.headers)
self.assertEqual(stopped.status_code, 200)
self.assertEqual(stopped.json()["status"], "stopped")
self.assertEqual(self.application.lifecycle.vm.status, "stopped")
def test_workspace_preserves_api_errors_and_liveness(self) -> None:
missing = self.client.get("/vms/missing", headers=self.headers)
self.assertEqual(missing.status_code, 404)
self.assertEqual(missing.json(), {"error": {"message": "VM not found: missing"}})
health = self.client.get("/health")
self.assertEqual(health.status_code, 200)
self.assertEqual(health.json(), {"status": "ok"})
def test_workspace_api_reference_includes_the_dashboard_and_machine_routes(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(schema["info"]["title"], "Nest")
for path in ("/", "/dashboard", "/vms", "/diagnostics", "/vms/{vm_id}/resize"):
self.assertIn(path, schema["paths"])
def test_launcher_serves_nest_and_wires_graceful_shutdown(self) -> None:
server = SimpleNamespace(run=Mock(), should_exit=False)
config = object()
fake_uvicorn = SimpleNamespace(Config=Mock(return_value=config), Server=Mock(return_value=server))
with patch.dict(sys.modules, {"uvicorn": fake_uvicorn}):
run_server(self.application, host="127.0.0.1", port=8123)
api = fake_uvicorn.Config.call_args.args[0]
fake_uvicorn.Config.assert_called_once_with(api, host="127.0.0.1", port=8123)
fake_uvicorn.Server.assert_called_once_with(config)
server.run.assert_called_once_with()
self.assertIs(api.state.uvicorn_server, server)
with TestClient(api) as client:
self.assertIn("<title>Nest</title>", client.get("/dashboard").text)
response = client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"status": "stopping"})
self.assertTrue(server.should_exit)
+429 -71
View File
@@ -1,35 +1,93 @@
from __future__ import annotations
import json
import unittest
from copy import deepcopy
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from unittest.mock import patch
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] = {}
self.default_routes = "default via 192.0.2.1 dev eth0\n"
self.rules: dict[tuple[str, str], list[tuple[str, ...]]] = {
("nat", "POSTROUTING"): [], ("filter", "FORWARD"): [],
}
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":
if args[1:4] != ("--wait", "5", "-t"):
raise AssertionError(f"unexpected iptables options: {args}")
table, operation, chain, *rule = args[4:]
rules = self.rules[table, chain]
if operation == "-C":
return CommandResult(args=args, returncode=0 if tuple(rule) in rules else 1)
if operation == "-I":
position, *rule = rule
rules.insert(int(position) - 1, tuple(rule))
else:
raise AssertionError(f"unexpected iptables mutation: {args}")
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, stdout=self.default_routes)
return CommandResult(args=args, returncode=0)
@@ -37,13 +95,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 +106,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):
@@ -77,44 +142,61 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
self.runner.calls,
)
self.assertIn(
(
"iptables",
"-A",
"FORWARD",
"-i",
"uvm0",
"-o",
"eth0",
"-s",
"10.42.0.0/24",
"-j",
"ACCEPT",
),
self.runner.calls,
)
checks = [
call
for call in self.runner.calls
if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING")
self.assertIn(("sysctl", "-w", "net.ipv4.ip_forward=1"), self.runner.calls)
nat, = self.runner.rules["nat", "POSTROUTING"]
self.assertEqual(nat[:6], ("-s", "10.42.0.0/24", "!", "-o", "uvm0", "-m"))
self.assertEqual(nat[-2:], ("-j", "MASQUERADE"))
inbound, outbound = self.runner.rules["filter", "FORWARD"]
self.assertEqual(outbound[:4], ("-i", "uvm0", "-s", "10.42.0.0/24"))
self.assertEqual(inbound[:8], (
"-o", "uvm0", "-d", "10.42.0.0/24", "-m", "conntrack",
"--ctstate", "ESTABLISHED,RELATED",
))
self.assertEqual(inbound[-2:], ("-j", "ACCEPT"))
self.assertEqual(outbound[-2:], ("-j", "ACCEPT"))
def test_egress_is_installed_offline_and_survives_uplink_changes_without_extra_rules(self) -> None:
self.runner.default_routes = ""
self.network.ensure_bridge()
offline_rules = deepcopy(self.runner.rules)
self.assertEqual([len(rules) for rules in offline_rules.values()], [1, 2])
for route in (
"default via 192.0.2.1 dev eth0\n",
"default via 198.51.100.1 dev wlan0\n",
"default dev tun0\ndefault via 198.51.100.1 dev wlan0 metric 600\n",
"",
):
with self.subTest(route=route):
self.runner.default_routes = route
self.runner.calls.clear()
self.network.ensure_bridge()
self.assertEqual(self.runner.rules, offline_rules)
self.assertFalse(any("-I" in call for call in self.runner.calls))
self.assertFalse(any("eth0" in rule or "wlan0" in rule or "tun0" in rule
for rules in offline_rules.values() for rule in rules))
def test_egress_exceptions_precede_existing_drops_and_preserve_foreign_and_legacy_rules(self) -> None:
existing = [
("-j", "HOST-FIREWALL"), ("-j", "DROP"),
("-i", "uvm0", "-o", "eth0", "-s", "10.42.0.0/24", "-j", "ACCEPT"),
]
self.assertEqual(len(checks), 1)
self.assertIn(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
"10.42.0.0/24",
"-o",
"eth0",
"-j",
"MASQUERADE",
),
self.runner.calls,
)
self.runner.rules["filter", "FORWARD"] = list(existing)
self.network.ensure_bridge()
rules = list(self.runner.rules["filter", "FORWARD"])
self.assertEqual(rules[2:], existing)
self.assertTrue(all("uvm-egress" in rule and rule[-1] == "ACCEPT" for rule in rules[:2]))
self.network.ensure_bridge()
self.assertEqual(self.runner.rules["filter", "FORWARD"], rules)
def test_iptables_inspection_errors_are_not_treated_as_missing_rules(self) -> None:
check = ("iptables", "--wait", "5", "-t", "nat", "-C", "POSTROUTING",
"-s", "10.42.0.0/24", "!", "-o", "uvm0",
"-m", "comment", "--comment", "uvm-egress", "-j", "MASQUERADE")
with patch.object(self.runner, "run", return_value=CommandResult(check, 4, stderr="permission denied")) as run:
with self.assertRaisesRegex(CommandError, "could not inspect.*permission denied"):
self.network._ensure_masquerade()
self.assertEqual(run.call_count, 1)
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
settings = Settings(
@@ -128,21 +210,27 @@ class NetworkManagerTests(unittest.TestCase):
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
runner.calls,
)
for rules in runner.rules.values():
for rule in rules:
self.assertIn("10.50.0.0/16", rule)
self.assertNotIn("10.42.0.0/24", rule)
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 +239,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(ConflictError):
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)
+1278 -39
View File
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from tests.test_lifecycle import LifecycleTestCase
from uvm.config import MIN_STORAGE_MIB
from uvm.domain import PortRule, VmResources, VmSpec
from uvm.errors import ConflictError, FirecrackerError, UvmError, ValidationError
class LifecycleResizeTests(LifecycleTestCase):
def test_creation_provisions_the_default_or_requested_storage(self) -> None:
for capacity in (MIN_STORAGE_MIB, 80 * 1024):
with self.subTest(capacity=capacity):
vm = self.create_vm(storage_mib=capacity)
self.copy_disk.assert_called_with(
self.settings.rootfs_image, Path(vm.disk), storage_mib=capacity,
)
self.filesystem_resize.assert_called_with(Path(vm.disk), capacity)
self.assertEqual(vm.storage_mib, capacity)
self.assertEqual(self.store.get(vm.id).storage_mib, capacity)
self.assertEqual(VmSpec(cpu=1, ram_mib=512).storage_mib, MIN_STORAGE_MIB)
def test_stop_resize_start_preserves_disk_data_credentials_and_network_identity(self) -> None:
vm = self.create_vm(name="database", username="admin", password="same-secret",
ports=(PortRule(22), PortRule(5432)))
sibling = self.create_vm(name="sibling")
sibling_before = self.store.get(sibling.id).to_dict()
original = replace(vm)
disk = Path(vm.disk)
contents = b"guest data\nunchanged password hash\noriginal SSH host keys\n"
disk.write_bytes(contents)
for cpu, ram_mib, storage_mib in ((2, 4096, 50 * 1024), (0.5, 256, MIN_STORAGE_MIB)):
with self.subTest(cpu=cpu, storage=storage_mib):
previous_pid = self.store.get(vm.id).pid
locks = self.store.operation_locks
self.host.events.clear()
resized = self.service.resize(vm.name, cpu=cpu, ram_mib=ram_mib,
storage_mib=storage_mib, timeout=0.5)
self.assertEqual(self.store.operation_locks - locks, 1)
self.assertEqual((resized.cpu, resized.ram_mib, resized.storage_mib), (cpu, ram_mib, storage_mib))
self.assertEqual(resized.status, "running")
self.assertIsNone(resized.pending_resize)
self.assertIsNone(resized.operation)
self.assertNotEqual(resized.pid, previous_pid)
for field in ("id", "name", "guest_ip", "gateway", "network", "bridge", "mac", "tap",
"disk", "username", "password", "ports", "created_at"):
self.assertEqual(getattr(resized, field), getattr(original, field), field)
self.assertEqual(disk.read_bytes(), contents)
actions = self.host.actions(vm.id)
self.assertLess(actions.index("process.gone"), actions.index("images.resize_disk"))
self.assertLess(actions.index("images.resize_disk"), actions.index("process.start"))
stopped = self.host.records["process.terminate", vm.id]
self.assertEqual(stopped.pending_resize, VmResources(cpu, ram_mib, storage_mib))
self.assertEqual(stopped.operation, "resize")
config = self.clients[vm.id].config
self.assertEqual(config["machine-config"], {"vcpu_count": max(1, int(cpu)), "mem_size_mib": ram_mib})
self.assertEqual(config["drives"][0]["path_on_host"], vm.disk)
self.assertEqual(self.store.get(vm.id).to_dict(), resized.to_dict())
self.assertEqual(self.store.get(sibling.id).to_dict(), sibling_before)
self.assertEqual(self.copy_disk.call_count, 2)
self.assertEqual(self.provision.call_count, 2)
self.assertEqual(self.resize_disk.call_count, 2)
def test_cpu_or_memory_only_resize_keeps_storage_and_boots_an_initially_stopped_vm(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.service.stop(vm.id)
first = self.service.resize(vm.guest_ip, cpu=2)
self.assertEqual((first.cpu, first.ram_mib, first.storage_mib), (2, 512, 50 * 1024))
second = self.service.resize(vm.id, ram_mib=4096)
self.assertEqual((second.cpu, second.ram_mib, second.storage_mib), (2, 4096, 50 * 1024))
self.assertEqual(second.status, "running")
self.resize_disk.assert_not_called()
self.assertEqual(self.provision.call_count, 1)
def test_invalid_resize_requests_do_not_change_state_or_stop_the_machine(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
for changes in ({}, {"cpu": 0}, {"cpu": float("nan")}, {"cpu": True},
{"ram_mib": 127}, {"ram_mib": 512.0}, {"storage_mib": 29 * 1024},
{"storage_mib": True}, {"storage_mib": 2**63}, {"cpu": 2, "timeout": -1}):
with self.subTest(changes=changes), self.assertRaises(ValidationError):
self.service.resize(vm.id, **changes)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(self.host.events, [])
self.resize_disk.assert_not_called()
def test_resize_rejects_unsafe_or_unbootable_vm_before_stopping(self) -> None:
for problem in ("unknown", "delete", "legacy", "unprovisioned", "missing", "topology", "kernel"):
with self.subTest(problem=problem):
vm = self.create_vm()
if problem == "unknown":
self.process.unknown.add(vm.id)
elif problem == "delete":
self.persist(vm, operation="delete")
elif problem == "legacy":
self.persist(vm, ports=None)
elif problem == "unprovisioned":
self.persist(vm, disk_ready=False)
elif problem == "missing":
Path(vm.disk).unlink()
elif problem == "topology":
self.persist(vm, bridge="different")
else:
self.settings.kernel_image.unlink()
before = self.settings.state_path.read_bytes()
self.host.events.clear()
with self.assertRaises(UvmError):
self.service.resize(vm.id, cpu=2)
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertNotIn("process.terminate", self.host.actions(vm.id))
self.resize_disk.assert_not_called()
if problem == "topology":
self.persist(vm, bridge="uvm0")
if problem == "kernel":
self.settings.kernel_image.write_bytes(b"kernel")
def test_failed_stop_keeps_old_resources_and_persists_retryable_target(self) -> None:
vm = self.create_vm()
self.host.failures["process.terminate", vm.id] = FirecrackerError("exit unconfirmed")
with self.assertRaisesRegex(UvmError, "failed to resize.*exit unconfirmed"):
self.service.resize(vm.id, cpu=2, ram_mib=4096, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.ram_mib, retained.storage_mib), (1, 512, MIN_STORAGE_MIB))
self.assertEqual(retained.pending_resize, VmResources(2, 4096, 50 * 1024))
self.assertEqual(retained.pid, vm.pid)
self.resize_disk.assert_not_called()
self.assertEqual(len(self.process.started), 1)
with self.assertRaisesRegex(ConflictError, "resize is pending"):
self.service.start(vm.id)
retried = self.service.resize(vm.id, cpu=2)
self.assertEqual((retried.cpu, retried.ram_mib, retried.storage_mib), (2, 4096, 50 * 1024))
def test_failed_disk_resize_is_stopped_and_start_retries_persisted_target(self) -> None:
vm = self.create_vm(storage_mib=50 * 1024)
self.host.failures["images.resize_disk", vm.id] = UvmError("filesystem does not fit")
with self.assertRaisesRegex(UvmError, "filesystem does not fit"):
self.service.resize(vm.id, cpu=2, storage_mib=MIN_STORAGE_MIB)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (1, 50 * 1024))
self.assertEqual(retained.pending_resize, VmResources(2, 512, MIN_STORAGE_MIB))
self.assertIsNone(retained.pid)
self.assertEqual(len(self.process.started), 1)
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
restarted = self.service.start(vm.id)
self.assertEqual((restarted.cpu, restarted.storage_mib), (2, MIN_STORAGE_MIB))
self.assertIsNone(restarted.pending_resize)
self.assertEqual(self.provision.call_count, 1)
def test_failed_boot_retains_applied_resources_for_a_later_start(self) -> None:
vm = self.create_vm()
self.host.failures["client.configure", vm.id] = FirecrackerError("boot failed")
with self.assertRaisesRegex(UvmError, "boot failed"):
self.service.resize(vm.id, cpu=2, storage_mib=50 * 1024)
retained = self.store.get(vm.id)
self.assertEqual((retained.cpu, retained.storage_mib), (2, 50 * 1024))
self.assertIsNone(retained.pending_resize)
self.assertTrue(retained.disk_ready)
restarted = self.service.start(vm.id)
self.assertEqual(restarted.status, "running")
self.resize_disk.assert_called_once_with(Path(vm.disk), 50 * 1024)
def test_legacy_disk_gets_minimum_capacity_on_its_first_resource_resize(self) -> None:
vm = self.create_vm()
self.persist(vm, storage_mib=None)
resized = self.service.resize(vm.id, cpu=2)
self.assertEqual(resized.storage_mib, MIN_STORAGE_MIB)
self.resize_disk.assert_called_once_with(Path(vm.disk), MIN_STORAGE_MIB)
def test_retry_can_change_target_after_disk_publication_before_metadata_save(self) -> None:
for recorded_gib, published_gib in ((30, 50), (50, 30)):
with self.subTest(recorded=recorded_gib, published=published_gib):
vm = self.create_vm(storage_mib=recorded_gib * 1024)
self.service.stop(vm.id)
self.persist(vm, operation="resize", pending_resize=VmResources(2, 4096, published_gib * 1024))
self.disk_sizes[Path(vm.disk)] = published_gib * 1024**3
self.resize_disk.reset_mock()
result = self.service.resize(vm.id, storage_mib=recorded_gib * 1024)
self.resize_disk.assert_called_once_with(Path(vm.disk), recorded_gib * 1024)
self.assertEqual(result.storage_mib * 1024 * 1024, self.disk_sizes[Path(vm.disk)])
self.assertIsNone(result.pending_resize)
def test_interrupted_resize_can_be_stopped_then_resumed_or_deleted(self) -> None:
vm = self.create_vm()
self.host.failures["images.resize_disk", vm.id] = KeyboardInterrupt()
with self.assertRaises(KeyboardInterrupt):
self.service.resize(vm.id, storage_mib=50 * 1024)
stopped = self.service.stop(vm.id)
self.assertEqual(stopped.pending_resize.storage_mib, 50 * 1024)
self.assertEqual(stopped.operation, "resize")
self.service.destroy(vm.id)
self.assertNotIn(vm.id, self.store.load().vms)
self.assertFalse(Path(vm.disk).exists())
+690 -38
View File
@@ -7,12 +7,18 @@ import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import Mock, 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, VmResources, VmSpec
from uvm.errors import (
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
NotFoundError, UvmError, ValidationError,
)
from uvm.guest_exec import ExecResult
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
def fake_application(*, api_token: str | None = None):
@@ -20,6 +26,7 @@ def fake_application(*, api_token: str | None = None):
app_name="uvm",
default_vcpu=1,
default_ram_mib=512,
default_storage_mib=30 * 1024,
default_ssh_user="root",
api_token=api_token,
api_tls_cert=None,
@@ -96,19 +103,31 @@ class ServerCliTests(unittest.TestCase):
application = fake_application(api_token="test-token")
application.settings.api_tls_cert = certificate
application.settings.api_tls_key = key
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
api = SimpleNamespace(state=SimpleNamespace())
server = SimpleNamespace(run=Mock())
config = object()
fake_uvicorn = SimpleNamespace(
Config=Mock(return_value=config),
Server=Mock(return_value=server),
)
with (
patch("uvm.server._validate_server_settings"),
patch("uvm.server.create_api", return_value=object()),
patch("uvm.server.create_api", return_value=api),
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
patch.object(fake_uvicorn, "run") as run_mock,
):
run_server(application, host="0.0.0.0", port=8443)
run_mock.assert_called_once()
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
fake_uvicorn.Config.assert_called_once_with(
api,
host="0.0.0.0",
port=8443,
ssl_certfile=str(certificate),
ssl_keyfile=str(key),
)
fake_uvicorn.Server.assert_called_once_with(config)
self.assertIs(api.state.uvicorn_server, server)
server.run.assert_called_once_with()
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
for token in ("s\u00e9cret", "contains space", ""):
@@ -117,6 +136,22 @@ 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),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost connection", outcome_unknown=True), 502),
):
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,17 +160,20 @@ 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.last_spec: VmSpec | None = None
self.last_resize: dict | None = None
self.last_execution: dict | None = None
self.observed_status = "running"
self.policy_status = "enforced"
self.vm = VmRecord(
id="vm-test",
name="test-vm",
cpu=1,
ram_mib=512,
storage_mib=30 * 1024,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
@@ -143,27 +181,144 @@ if FASTAPI_AVAILABLE:
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.vm.status)]
return [ListedVm(
vm=self.vm,
observed_status=self.observed_status,
policy_status=self.policy_status,
)]
def create(self, spec):
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
self.vm.storage_mib = spec.storage_mib
if spec.guest_ip is not None:
self.vm.guest_ip = str(spec.guest_ip)
self.vm.username = spec.username
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, _vm_id: str):
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
self.vm.operation = None
self.observed_status = "stopped"
self.policy_status = "inactive"
return self.vm
def destroy(self, _vm_id: str):
def resize(self, identifier: str, *, cpu=None, ram_mib=None, storage_mib=None, timeout=None) -> VmRecord:
self.inspect(identifier)
self.last_resize = dict(cpu=cpu, ram_mib=ram_mib, storage_mib=storage_mib, timeout=timeout)
for key, value in (("cpu", cpu), ("ram_mib", ram_mib), ("storage_mib", storage_mib)):
if value is not None:
setattr(self.vm, key, value)
self.vm.pending_resize = None
self.vm.operation = None
return self.start(identifier)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
return self.vm
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed")
def read_log_tail(self, identifier: str, *, tail: int) -> SimpleNamespace:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def execute(self, vm_id: str, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
self.last_execution = dict(vm_id=vm_id, command=command, sudo=sudo, timeout_seconds=timeout_seconds)
return ExecResult(7, "hello\n", "problem\n", False, False)
def install_guest_agent(self, vm_id: str) -> VmRecord:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
if self.vm.status != "stopped":
raise ConflictError("stop the VM before installing the guest agent")
return self.vm
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):
def setUp(self) -> None:
application = fake_application(api_token="test-token")
@@ -176,7 +331,10 @@ if FASTAPI_AVAILABLE:
)
)
application.state_store = SimpleNamespace(initialize=lambda: None)
self.client = TestClient(create_api(application, host="127.0.0.1"))
self.headers = {"X-UVM-Token": "test-token"}
self.api = create_api(application, host="127.0.0.1")
self.client = TestClient(self.api)
self.addCleanup(self.client.close)
def test_health_is_available_without_credentials(self) -> None:
response = self.client.get("/health")
@@ -184,20 +342,65 @@ if FASTAPI_AVAILABLE:
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
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"})
def test_standalone_uvm_api_does_not_serve_the_nest_dashboard(self) -> None:
for path in ("/", "/dashboard", "/dashboard/static/js/dashboard.js"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 404)
self.assertEqual(unauthorized.status_code, 401)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
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/resize", {"cpu": 2}),
("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("POST", "/vms/vm-test/exec", {"command": "id"}),
("POST", "/vms/vm-test/agent/install", {}),
("GET", "/diagnostics", {}),
("POST", "/exit", {}),
("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=payload)
self.assertEqual(response.status_code, 401)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(self.lifecycle.vm.status, "running")
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])
def test_exit_requests_a_graceful_authenticated_server_shutdown(self) -> None:
server = SimpleNamespace(should_exit=False)
self.api.state.uvicorn_server = server
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"status": "stopping"})
self.assertTrue(server.should_exit)
def test_exit_requires_a_running_uvicorn_server(self) -> None:
response = self.client.post("/exit", headers=self.headers)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json(), {
"detail": "server shutdown is unavailable outside nest --serve or uvm --serve",
})
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
headers = {"X-UVM-Token": "test-token"}
created = self.client.post(
"/vms",
headers=headers,
headers=self.headers,
json={
"cpu": 1,
"ram": "512",
@@ -205,37 +408,486 @@ if FASTAPI_AVAILABLE:
"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})
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})
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")
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(destroyed.json()["status"], "terminated")
self.assertEqual(stopped.json()["policy_status"], "inactive")
self.assertEqual(destroyed.json(), {"id": "vm-test", "status": "terminated"})
self.assertEqual(invalid.status_code, 422)
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"},
"/vms", headers=self.headers, json={"cpu": 1, "ram": "512"},
)
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(self.lifecycle.last_spec.storage_mib, 30 * 1024)
self.assertEqual(response.json()["storage_mib"], 30 * 1024)
def test_install_route_uses_the_existing_installer(self) -> None:
def test_create_accepts_custom_storage_and_rejects_capacities_below_minimum(self) -> None:
for capacity, expected in ((50, 50 * 1024), ("50G", 50 * 1024), ("1T", 1024 * 1024)):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(self.lifecycle.last_spec.storage_mib, expected)
self.assertEqual(response.json()["storage_mib"], expected)
self.lifecycle.last_spec = None
for capacity in ("29G", 29, "30M", "bad", True, None, [], {}):
with self.subTest(capacity=capacity):
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_spec)
def test_resize_accepts_partial_resources_and_returns_the_same_vm_identity(self) -> None:
before = self.lifecycle.vm.to_dict()
response = self.client.post("/vms/test-vm/resize?timeout=0.5", headers=self.headers,
json={"cpu": 2, "ram": "4G", "storage": "50G"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(self.lifecycle.last_resize, {"cpu": 2, "ram_mib": 4096, "storage_mib": 51200, "timeout": 0.5})
for field in ("id", "name", "guest_ip", "gateway", "mac", "username"):
self.assertEqual(response.json()[field], before[field])
self.assertEqual(response.json()["status"], "running")
self.assertEqual(response.json()["policy_status"], "enforced")
self.assertEqual(response.json()["storage_mib"], 51200)
self.assertIsNone(response.json()["pending_resize"])
self.assertNotIn("password", response.json())
self.assertEqual(self.lifecycle.vm.password, before["password"])
partial = self.client.post("/vms/10.42.0.2/resize", headers=self.headers, json={"ram": "2G"})
self.assertEqual(partial.status_code, 200)
self.assertEqual(self.lifecycle.last_resize, {"cpu": None, "ram_mib": 2048, "storage_mib": None, "timeout": None})
self.assertEqual(partial.json()["storage_mib"], 51200)
def test_resize_rejects_invalid_or_non_resource_edits_before_lifecycle_calls(self) -> None:
for payload in ({}, {"cpu": None}, {"cpu": 0}, {"cpu": True}, {"ram": "127M"},
{"storage": "29G"}, {"storage": False}, {"storage": []},
{"cpu": 2, "username": "new-user"}, {"cpu": 2, "password": "new-secret"},
{"cpu": 2, "guest_ip": "10.42.0.9"}):
with self.subTest(payload=payload):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_resize)
for timeout in ("-1", "nan", "inf"):
response = self.client.post(f"/vms/vm-test/resize?timeout={timeout}", headers=self.headers, json={"cpu": 2})
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.lifecycle.last_resize)
def test_resize_errors_and_pending_resources_are_exposed_without_credentials(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("unknown process"), 409),
(UvmError("filesystem does not fit"), 500)):
with patch.object(self.lifecycle, "resize", side_effect=error):
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json={"storage": "30G"})
self.assertEqual(response.status_code, expected)
self.assertEqual(response.json(), {"error": {"message": str(error)}})
target = VmResources(2, 4096, 51200)
self.lifecycle.vm.pending_resize = target
response = self.client.get("/vms/vm-test", headers=self.headers)
self.assertEqual(response.json()["pending_resize"], target.to_dict())
self.assertNotIn("guest-secret", response.text)
def test_create_accepts_valid_names(self) -> None:
for name in (None, "a", "web-1", "a" * 63):
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_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)
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_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)
def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None:
expected_fields = {
"id", "name", "cpu", "ram_mib", "storage_mib", "pending_resize", "guest_ip", "gateway", "mac", "username",
"status", "observed_status", "ports", "policy_status", "operation", "pid",
"created_at", "updated_at", "last_error",
}
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_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)
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_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_exec_preserves_script_text_privileges_and_nonzero_exit_results(self) -> None:
command = "printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7"
response = self.client.post("/vms/vm-test/exec", headers=self.headers,
json={"command": command, "sudo": True, "timeout_seconds": 12.5})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json(), {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
self.assertEqual(self.lifecycle.last_execution, {
"vm_id": "vm-test", "command": command, "sudo": True, "timeout_seconds": 12.5,
})
self.assertNotIn("guest-secret", response.text)
default = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(default.status_code, 200)
self.assertEqual(self.lifecycle.last_execution["sudo"], False)
self.assertEqual(self.lifecycle.last_execution["timeout_seconds"], 60)
def test_exec_rejects_malformed_requests_before_calling_the_service(self) -> None:
for payload in (
{}, {"command": ""}, {"command": " \n"}, {"command": "a\0b"}, {"command": 42},
{"command": "x" * 65537}, {"command": "\u00e9" * 40000},
{"command": "id", "sudo": "false"}, {"command": "id", "sudo": 1},
{"command": "id", "timeout_seconds": True}, {"command": "id", "timeout_seconds": "10"},
{"command": "id", "timeout_seconds": 0}, {"command": "id", "timeout_seconds": 3601},
{"command": "id", "username": "root"}, {"command": "id", "host_path": "/etc/passwd"},
):
with self.subTest(payload=str(payload)[:100]):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_exec_errors_distinguish_vm_state_agent_readiness_and_unknown_outcomes(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("stopped"), 409),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost response", outcome_unknown=True), 502)):
with self.subTest(error=error), patch.object(self.lifecycle, "execute", side_effect=error):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(response.status_code, expected)
if isinstance(error, GuestExecutionError):
self.assertTrue(response.json()["error"]["outcome_unknown"])
with patch.object(self.lifecycle, "execute", return_value=ExecResult(None, "partial", "", True, True)):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "sleep 10"})
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["exit_code"])
self.assertTrue(response.json()["timed_out"])
self.assertTrue(response.json()["truncated"])
def test_exec_rejects_nonfinite_and_invalid_unicode_json_without_serialization_errors(self) -> None:
for body in ('{"command":"id","timeout_seconds":NaN}',
'{"command":"id","timeout_seconds":Infinity}',
'{"command":"\\ud800"}', '{"command":"id","sudo":NaN}'):
with self.subTest(body=body):
response = self.client.post("/vms/vm-test/exec", headers={**self.headers, "Content-Type": "application/json"},
content=body)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_agent_install_requires_stopping_and_uses_the_existing_vm_disk(self) -> None:
blocked = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(blocked.status_code, 409)
self.client.post("/vms/vm-test/stop", headers=self.headers)
with patch.object(self.lifecycle, "install_guest_agent", wraps=self.lifecycle.install_guest_agent) as install:
response = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"id": "vm-test", "status": "installed", "restart_required": True})
install.assert_called_once_with("vm-test")
self.assertEqual(self.lifecycle.vm.status, "stopped")
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(
"/install",
headers={"X-UVM-Token": "test-token"},
json={"force": True},
"/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"),
((), "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"),
("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),
(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_exposes_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},
{
("/health", "get"),
("/exit", "post"),
("/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}/resize", "post"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/vms/{vm_id}/exec", "post"),
("/vms/{vm_id}/agent/install", "post"),
("/diagnostics", "get"),
},
)
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")
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import asyncio
import base64
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from nest.server import create_app
from nest.session_store import SessionStore
from nest.sessions import SessionManager
from tests.test_server import FASTAPI_AVAILABLE, fake_application
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError
if FASTAPI_AVAILABLE:
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
class FakeTerminal:
def __init__(self) -> None:
self.events = asyncio.Queue()
self.events.put_nowait({"type": "output", "data": b"guest$ "})
self.input = []
self.sizes = []
self.closed = False
async def read(self):
event = await self.events.get()
if isinstance(event, Exception):
raise event
return event
async def write(self, data):
self.input.append(data)
await self.events.put({"type": "output", "data": data + b"\r\nguest$ "})
async def resize(self, rows, cols):
self.sizes.append((rows, cols))
async def end(self):
await self.events.put({"type": "exit", "exit_code": 0})
def close(self):
self.closed = True
def session_application(base):
application = fake_application(api_token="test-token")
application.settings.base = Path(base)
application.terminals = []
application.vm_ids = {"vm-test", "vm-other"}
def get(vm_id):
if vm_id not in application.vm_ids:
raise NotFoundError("VM not found")
return SimpleNamespace(id=vm_id)
def open_terminal(vm_id, **_size):
get(vm_id)
terminal = FakeTerminal()
application.terminals.append(terminal)
return terminal, "worker"
application.state_store = SimpleNamespace(get=get)
application.lifecycle = SimpleNamespace(open_terminal=open_terminal)
return application
class SessionStoreTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.path = Path(temporary.name) / "nest" / "sessions.sqlite3"
self.store = SessionStore(self.path, max_output=8)
self.addCleanup(self.store.close)
def test_history_is_scoped_by_exact_vm_id_and_survives_reopening(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
self.store.append(record["id"], b"hello")
self.store.finish(record["id"], "closed", 7)
self.assertEqual(self.store.list("vm-b"), [])
with self.assertRaises(NotFoundError):
self.store.get("vm-b", record["id"])
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
saved, start, output = reopened.read("vm-a", record["id"], 0)
self.assertEqual((saved["status"], saved["exit_code"], saved["username"], start, output), ("closed", 7, "worker", 0, b"hello"))
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700)
def test_restarted_server_marks_only_live_sessions_interrupted(self):
live = self.store.create("vm-a", 24, 80)
ended = self.store.create("vm-a", 24, 80)
self.store.activate(live["id"], "worker")
self.store.finish(ended["id"], "closed", 0)
self.store.close()
reopened = SessionStore(self.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.get("vm-a", live["id"])["status"], "interrupted")
self.assertIsNotNone(reopened.get("vm-a", live["id"])["ended_at"])
self.assertEqual(reopened.get("vm-a", ended["id"])["status"], "closed")
def test_transcript_is_bounded_and_offsets_support_exact_replay(self):
record = self.store.create("vm-a", 24, 80)
self.store.append(record["id"], b"abcdefghij")
self.store.append(record["id"], b"kl")
saved, start, output = self.store.read("vm-a", record["id"], 0)
self.assertEqual((start, output, saved["output_end"], saved["truncated"]), (4, b"efghijkl", 12, True))
self.assertEqual(self.store.read("vm-a", record["id"], 9)[1:], (9, b"jkl"))
self.assertEqual(self.store.read("vm-a", record["id"], 12)[2], b"")
def test_second_server_cannot_reconcile_another_servers_live_sessions(self):
record = self.store.create("vm-a", 24, 80)
self.store.activate(record["id"], "worker")
with self.assertRaises(ConflictError):
SessionStore(self.path)
self.assertEqual(self.store.get("vm-a", record["id"])["status"], "active")
def test_symlinked_history_file_is_not_opened_or_rewritten(self):
self.store.close()
self.path.unlink()
target = self.path.parent / "unrelated"
target.write_bytes(b"keep this")
self.path.symlink_to(target)
with self.assertRaises(OSError):
SessionStore(self.path)
self.assertEqual(target.read_bytes(), b"keep this")
class SessionManagerTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.application = session_application(self.temporary.name)
self.manager = SessionManager(self.application)
self.addAsyncCleanup(self.manager.shutdown)
async def test_failed_launch_is_saved_in_history(self):
def unavailable(*_args, **_kwargs):
raise GuestAgentUnavailableError("update the guest agent")
self.application.lifecycle.open_terminal = unavailable
with self.assertRaises(GuestAgentUnavailableError):
await self.manager.create("vm-test", 24, 80)
history = await self.manager.list("vm-test")
self.assertEqual(history[0]["status"], "failed")
self.assertEqual(history[0]["error"], "update the guest agent")
async def test_shutdown_closes_live_channels_and_saves_interrupted_history(self):
record = await self.manager.create("vm-test", 24, 80)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
store = SessionStore(Path(self.temporary.name) / "nest" / "sessions.sqlite3")
self.addCleanup(store.close)
self.assertEqual(store.get("vm-test", record["id"])["status"], "interrupted")
async def test_cancelling_the_create_request_does_not_orphan_the_shell(self):
import threading
entered, release = threading.Event(), threading.Event()
connect = self.application.lifecycle.open_terminal
def slow_connect(*args, **kwargs):
entered.set()
release.wait(2)
return connect(*args, **kwargs)
self.application.lifecycle.open_terminal = slow_connect
request = asyncio.create_task(self.manager.create("vm-test", 24, 80))
await asyncio.to_thread(entered.wait, 2)
request.cancel()
release.set()
await asyncio.gather(request, return_exceptions=True)
await self.manager.shutdown()
self.assertTrue(self.application.terminals[0].closed)
async def test_guest_loss_ends_the_session_and_retains_output(self):
record = await self.manager.create("vm-test", 24, 80)
terminal = self.application.terminals[0]
await terminal.events.put(GuestExecutionError("VM connection lost", outcome_unknown=True))
async with asyncio.timeout(2):
while (await self.manager.get("vm-test", record["id"]))["status"] == "active":
await asyncio.sleep(0.01)
saved, _start, output = await self.manager.read("vm-test", record["id"], 0)
self.assertEqual(saved["status"], "interrupted")
self.assertEqual(output, b"guest$ ")
self.assertTrue(terminal.closed)
async def test_shutdown_finishes_inflight_output_before_releasing_history_ownership(self):
import threading
entered, release = threading.Event(), threading.Event()
history = self.manager._history()
append = history.append
def slow_append(*args):
entered.set()
release.wait(2)
append(*args)
with patch.object(history, "append", side_effect=slow_append):
record = await self.manager.create("vm-test", 24, 80)
self.assertTrue(await asyncio.to_thread(entered.wait, 2))
closing = asyncio.create_task(self.manager.shutdown())
try:
await asyncio.sleep(0.03)
self.assertFalse(closing.done())
with self.assertRaises(ConflictError):
SessionStore(history.path)
finally:
release.set()
await closing
reopened = SessionStore(history.path)
self.addCleanup(reopened.close)
self.assertEqual(reopened.read("vm-test", record["id"], 0)[2], b"guest$ ")
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required")
class SessionApiTests(unittest.TestCase):
def setUp(self):
self.temporary = self.enterContext(tempfile.TemporaryDirectory())
self.application = session_application(self.temporary)
self.api = create_app(self.application, host="127.0.0.1")
self.client = self.enterContext(TestClient(self.api))
self.headers = {"X-UVM-Token": "test-token"}
self.base = "/vms/vm-test/sessions"
def create(self):
response = self.client.post(self.base, headers=self.headers, json={})
self.assertEqual(response.status_code, 201, response.text)
return response.json()
def connect(self, record, *, vm_id="vm-test", offset=0):
ws = self.enterContext(self.client.websocket_connect(f"/vms/{vm_id}/sessions/{record['id']}/terminal"))
ws.send_json({"type": "auth", "token": "test-token", "offset": offset})
return ws
def output(self, ws):
while True:
message = ws.receive_json()
if message["type"] == "output":
data = base64.b64decode(message["data"])
end = message["offset"] + len(data)
ws.send_json({"type": "ack", "offset": end})
return data, end
self.assertEqual(message["type"], "session", message)
def test_all_http_operations_and_websocket_output_require_authentication(self):
for method, path in (("GET", self.base), ("POST", self.base),
("GET", self.base + "/session-unknown"), ("POST", self.base + "/session-unknown/close")):
self.assertEqual(self.client.request(method, path, json={}).status_code, 401)
for token in ("wrong", "", "\u00e9"):
with self.client.websocket_connect(self.base + "/session-unknown/terminal") as ws:
ws.send_json({"type": "auth", "token": token})
with self.assertRaises(WebSocketDisconnect) as raised:
ws.receive_json()
self.assertEqual(raised.exception.code, 4401)
self.assertEqual(self.application.terminals, [])
self.assertFalse((Path(self.temporary) / "nest").exists())
def test_foreign_browser_origin_is_rejected(self):
with self.assertRaises(WebSocketDisconnect):
with self.client.websocket_connect(self.base + "/unknown/terminal", headers={"Origin": "https://foreign.example"}):
self.fail("foreign origin accepted")
def test_create_rejects_unknown_vm_aliases_and_invalid_sizes(self):
for body in ({"rows": 0}, {"cols": True}, {"cols": 401}, {"command": "id"}):
self.assertEqual(self.client.post(self.base, headers=self.headers, json=body).status_code, 422)
self.assertEqual(self.client.post("/vms/name-alias/sessions", headers=self.headers, json={}).status_code, 404)
self.assertEqual(self.application.terminals, [])
def test_sessions_are_nested_under_the_selected_vm(self):
record = self.create()
self.assertEqual(record["username"], "worker")
other = "/vms/vm-other/sessions"
self.assertEqual(self.client.get(other, headers=self.headers).json(), [])
self.assertEqual(self.client.get(other + "/" + record["id"], headers=self.headers).status_code, 404)
self.assertEqual(self.client.post(other + "/" + record["id"] + "/close", headers=self.headers).status_code, 404)
ws = self.connect(record, vm_id="vm-other")
self.assertEqual(ws.receive_json()["type"], "error")
self.assertFalse(self.application.terminals[0].closed)
def test_disconnect_reconnect_input_resize_and_closed_history(self):
record = self.create()
ws = self.connect(record)
output, offset = self.output(ws)
self.assertEqual(output, b"guest$ ")
ws.send_json({"type": "resize", "rows": 35, "cols": 120})
ws.send_json({"type": "input", "data": base64.b64encode(b"pwd\r").decode()})
output, offset = self.output(ws)
self.assertEqual(output, b"pwd\r\r\nguest$ ")
self.assertEqual(self.application.terminals[0].sizes, [(35, 120)])
ws.close()
self.assertFalse(self.application.terminals[0].closed)
reopened = self.connect(record, offset=offset)
self.assertEqual(reopened.receive_json()["session"]["status"], "active")
self.assertEqual(len(self.application.terminals), 1)
closed = self.client.post(self.base + "/" + record["id"] + "/close", headers=self.headers)
self.assertEqual(closed.json()["status"], "closed")
self.assertEqual(closed.json()["exit_code"], 0)
replay = self.connect(record)
transcript, _end = self.output(replay)
self.assertEqual(transcript, b"guest$ pwd\r\r\nguest$ ")
self.assertEqual(self.client.get(self.base, headers=self.headers).json()[0]["status"], "closed")
self.assertTrue(self.application.terminals[0].closed)
def test_malformed_input_ends_only_the_browser_attachment(self):
record = self.create()
ws = self.connect(record)
self.output(ws)
ws.send_json({"type": "input", "data": "not base64!"})
while (message := ws.receive_json())["type"] == "session":
pass
self.assertEqual(message["type"], "error")
self.assertEqual(self.application.terminals[0].input, [])
self.assertFalse(self.application.terminals[0].closed)
self.assertEqual(self.client.get(self.base + "/" + record["id"], headers=self.headers).json()["status"], "active")
+258 -11
View File
@@ -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, VmResources, VmSpec, new_vm_id
from uvm.errors import ConflictError, NotFoundError, StateError
from uvm.state import StateStore
@@ -61,26 +61,108 @@ 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")
for version in (None, 1, 2):
with self.subTest(version=version):
raw_vm = make_vm().to_dict()
for field in (
"disk", "username", "password", "updated_at", "name", "ports",
"disk_ready", "runtime_owned", "tap_owned", "operation", "boot_id",
"spawn_pending", "network", "bridge", "storage_mib", "pending_resize",
):
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)
vm = loaded.vms["vm-test"]
self.assertEqual(loaded.schema_version, 3)
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.assertIsNone(vm.storage_mib)
self.assertIsNone(vm.pending_resize)
self.assertFalse(vm.disk_ready)
self.assertTrue(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"], 1)
self.assertEqual(persisted["schema_version"], 3)
self.assertEqual(persisted["next_mac_index"], 2)
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
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"))):
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", storage_mib=30 * 1024,
pending_resize=VmResources(cpu=2, ram_mib=4096, storage_mib=50 * 1024),
)
with self.store.transaction() as state:
state.vms[vm.id] = vm
loaded = self.store.load()
self.assertEqual(loaded.schema_version, 3)
self.assertEqual(loaded.vms[vm.id], vm)
raw = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(raw["schema_version"], 3)
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.id] = vm
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, 3):
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 +192,168 @@ 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, True, True)),
("unknown", "", None, (False, True, 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_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):
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_three_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 3)
with self.assertRaisesRegex(StateError, "newer than"):
State.from_dict({"schema_version": STATE_SCHEMA_VERSION + 1, "vms": {}})
def test_storage_and_pending_resize_reject_malformed_persisted_values(self) -> None:
raw = make_vm().to_dict()
for value in (0, 1024, True, "30720", 30720.0, -1, {}, [], 2**63):
with self.subTest(storage=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "storage_mib": value})
valid = {"cpu": 2, "ram_mib": 4096, "storage_mib": 50 * 1024}
for value in ({}, [], "resize", {**valid, "cpu": True}, {**valid, "cpu": float("nan")},
{**valid, "ram_mib": 127}, {**valid, "storage_mib": 29 * 1024},
{**valid, "password": "not-a-resource"}):
with self.subTest(pending=value), self.assertRaises(StateError):
VmRecord.from_dict({**raw, "pending_resize": value})
def test_generated_ids_have_the_reserved_uuid_format(self) -> None:
first, second = new_vm_id(), new_vm_id()
self.assertNotEqual(first, second)
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)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import hashlib
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import MIN_STORAGE_MIB, Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import ImageStore
from uvm.system import CommandResult, CommandRunner
MIB = 1024 * 1024
class DiskResizeTests(unittest.TestCase):
def test_grow_extends_the_file_first_and_shrink_truncates_only_after_verified_resize(self) -> None:
for original_mib, target_mib in ((8, 16), (32, 16)):
with self.subTest(original=original_mib), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(original_mib * MIB)
sizes = []
def run(command, **_options):
sizes.append((command[0], disk.stat().st_size))
return CommandResult(args=tuple(map(str, command)), returncode=1 if command[0] == "e2fsck" else 0)
runner = Mock(spec=CommandRunner)
runner.run.side_effect = run
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=target_mib * MIB):
store._resize_filesystem(disk, target_mib)
self.assertEqual(sizes, [("e2fsck", original_mib * MIB), ("resize2fs", max(original_mib, target_mib) * MIB)])
self.assertEqual(disk.stat().st_size, target_mib * MIB)
def test_failed_or_interrupted_staging_never_changes_the_original_disk(self) -> None:
for failure in (UvmError("filesystem cannot fit"), KeyboardInterrupt()):
with self.subTest(failure=type(failure)), tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"data, password hash, and SSH host keys")
disk.chmod(0o600)
before = (disk.stat().st_ino, disk.read_bytes())
store = ImageStore(Settings(base=Path(temporary)), Mock(spec=CommandRunner))
def fail(candidate, _capacity):
self.assertNotEqual(candidate, disk)
candidate.write_bytes(b"partly resized staging image")
raise failure
with (
patch.object(store, "_copy_sparse", side_effect=shutil.copyfile),
patch.object(store, "_resize_filesystem", side_effect=fail),
self.assertRaises(type(failure)),
):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
self.assertEqual((disk.stat().st_ino, disk.read_bytes()), before)
self.assertEqual(list(disk.parent.iterdir()), [disk])
def test_filesystem_check_errors_prevent_resize_and_truncation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"original")
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("e2fsck",), 4, stderr="unrepaired errors")
store = ImageStore(Settings(), runner)
with self.assertRaisesRegex(UvmError, "filesystem check failed"):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.read_bytes(), b"original")
self.assertEqual(runner.run.call_count, 1)
def test_geometry_mismatch_prevents_truncating_a_larger_filesystem(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(32 * MIB)
runner = Mock(spec=CommandRunner)
runner.run.return_value = CommandResult(("tool",), 0)
store = ImageStore(Settings(), runner)
with (
patch.object(store, "_filesystem_size", return_value=32 * MIB),
self.assertRaisesRegex(UvmError, "does not match"),
):
store._resize_filesystem(disk, 16)
self.assertEqual(disk.stat().st_size, 32 * MIB)
def test_retry_after_disk_publication_recognizes_the_completed_capacity(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
disk = Path(temporary) / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(MIN_STORAGE_MIB * MIB)
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
with patch.object(store, "_filesystem_size", return_value=MIN_STORAGE_MIB * MIB):
store.resize_vm_disk(disk, MIN_STORAGE_MIB)
runner.run.assert_not_called()
def test_resize_rejects_subminimum_capacity_and_shared_or_linked_disks(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"template")
disk = Path(temporary) / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = Path(temporary) / "linked.ext4"
linked.hardlink_to(disk)
symlink = Path(temporary) / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
with self.assertRaises(ValidationError):
store.resize_vm_disk(disk, MIN_STORAGE_MIB - 1)
for target in (settings.rootfs_image, linked, symlink):
with self.subTest(target=target), self.assertRaisesRegex(UvmError, "unsafe"):
store.resize_vm_disk(target, MIN_STORAGE_MIB)
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "e2fsck", "resize2fs", "dumpe2fs", "debugfs", "cp")),
"e2fsprogs and coreutils are required for offline disk checks")
class OfflineExt4ResizeTests(unittest.TestCase):
def test_real_sparse_disk_grows_and_shrinks_with_guest_data_and_keys_intact(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "template.ext4"
root = base / "guest"
root.mkdir()
contents = {
"data": "retained application data\n",
"shadow": "root:$6$unchanged-password-hash:1:0:99999:7:::\n",
"host-key": "unchanged-private-host-key\n",
}
for name, value in contents.items():
(root / name).write_text(value, encoding="utf-8")
with source.open("wb") as image:
image.truncate(32 * MIB)
runner = CommandRunner(emit=None)
runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-N", "128", "-d", root, source), capture=True)
template_digest = hashlib.sha256(source.read_bytes()).hexdigest()
store = ImageStore(Settings(base=base), runner)
disk = store.create_vm_disk(source, base / "rootfs.ext4")
for capacity in (MIN_STORAGE_MIB, 31 * 1024, MIN_STORAGE_MIB):
store.resize_vm_disk(disk, capacity)
self.assertEqual(disk.stat().st_size, capacity * MIB)
self.assertEqual(store._filesystem_size(disk), capacity * MIB)
self.assertEqual(disk.stat().st_mode & 0o777, 0o600)
self.assertLess(disk.stat().st_blocks * 512, disk.stat().st_size // 2)
runner.run(("e2fsck", "-f", "-n", disk), capture=True)
for name, value in contents.items():
result = runner.run(("debugfs", "-R", f"cat /{name}", disk), capture=True)
self.assertEqual(result.stdout, value)
self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), template_digest)
+67 -14
View File
@@ -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:
completed = SimpleNamespace(
returncode=1,
stdout="",
stderr="failure mentioning secret-value",
)
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=None).run(
("credential-tool", "--stdin"),
capture=True,
input_text="secret-value\n",
sensitive=True,
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="secret-value\n", stderr="failure mentioning secret-value",
)
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.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, completed.stdout)
self.assertEqual(result.stderr, completed.stderr)
+120 -1
View File
@@ -2,11 +2,29 @@ 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_storage, parse_timeout, parse_username,
)
class ValidationTests(unittest.TestCase):
def test_storage_defaults_to_gib_and_supports_larger_and_fractional_units(self) -> None:
for value, expected in (
("30", 30720), ("30G", 30720), ("30GB", 30720), ("30GiB", 30720),
(" 50 g ", 51200), ("30720M", 30720), ("30.5G", 31232),
("1T", 1048576), ("0.5TiB", 524288), ("30.0001G", 30721),
):
with self.subTest(value=value):
self.assertEqual(parse_storage(value), expected)
def test_storage_rejects_subminimum_invalid_and_unrepresentable_capacity(self) -> None:
for value in ("0", "29G", "29.999999G", "30719M", "30M", "", "-30G", "nan",
"inf", "True", "30G garbage", "100000000000000000000000T"):
with self.subTest(value=value), self.assertRaises(ValidationError):
parse_storage(value)
def test_parse_ram_uses_mib_by_default(self) -> None:
self.assertEqual(parse_ram("512"), 512)
self.assertEqual(parse_ram("1G"), 1024)
@@ -43,3 +61,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)
+137 -9
View File
@@ -3,21 +3,28 @@
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, model_validator
from .domain import VmRecord
from .errors import ValidationError
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
from .guest_exec import validate_exec
class HealthResponse(BaseModel):
status: str = "ok"
class InstallRequest(BaseModel):
force: bool = False
class ServerExitResponse(BaseModel):
status: str = "stopping"
class Config:
extra = "forbid"
class InstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
force: bool = False
class InstallResponse(BaseModel):
@@ -26,27 +33,59 @@ 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"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None
username: str = "root"
password: SecretStr = SecretStr("root")
name: str | None = None
ports: list[PortRuleModel] | None = None
no_default_ports: bool = Field(default=False, strict=True)
class Config:
extra = "forbid"
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel):
id: str
name: str | None
cpu: float
ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str
gateway: str
mac: str
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,17 +97,106 @@ class DestroyResponse(BaseModel):
status: str = "terminated"
def vm_response(vm: VmRecord, *, observed_status: str | None = None) -> VmResponse:
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 VmExecRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command: str = Field(strict=True, min_length=1, max_length=MAX_COMMAND_BYTES, repr=False,
description="Command or multiline script interpreted by /bin/sh inside the guest; at most 64 KiB UTF-8")
sudo: bool = Field(default=False, strict=True, description="Run as guest root instead of the VM's configured login user")
timeout_seconds: float = Field(default=DEFAULT_TIMEOUT_SECONDS, strict=True, gt=0,
le=MAX_TIMEOUT_SECONDS, allow_inf_nan=False)
@model_validator(mode="before")
@classmethod
def validate_execution(cls, value: object) -> object:
# Use the domain error envelope: echoing NaN or unpaired surrogates in
# FastAPI's validation-error input can itself fail JSON serialization.
if not isinstance(value, dict) or set(value) - {"command", "sudo", "timeout_seconds"}:
raise ValidationError("execution requires an object with command, sudo, and timeout_seconds fields only")
validate_exec(value.get("command"), value.get("sudo", False), value.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS))
return value
class VmExecResponse(BaseModel):
exit_code: int | None
stdout: str
stderr: str
timed_out: bool
truncated: bool
class VmAgentInstallResponse(BaseModel):
id: str
status: Literal["installed"] = "installed"
restart_required: bool = True
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,
*,
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,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip,
gateway=vm.gateway,
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,
+3 -1
View File
@@ -9,6 +9,7 @@ from .config import Settings
from .firecracker.process import FirecrackerProcessManager
from .images import ImageStore
from .install import Installer
from .known_hosts import KnownHostsManager
from .lifecycle import LifecycleService
from .network import NetworkManager
from .state import StateStore
@@ -32,7 +33,8 @@ def build_application(emit: Callable[[str], None] | None = print) -> Application
images = ImageStore(settings, runner)
network = NetworkManager(settings, runner)
process = FirecrackerProcessManager(settings)
lifecycle = LifecycleService(settings, state_store, images, network, process)
known_hosts = KnownHostsManager(settings)
lifecycle = LifecycleService(settings, state_store, images, network, process, known_hosts=known_hosts)
installer = Installer(settings, runner, state_store, emit=emit)
return Application(
settings=settings,
+528 -38
View File
@@ -1,29 +1,62 @@
"""The sole public interface of this project: the local uvm CLI."""
"""Command-line interface for UVM's local machine management services."""
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_storage,
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",
action="store_true",
help="launch the FastAPI management server instead of running a CLI command",
help="launch the UVM management API; use nest --serve for the workspace dashboard",
)
parser.add_argument(
"--host",
@@ -36,13 +69,14 @@ 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",
description=(
"Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 "
"to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
"Set UVM_FIRECRACKER_SHA256 and UVM_KERNEL_SHA256 to trusted digests. "
"The default Ubuntu rootfs is built from signed packages. A custom "
"UVM_ROOTFS_URL also requires UVM_ROOTFS_SHA256. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit "
"development-only opt-out."
),
help="install dependencies and assets; SHA-256 values are required by default",
@@ -50,10 +84,11 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
install.add_argument(
"--force",
action="store_true",
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
help="refresh downloads and rebuild the default Ubuntu rootfs even when assets 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 +100,11 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
help="RAM, e.g. 512, 1G, 512M",
)
create.add_argument(
"--storage", default=f"{application.settings.default_storage_mib}M",
help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)",
)
create.add_argument(
"--guest-ip",
"--host-ip",
dest="host_ip",
help="guest IP to assign; otherwise uvm allocates one",
@@ -72,38 +112,143 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
create.add_argument(
"--username",
default=application.settings.default_ssh_user,
help="existing guest account to configure; defaults to root",
help="guest administrator account; created if missing, with sudo access (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")
resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM")
resize.add_argument("vm", help="VM ID, name, or guest IP")
resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained")
resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G")
resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB")
resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)")
resize.add_argument("--json", action="store_true")
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
ssh.add_argument("vm", help="VM ID or guest IP")
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")
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 +258,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 +297,148 @@ 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),
storage_mib=parse_storage(args.storage),
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; "
"fractional CPU enforcement is not yet applied.",
file=sys.stderr,
)
print(" fractional CPU enforcement is not yet applied by this MVP.")
print()
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" Storage: {vm.storage_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}")
print()
if vm.password == "root":
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
return 0
if args.command == "resize":
if args.cpu is None and args.ram is None and args.storage is None:
raise ValidationError("resize requires at least one of --cpu, --ram, or --storage")
vm = application.lifecycle.resize(
args.vm,
cpu=parse_cpu(args.cpu) if args.cpu is not None else None,
ram_mib=parse_ram(args.ram) if args.ram is not None else None,
storage_mib=parse_storage(args.storage) if args.storage is not None else None,
timeout=parse_timeout(args.timeout) if args.timeout is not None else None,
)
if args.json:
_print_json(_vm_output(vm, policy_status="enforced"))
else:
print(f"VM resized and started: {vm.id}")
print(f" CPU: {vm.cpu}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" IP: {vm.guest_ip}")
print(f" Username: {vm.username}")
return 0
if args.command == "list":
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} {'DISK(MiB)':<12} {'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} {vm.storage_mib or '-':<12} {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 +459,268 @@ 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,
"storage_mib": vm.storage_mib,
"pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None,
"guest_ip": vm.guest_ip,
"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
+40 -13
View File
@@ -3,21 +3,18 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from .errors import ConfigurationError
DEFAULT_KERNEL_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/kernels/vmlinux.bin"
)
DEFAULT_ROOTFS_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/rootfs/bionic.rootfs.ext4"
)
# Pin published kernels with built-in virtio-rng support. The artifact series
# is independent of the VMM release; the old ARM quickstart kernel lacks this driver.
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.15"
MIN_STORAGE_MIB = 30 * 1024
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
@dataclass(frozen=True, slots=True)
@@ -29,12 +26,14 @@ class Settings:
network: IPv4Network = IPv4Network("10.42.0.0/24")
gateway: IPv4Address = IPv4Address("10.42.0.1")
bridge: str = "uvm0"
architecture: str = field(default_factory=lambda: os.uname().machine)
firecracker_version: str = "v1.16.1"
default_ram_mib: int = 512
default_storage_mib: int = MIN_STORAGE_MIB
default_vcpu: float = 1
default_ssh_user: str = "root"
kernel_url: str = DEFAULT_KERNEL_URL
rootfs_url: str = DEFAULT_ROOTFS_URL
kernel_url: str = ""
rootfs_url: str = "" # Empty selects the locally built, package-managed Ubuntu image.
firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None
@@ -43,11 +42,23 @@ class Settings:
api_token: str | None = None
api_tls_cert: Path | None = None
api_tls_key: Path | None = None
known_hosts_files: tuple[Path, ...] | None = None
api_timeout_s: float = 2.0
api_socket_timeout_s: float = 5.0
terminate_timeout_s: float = 5.0
def __post_init__(self) -> None:
architecture = self.architecture.lower()
architecture = {"amd64": "x86_64", "arm64": "aarch64"}.get(architecture, architecture)
if architecture not in ("x86_64", "aarch64"):
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
object.__setattr__(self, "architecture", architecture)
# Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url:
object.__setattr__(self, "kernel_url", f"{asset_base}/vmlinux-6.1.155")
if self.gateway not in self.network:
raise ConfigurationError(
f"gateway {self.gateway} is outside configured network {self.network}"
@@ -56,6 +67,8 @@ class Settings:
raise ConfigurationError("gateway must be a usable host address")
if len(self.bridge) > 15:
raise ConfigurationError("bridge name must be 15 characters or fewer")
if self.known_hosts_files is not None and any(not path.is_absolute() for path in self.known_hosts_files):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must contain absolute file paths")
if self.api_token is not None:
if not self.api_token or not all(33 <= ord(character) <= 126 for character in self.api_token):
raise ConfigurationError(
@@ -109,6 +122,9 @@ class Settings:
def vm_dir(self, vm_id: str) -> Path:
return self.vms_dir / vm_id
def guest_socket_path(self, vm_id: str) -> Path:
return self.vm_dir(vm_id) / "vsock.sock"
@classmethod
def from_environment(cls) -> "Settings":
"""Load optional deployment overrides while retaining script defaults."""
@@ -124,8 +140,8 @@ class Settings:
network=network,
gateway=gateway,
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
firecracker_binary_sha256=_optional_environment_value(
"UVM_FIRECRACKER_BINARY_SHA256"
@@ -138,6 +154,7 @@ class Settings:
api_token=_optional_environment_value("UVM_API_TOKEN"),
api_tls_cert=_optional_environment_path("UVM_API_TLS_CERT"),
api_tls_key=_optional_environment_path("UVM_API_TLS_KEY"),
known_hosts_files=_known_hosts_environment_paths(),
)
@@ -151,6 +168,16 @@ def _optional_environment_path(name: str) -> Path | None:
return Path(value).expanduser() if value else None
def _known_hosts_environment_paths() -> tuple[Path, ...] | None:
value = _optional_environment_value("UVM_KNOWN_HOSTS_FILES")
if value is None:
return None
parts = value.split(os.pathsep)
if any(not part for part in parts):
raise ConfigurationError("UVM_KNOWN_HOSTS_FILES must be a colon-separated list of file paths")
return tuple(Path(part).expanduser() for part in parts)
def _boolean_environment_value(name: str, *, default: bool) -> bool:
value = os.environ.get(name)
if value is None:
+121 -2
View File
@@ -2,16 +2,45 @@
from __future__ import annotations
import math
import time
import uuid
from dataclasses import dataclass, field
from ipaddress import IPv4Address
from typing import Any
from .errors import StateError
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError
STATE_SCHEMA_VERSION = 1
STATE_SCHEMA_VERSION = 3
@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 +52,42 @@ 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),)
storage_mib: int = MIN_STORAGE_MIB
@dataclass(frozen=True, slots=True)
class VmResources:
"""Complete resource target retained while a stop/resize/start is pending."""
cpu: float
ram_mib: int
storage_mib: int
def __post_init__(self) -> None:
try:
valid_cpu = type(self.cpu) in (int, float) and math.isfinite(self.cpu) and self.cpu > 0
except OverflowError:
valid_cpu = False
if not valid_cpu:
raise ValidationError("CPU must be a finite value greater than 0")
if type(self.ram_mib) is not int or self.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
if type(self.storage_mib) is not int or not MIN_STORAGE_MIB <= self.storage_mib <= MAX_STORAGE_MIB:
raise ValidationError(f"storage must be between {MIN_STORAGE_MIB} and {MAX_STORAGE_MIB} MiB (minimum 30 GiB)")
def to_dict(self) -> dict[str, Any]:
return {"cpu": self.cpu, "ram_mib": self.ram_mib, "storage_mib": self.storage_mib}
@classmethod
def from_dict(cls, value: object) -> "VmResources":
if not isinstance(value, dict) or set(value) != {"cpu", "ram_mib", "storage_mib"}:
raise StateError("invalid pending resize resources")
try:
return cls(**value)
except ValidationError as error:
raise StateError(f"invalid pending resize resources: {error}") from error
@dataclass(slots=True)
@@ -48,12 +113,26 @@ 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
storage_mib: int | None = None
pending_resize: VmResources | None = None
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
"id": self.id,
"cpu": self.cpu,
"ram_mib": self.ram_mib,
"storage_mib": self.storage_mib,
"pending_resize": self.pending_resize.to_dict() if self.pending_resize is not None else None,
"guest_ip": self.guest_ip,
"gateway": self.gateway,
"tap": self.tap,
@@ -67,6 +146,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 +188,22 @@ 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"}
storage_mib = value.get("storage_mib")
if storage_mib is not None and (
type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB
):
raise StateError("VM storage_mib must be a supported integer capacity of at least 30 GiB or null")
pending_resize = value.get("pending_resize")
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
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 +236,18 @@ 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", 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,
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,
storage_mib=storage_mib,
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
)
except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error
@@ -170,6 +287,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())
+20
View File
@@ -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."""
@@ -31,6 +39,18 @@ class FirecrackerError(UvmError):
"""Raised when Firecracker cannot be started or configured."""
class GuestAgentUnavailableError(UvmError):
"""The VM's execution agent could not be reached before submission."""
class GuestExecutionError(UvmError):
"""Guest execution failed; a lost response does not prove the command failed."""
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.outcome_unknown = outcome_unknown
class TapCreationError(UvmError):
"""Raised when TAP setup fails after creating a host interface."""
+10
View File
@@ -35,12 +35,20 @@ class FirecrackerClient:
boot = config["boot-source"]
drives = config["drives"]
interfaces = config["network-interfaces"]
entropy = config.get("entropy")
vsock = config.get("vsock")
if not isinstance(machine, Mapping) or not isinstance(boot, Mapping):
raise FirecrackerError("invalid Firecracker configuration")
if not isinstance(drives, list) or not drives:
raise FirecrackerError("Firecracker configuration has no root drive")
if not isinstance(interfaces, list) or not interfaces:
raise FirecrackerError("Firecracker configuration has no network interface")
if not isinstance(entropy, Mapping):
raise FirecrackerError("Firecracker configuration has no valid entropy device")
if (not isinstance(vsock, Mapping) or type(vsock.get("guest_cid")) is not int
or not 3 <= vsock["guest_cid"] < 2**32 - 1
or not isinstance(vsock.get("uds_path"), str) or not vsock["uds_path"]):
raise FirecrackerError("Firecracker configuration has no valid vsock device")
self._request(
"PUT",
@@ -54,6 +62,8 @@ class FirecrackerClient:
self._request("PUT", "/boot-source", boot)
self._request("PUT", "/drives/rootfs", drives[0])
self._request("PUT", "/network-interfaces/eth0", interfaces[0])
self._request("PUT", "/vsock", vsock)
self._request("PUT", "/entropy", entropy)
self._request("PUT", "/actions", {"action_type": "InstanceStart"})
def _request(self, method: str, path: str, body: object) -> None:
+7
View File
@@ -44,6 +44,8 @@ def build_config(
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
)
)
if settings.architecture == "aarch64":
boot_args = f"keep_bootcon {boot_args}"
return {
"boot-source": {
"kernel_image_path": str(kernel),
@@ -68,6 +70,11 @@ def build_config(
"host_dev_name": vm.tap,
}
],
# Supply host-backed randomness before guest services such as sshd start.
"entropy": {},
# Each Firecracker instance has its own Unix-socket-backed vsock device;
# CIDs need not be allocated across these independent devices.
"vsock": {"guest_cid": 3, "uds_path": str(settings.guest_socket_path(vm.id))},
}
+367 -89
View File
@@ -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,336 @@ 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():
try:
socket_path.unlink()
except OSError as error:
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"could not remove stale Firecracker socket {socket_path}: {error}"
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:
_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_runtime_sockets(vm)
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:
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,
)
self._wait_for_socket(process, socket_path, log_path)
start_time = self.process_start_time(process.pid)
if start_time is None:
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}"
)
return ProcessInfo(pid=process.pid, start_time=start_time)
except OSError as error:
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)
return info
except BaseException as error:
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
except BaseException:
if process is not None:
self._cleanup_started_process(process, socket_path)
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
except PermissionError:
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
if (vm.pid, vm.process_start_time, vm.boot_id) != (pid, start_time, None):
return False
vm.boot_id = boot_id
return True
return self.process_start_time(vm.pid) == vm.process_start_time
def observe(self, vm: VmRecord) -> str:
"""Distinguish proven exit from missing or unreadable process identity."""
def terminate(self, vm: VmRecord) -> None:
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:
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
raise FirecrackerError(
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
return "unknown"
for socket_path in (Path(vm.socket), self._settings.guest_socket_path(vm.id)):
try:
socket_path.lstat()
except FileNotFoundError:
continue
except OSError:
return "unknown"
# An unowned socket may belong to a child whose identity was never saved.
return "unknown"
return "gone"
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 not self.is_alive(vm):
self._remove_socket(Path(vm.socket))
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}")
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 stop Firecracker PID {vm.pid}: process identity is ambiguous"
)
if observation == "gone":
self._remove_runtime_sockets(vm)
return
try:
os.kill(vm.pid, signal.SIGTERM)
pidfd = _open_pidfd(vm.pid)
except ProcessLookupError:
self._remove_socket(Path(vm.socket))
self._reap_child(vm.pid)
self._remove_runtime_sockets(vm)
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_runtime_sockets(vm)
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 +430,36 @@ 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)
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)
def _remove_runtime_sockets(self, vm: VmRecord) -> None:
self._remove_socket(self._settings.guest_socket_path(vm.id))
self._remove_socket(Path(vm.socket))
@staticmethod
def _remove_socket(socket_path: Path) -> None:
@@ -162,9 +467,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 +478,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
+527
View File
@@ -0,0 +1,527 @@
"""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)])
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.
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.
Legacy non-RFC1918 addresses are allowed here; only the recorded subnet,
when present, determines host-address boundaries, never current settings.
"""
self._require_offline(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", "-D", *selector), check=False)
remaining = self._run(("conntrack", "-L", *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.
Normalize handles, empty set presentation and implied protocol dependencies
without losing address-family constraints. 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
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
definitions, rules = [], {}
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)
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]):
matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions
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=(",", ":"))
+473
View File
@@ -0,0 +1,473 @@
"""Standalone, stdlib-only guest execution agent (Python 3.10+).
This file is copied into guest images and must not import the host application.
The host also imports its wire-format helpers; importing it never starts a server.
"""
from __future__ import annotations
import base64
import errno
import fcntl
import json
import math
import os
import pwd
import re
import selectors
import signal
import socket
import stat
import struct
import subprocess
import sys
import termios
import threading
import time
import uuid
from pathlib import Path
PROTOCOL_VERSION = 1
AGENT_PORT = 1070
DEFAULT_TIMEOUT_SECONDS = 60.0
MAX_TIMEOUT_SECONDS = 3600.0
MAX_COMMAND_BYTES = 65536
MAX_OUTPUT_BYTES = 1024 * 1024 # Combined stdout and stderr, before UTF-8 decoding.
MAX_REQUEST_BYTES = 6 * MAX_COMMAND_BYTES + 4096
MAX_RESPONSE_BYTES = 6 * MAX_OUTPUT_BYTES + 4096
CONNECT_TIMEOUT_SECONDS = 5.0
CLEANUP_SECONDS = 5.0
MAX_CONCURRENT_COMMANDS = 8
MAX_TERMINAL_BYTES = 8192
MAX_TERMINAL_FRAME = 16384
CONFIG_PATH = Path("/etc/uvm-agent.json")
class ProtocolError(ValueError):
"""An invalid or oversized wire message; never include command text."""
class ExecutionFailure(Exception):
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.outcome_unknown = outcome_unknown
def validate_terminal_size(rows: object, cols: object) -> None:
if type(rows) is not int or type(cols) is not int or not (2 <= rows <= 200 and 2 <= cols <= 400):
raise ProtocolError("terminal dimensions must be integers: 2–200 rows and 2–400 columns")
def terminal_data(value: object) -> bytes:
if not isinstance(value, str) or len(value) > 4 * ((MAX_TERMINAL_BYTES + 2) // 3):
raise ProtocolError("terminal data exceeds its size limit")
try:
data = base64.b64decode(value, validate=True)
except (ValueError, UnicodeError) as error:
raise ProtocolError("invalid terminal data") from error
if len(data) > MAX_TERMINAL_BYTES:
raise ProtocolError("terminal data exceeds its size limit")
return data
def validate_command(command: object, sudo: object, timeout_seconds: object) -> None:
if not isinstance(command, str) or not command.strip() or "\0" in command:
raise ProtocolError("command must be non-empty text without NUL characters")
try:
size = len(command.encode("utf-8"))
except UnicodeError as error:
raise ProtocolError("command must be valid UTF-8 text") from error
if size > MAX_COMMAND_BYTES:
raise ProtocolError(f"command exceeds {MAX_COMMAND_BYTES} UTF-8 bytes")
if type(sudo) is not bool:
raise ProtocolError("sudo must be a boolean")
try:
valid_timeout = (
type(timeout_seconds) in (int, float)
and math.isfinite(timeout_seconds)
and 0 < timeout_seconds <= MAX_TIMEOUT_SECONDS
)
except OverflowError:
valid_timeout = False
if not valid_timeout:
raise ProtocolError(f"timeout_seconds must be finite and greater than 0, up to {MAX_TIMEOUT_SECONDS:g}")
def _remaining(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("guest protocol deadline exceeded")
return remaining
def receive_exact(connection: socket.socket, size: int, deadline: float) -> bytes:
parts = bytearray()
while len(parts) < size:
connection.settimeout(_remaining(deadline))
chunk = connection.recv(size - len(parts))
if not chunk:
raise EOFError("guest connection closed")
parts.extend(chunk)
return bytes(parts)
def receive_frame(connection: socket.socket, limit: int, deadline: float) -> dict:
size, = struct.unpack("!I", receive_exact(connection, 4, deadline))
if not 0 < size <= limit:
raise ProtocolError("invalid guest message length")
try:
message = json.loads(receive_exact(connection, size, deadline))
except (ValueError, UnicodeError, RecursionError) as error:
raise ProtocolError("invalid guest JSON message") from error
if not isinstance(message, dict):
raise ProtocolError("guest message must be an object")
return message
def send_frame(connection: socket.socket, message: dict, limit: int, deadline: float) -> None:
payload = json.dumps(message, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
if len(payload) > limit:
raise ProtocolError("guest message exceeds its size limit")
connection.settimeout(_remaining(deadline))
connection.sendall(struct.pack("!I", len(payload)) + payload)
class JobScope:
"""A delegated guest cgroup, keeping detached descendants in the same job."""
def __init__(self) -> None:
entries = Path("/proc/self/cgroup").read_text(encoding="ascii").splitlines()
unified = next((entry[3:] for entry in entries if entry.startswith("0::")), None)
if unified is None or ".." in Path(unified).parts:
raise ExecutionFailure("guest execution requires a delegated cgroup v2 hierarchy")
parent = Path("/sys/fs/cgroup") / unified.lstrip("/")
self.path = parent / f"uvm-exec-{uuid.uuid4().hex}"
self.path.mkdir(mode=0o700)
if not (self.path / "cgroup.kill").is_file():
self.path.rmdir()
raise ExecutionFailure("guest execution requires cgroup.kill (Linux 5.14 or newer)")
def kill(self) -> None:
(self.path / "cgroup.kill").write_text("1", encoding="ascii")
def close(self) -> None:
# cgroup.events reports descendants as well as processes in this cgroup.
deadline = time.monotonic() + 1.0
while "populated 1" in (self.path / "cgroup.events").read_text(encoding="ascii"):
if time.monotonic() >= deadline:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
time.sleep(0.01)
for child in sorted(self.path.rglob("*"), key=lambda path: len(path.parts), reverse=True):
if child.is_dir():
child.rmdir()
self.path.rmdir()
def _signal_group(process: subprocess.Popen, number: int) -> None:
# Do not reap the group leader until after signaling: its PID pins the PGID.
try:
os.killpg(process.pid, number)
except ProcessLookupError:
pass
def _exited(process: subprocess.Popen) -> bool:
return os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None
def _cleanup_job(process: subprocess.Popen | None, scope: JobScope) -> None:
failed = False
try:
scope.kill()
except (OSError, ExecutionFailure):
failed = True
if process is not None:
try:
_signal_group(process, signal.SIGKILL)
process.wait(timeout=1.0)
except (OSError, subprocess.TimeoutExpired):
failed = True
finally:
for name in ("stdout", "stderr"):
stream = getattr(process, name)
if stream is not None:
stream.close()
try:
scope.close()
except (OSError, ExecutionFailure):
failed = True
if failed:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=process is not None)
def run_command(command: str, username: str, timeout_seconds: float, *, scope_factory=JobScope) -> dict:
"""Run one non-interactive guest job, drain both pipes, and bound its lifetime."""
scope = scope_factory()
process = None
buffers = {"stdout": bytearray(), "stderr": bytearray()}
captured = 0
truncated = False
timed_out = False
try:
# The fresh child joins its scope BEFORE dropping credentials or executing
# user text. Avoid preexec_fn in the threaded agent.
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--child", str(scope.path), username, command],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
deadline = time.monotonic() + timeout_seconds
terminating = None
killed = False
with selectors.DefaultSelector() as selector:
for name in buffers:
stream = getattr(process, name)
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, name)
while True:
now = time.monotonic()
exited = _exited(process)
if terminating is None and (exited or now >= deadline):
timed_out = not exited
terminating = now
_signal_group(process, signal.SIGTERM if timed_out else signal.SIGKILL)
if terminating is not None:
if not killed and (exited or now >= terminating + 0.5):
scope.kill()
_signal_group(process, signal.SIGKILL)
killed = True
if killed and exited and not selector.get_map():
break
if now >= terminating + 3.0:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
for key, _events in selector.select(0.05):
chunk = os.read(key.fileobj.fileno(), 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
keep = min(len(chunk), MAX_OUTPUT_BYTES - captured)
buffers[key.data].extend(chunk[:keep])
captured += keep
truncated |= keep < len(chunk)
# Finally below kills the scope and reaps the leader before returning.
except OSError as error:
raise ExecutionFailure("guest command execution failed", outcome_unknown=process is not None) from error
finally:
_cleanup_job(process, scope)
return {
"exit_code": None if timed_out else process.returncode,
"stdout": buffers["stdout"].decode("utf-8", errors="replace"),
"stderr": buffers["stderr"].decode("utf-8", errors="replace"),
"timed_out": timed_out,
"truncated": truncated,
}
def child_main(scope: Path, username: str, command: str | None) -> None:
"""Enter the job cgroup and account in a fresh interpreter, then replace it."""
(scope / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
if command is None:
# Popen's start_new_session already performed setsid in this fresh child.
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
account = pwd.getpwnam(username)
if os.geteuid() == 0:
os.initgroups(account.pw_name, account.pw_gid)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
elif (account.pw_uid, account.pw_gid) != (os.geteuid(), os.getegid()):
raise PermissionError("guest agent cannot assume the requested account")
os.chdir(account.pw_dir)
os.umask(0o022)
environment = {
"HOME": account.pw_dir, "USER": account.pw_name, "LOGNAME": account.pw_name,
"SHELL": "/bin/sh", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
}
if command is None:
shell = account.pw_shell or "/bin/sh"
environment.update(SHELL=shell, TERM="xterm-256color", COLORTERM="truecolor")
os.execve(shell, [shell, "-i"], environment)
else:
os.execve("/bin/sh", ["sh", "-c", command], environment)
def run_terminal(connection: socket.socket, username: str, rows: int, cols: int, *, scope_factory=JobScope) -> None:
"""Bridge one real guest PTY until shell exit, explicit close, or host loss."""
validate_terminal_size(rows, cols)
scope = scope_factory()
process = None
master = slave = None
try:
master, slave = os.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--terminal-child", str(scope.path), username],
stdin=slave, stdout=slave, stderr=slave, start_new_session=True,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
os.close(slave)
slave = None
os.set_blocking(master, False)
send_frame(connection, {"type": "ready"}, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
pending = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(connection, selectors.EVENT_READ, "host")
selector.register(master, selectors.EVENT_READ, "pty")
finished = False
while not finished:
for key, events in selector.select(0.1):
if key.data == "host":
message = receive_frame(connection, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if message == {"type": "close"}:
finished = True
break
if set(message) == {"type", "data"} and message["type"] == "input":
pending.extend(terminal_data(message["data"]))
if len(pending) > 65536:
raise ProtocolError("terminal input backlog exceeded")
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
validate_terminal_size(message["rows"], message["cols"])
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", message["rows"], message["cols"], 0, 0))
else:
raise ProtocolError("invalid terminal control message")
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
else:
if events & selectors.EVENT_READ:
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except OSError as error:
if error.errno != errno.EIO:
raise
data = b""
if not data:
finished = True
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if events & selectors.EVENT_WRITE and pending:
try:
del pending[:os.write(master, pending)]
except BlockingIOError:
pass
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
if _exited(process):
# Drain the leader's final output before terminating any background jobs.
for _ in range(16):
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except (BlockingIOError, OSError):
break
if not data:
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
break
finally:
try:
_cleanup_job(process, scope)
finally:
for descriptor in (master, slave):
if descriptor is not None:
os.close(descriptor)
send_frame(connection, {"type": "exit", "exit_code": process.returncode}, MAX_TERMINAL_FRAME,
time.monotonic() + CONNECT_TIMEOUT_SECONDS)
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command, terminal_runner=run_terminal) -> None:
with connection:
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(connection, {"version": PROTOCOL_VERSION, **config}, 4096, deadline)
request = receive_frame(connection, MAX_REQUEST_BYTES, deadline)
if set(request) == {"version", "terminal"}:
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
size = request["terminal"]
if not isinstance(size, dict) or set(size) != {"rows", "cols"}:
raise ProtocolError("invalid terminal request")
validate_terminal_size(size["rows"], size["cols"])
terminal_runner(connection, config["username"], size["rows"], size["cols"])
return
if set(request) != {"version", "command", "sudo", "timeout_seconds"}:
raise ProtocolError("invalid execution request fields")
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
validate_command(request["command"], request["sudo"], request["timeout_seconds"])
username = "root" if request["sudo"] else config["username"]
result = runner(request["command"], username, request["timeout_seconds"])
send_frame(connection, result, MAX_RESPONSE_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (ProtocolError, ExecutionFailure, OSError, KeyError) as error:
# Never log request text, guest output, or credentials to the console.
try:
send_frame(connection, {
"error": "guest execution failed: " + str(error),
"outcome_unknown": isinstance(error, ExecutionFailure) and error.outcome_unknown,
}, 4096, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (OSError, ProtocolError):
pass
except EOFError:
pass
def load_config(path: Path = CONFIG_PATH) -> dict:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(descriptor, encoding="utf-8") as source:
metadata = os.fstat(source.fileno())
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
or metadata.st_mode & 0o077 or metadata.st_size > 4096):
raise ValueError("guest agent configuration must be a root-owned private regular file")
config = json.load(source)
if (not isinstance(config, dict) or set(config) != {"vm_id", "username"}
or not isinstance(config["vm_id"], str)
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", config["vm_id"]) is None
or not isinstance(config["username"], str)
or re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", config["username"]) is None):
raise ValueError("invalid guest agent configuration")
pwd.getpwnam(config["username"])
return config
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--terminal-child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], None)
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest terminal: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) == 5 and sys.argv[1] == "--child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest command: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) != 1 or os.geteuid() != 0:
raise SystemExit("uvm-agent must run as a root systemd service")
config = load_config()
slots = threading.BoundedSemaphore(MAX_CONCURRENT_COMMANDS)
def handle(connection: socket.socket) -> None:
try:
serve_connection(connection, config)
finally:
slots.release()
with socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) as listener:
listener.bind((socket.VMADDR_CID_ANY, AGENT_PORT))
listener.listen(MAX_CONCURRENT_COMMANDS)
while True:
connection, peer = listener.accept()
# Guest-local callers must not use the root agent to elevate themselves.
if peer[0] != socket.VMADDR_CID_HOST:
connection.close()
continue
if not slots.acquire(blocking=False):
connection.close()
continue
threading.Thread(target=handle, args=(connection,), daemon=True).start()
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
"""Host-side, one-shot guest execution over Firecracker's vsock Unix socket."""
from __future__ import annotations
import socket
import stat
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from .guest_agent import (
AGENT_PORT, CLEANUP_SECONDS, CONNECT_TIMEOUT_SECONDS, MAX_OUTPUT_BYTES,
MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, ProtocolError,
receive_exact, receive_frame, send_frame, validate_command,
)
@dataclass(frozen=True, slots=True)
class ExecResult:
exit_code: int | None
stdout: str
stderr: str
timed_out: bool
truncated: bool
def validate_exec(command: object, sudo: object, timeout_seconds: object) -> None:
try:
validate_command(command, sudo, timeout_seconds)
except ProtocolError as error:
raise ValidationError(str(error)) from error
class GuestSession:
"""A connected session pins the selected VMM; never reconnect after submission."""
def __init__(self, connection: socket.socket) -> None:
self._connection = connection
@classmethod
def connect(cls, path: Path, vm_id: str, username: str, pid: int) -> GuestSession:
connection = None
try:
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
metadata = path.lstat()
if not stat.S_ISSOCK(metadata.st_mode):
raise ProtocolError("guest socket is not a Unix socket")
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
connection.settimeout(CONNECT_TIMEOUT_SECONDS)
connection.connect(str(path))
peer_pid, _uid, _gid = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if peer_pid != pid:
raise ProtocolError("guest socket does not belong to the selected VM process")
connection.sendall(f"CONNECT {AGENT_PORT}\n".encode("ascii"))
acknowledgement = bytearray()
while not acknowledgement.endswith(b"\n") and len(acknowledgement) < 32:
acknowledgement.extend(receive_exact(connection, 1, deadline))
parts = bytes(acknowledgement).split()
if (not acknowledgement.endswith(b"\n") or len(parts) != 2
or parts[0] != b"OK" or not parts[1].isdigit()):
raise ProtocolError("invalid Firecracker vsock acknowledgement")
hello = receive_frame(connection, 4096, deadline)
if (set(hello) != {"version", "vm_id", "username"}
or type(hello["version"]) is not int or hello["version"] != PROTOCOL_VERSION
or hello["vm_id"] != vm_id or hello["username"] != username):
raise ProtocolError("guest agent identity or protocol version does not match this VM")
return cls(connection)
except (OSError, EOFError, ProtocolError) as error:
if connection is not None:
connection.close()
raise GuestAgentUnavailableError(
"guest agent is unavailable or not ready; wait for guest startup, or restart the VM "
"to apply automatic vsock guest-agent setup (vsock-capable kernel required)"
) from error
def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
validate_exec(command, sudo, timeout_seconds)
try:
send_frame(self._connection, {
"version": PROTOCOL_VERSION, "command": command,
"sudo": sudo, "timeout_seconds": timeout_seconds,
}, MAX_REQUEST_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
result = receive_frame(
self._connection, MAX_RESPONSE_BYTES,
time.monotonic() + timeout_seconds + CLEANUP_SECONDS + CONNECT_TIMEOUT_SECONDS,
)
if "error" in result:
if (set(result) != {"error", "outcome_unknown"} or not isinstance(result["error"], str)
or len(result["error"]) > 4096 or type(result["outcome_unknown"]) is not bool):
raise ProtocolError("invalid guest failure response")
result["error"].encode("utf-8") # Reject invalid Unicode before rendering an HTTP error.
raise GuestExecutionError(result["error"], outcome_unknown=result["outcome_unknown"])
if set(result) != {"exit_code", "stdout", "stderr", "timed_out", "truncated"}:
raise ProtocolError("invalid guest result fields")
if (type(result["timed_out"]) is not bool or type(result["truncated"]) is not bool
or not isinstance(result["stdout"], str) or not isinstance(result["stderr"], str)
or (result["timed_out"] and result["exit_code"] is not None)
or (not result["timed_out"] and (
type(result["exit_code"]) is not int or not -64 <= result["exit_code"] <= 255))):
raise ProtocolError("invalid guest result types")
if sum(len(result[name].encode("utf-8")) for name in ("stdout", "stderr")) > 3 * MAX_OUTPUT_BYTES:
raise ProtocolError("guest output exceeds its limit")
return ExecResult(**result)
except (OSError, EOFError, ProtocolError, UnicodeError) as error:
raise GuestExecutionError(
"guest execution connection was lost or returned an invalid result; "
"the command may have run and will not be retried",
outcome_unknown=True,
) from error
def close(self) -> None:
self._connection.close()
+89
View File
@@ -0,0 +1,89 @@
"""Interactive, process-pinned guest terminal transport over the existing vsock agent."""
from __future__ import annotations
import asyncio
import base64
import json
import struct
import time
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
from .guest_agent import (
CONNECT_TIMEOUT_SECONDS, MAX_TERMINAL_FRAME, PROTOCOL_VERSION, ProtocolError,
receive_frame, send_frame, terminal_data, validate_terminal_size,
)
from .guest_exec import GuestSession
def validate_size(rows: object, cols: object) -> None:
try:
validate_terminal_size(rows, cols)
except ProtocolError as error:
raise ValidationError(str(error)) from error
class GuestTerminal(GuestSession):
def start(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(self._connection, {"version": PROTOCOL_VERSION, "terminal": {"rows": rows, "cols": cols}},
MAX_TERMINAL_FRAME, deadline)
response = receive_frame(self._connection, MAX_TERMINAL_FRAME, deadline)
if response != {"type": "ready"}:
raise ProtocolError("terminal support is unavailable")
self._connection.setblocking(False)
self._write_lock = asyncio.Lock()
except (OSError, EOFError, ProtocolError) as error:
self.close()
raise GuestAgentUnavailableError(
"Interactive terminal support is unavailable. Restart this VM to apply automatic guest-agent setup, "
"then wait for the agent to start and launch a new session."
) from error
async def _receive_exact(self, size: int) -> bytes:
data = bytearray()
while len(data) < size:
chunk = await asyncio.get_running_loop().sock_recv(self._connection, size - len(data))
if not chunk:
raise EOFError("guest terminal connection closed")
data.extend(chunk)
return bytes(data)
async def read(self) -> dict:
try:
# An idle shell has no output deadline; partial messages are bounded.
first = await self._receive_exact(1)
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
size, = struct.unpack("!I", first + await self._receive_exact(3))
if not 0 < size <= MAX_TERMINAL_FRAME:
raise ProtocolError("invalid terminal frame length")
message = json.loads(await self._receive_exact(size))
if isinstance(message, dict):
if set(message) == {"type", "data"} and message["type"] == "output":
return {"type": "output", "data": terminal_data(message["data"])}
if (set(message) == {"type", "exit_code"} and message["type"] == "exit"
and type(message["exit_code"]) is int and -64 <= message["exit_code"] <= 255):
return message
raise ProtocolError("invalid guest terminal response")
except (OSError, EOFError, ValueError, UnicodeError, RecursionError) as error:
raise GuestExecutionError("The guest terminal connection ended unexpectedly.", outcome_unknown=True) from error
async def _send(self, message: dict) -> None:
payload = json.dumps(message, separators=(",", ":")).encode("ascii")
if len(payload) > MAX_TERMINAL_FRAME:
raise ValidationError("terminal input exceeds its size limit")
async with self._write_lock:
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
await asyncio.get_running_loop().sock_sendall(self._connection, struct.pack("!I", len(payload)) + payload)
async def write(self, data: bytes) -> None:
await self._send({"type": "input", "data": base64.b64encode(data).decode("ascii")})
async def resize(self, rows: int, cols: int) -> None:
validate_size(rows, cols)
await self._send({"type": "resize", "rows": rows, "cols": cols})
async def end(self) -> None:
await self._send({"type": "close"})
+556 -22
View File
@@ -2,16 +2,23 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import stat
import tempfile
import time
from dataclasses import dataclass
from ipaddress import IPv4Address
from pathlib import Path
from .config import Settings
from .errors import UvmError
from . import guest_agent
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError, ValidationError
from .integrity import load_manifest, verify_file
from .system import CommandRunner
from .validation import parse_username
_FIRECRACKER_DEMO_PUBLIC_KEY = (
@@ -32,6 +39,69 @@ _HOST_KEY_TYPES = (
("ed25519", None),
)
# Keep the kernel's proto-boot default route until networkd takes over. On
# Ubuntu 22.04, KeepConfiguration=static preserves addresses but drops that route.
GUEST_NETWORK_TEMPLATE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=yes\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
_LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
"KeepConfiguration=yes", "KeepConfiguration=static",
)
GUEST_AGENT_SERVICE = """\
[Unit]
Description=UVM guest command execution agent
After=local-fs.target network.target
ConditionPathExists=/etc/uvm-agent.json
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStartPre=/bin/sh /usr/lib/uvm/prepare-agent.sh
ExecStart=/usr/bin/python3 -I /usr/lib/uvm/agent.py
Restart=on-failure
RestartSec=5
TimeoutStartSec=600
TimeoutStopSec=5
KillMode=control-group
Delegate=yes
UMask=0077
[Install]
WantedBy=multi-user.target
"""
# Runs only INSIDE the VM. Old Jammy guests can acquire the runtime without SSH
# or executing a retained guest's binaries in a privileged host chroot.
GUEST_AGENT_PREPARE = """\
set -eu
check_python() {
/usr/bin/python3 -I -c 'import sys, json, math, pwd, selectors, socket, struct, subprocess, threading, uuid; assert sys.version_info >= (3, 10)' 2>/dev/null
}
if check_python; then
exit 0
fi
export DEBIAN_FRONTEND=noninteractive LC_ALL=C
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -y --no-install-recommends install python3
check_python
"""
# The original v4 template shipped this exact agent without an ownership
# manifest. Recognize that payload when creating from a cached v4 template.
_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948"
def guest_agent_files() -> dict[str, bytes]:
"""The same standalone payload is used by the builder and offline upgrades."""
return {
"/usr/lib/uvm/agent.py": Path(guest_agent.__file__).read_bytes(),
"/usr/lib/uvm/prepare-agent.sh": GUEST_AGENT_PREPARE.encode("utf-8"),
"/etc/systemd/system/uvm-agent.service": GUEST_AGENT_SERVICE.encode("utf-8"),
}
@dataclass(frozen=True, slots=True)
class GuestAssets:
@@ -58,23 +128,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,26 +145,349 @@ class ImageStore:
)
return GuestAssets(kernel=kernel, rootfs=rootfs)
def create_vm_disk(self, source: Path, destination: Path) -> Path:
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, *, storage_mib: int = MIN_STORAGE_MIB,
) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM."""
self._validate_storage(storage_mib)
if destination.exists():
raise UvmError(f"VM disk already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(source, destination)
self._copy_sparse(source, destination)
destination.chmod(0o600)
except OSError as error:
self._resize_filesystem(destination, storage_mib)
except BaseException as error:
try:
destination.unlink()
except FileNotFoundError:
pass
if isinstance(error, OSError):
raise UvmError(f"could not create VM disk {destination}: {error}") from error
raise
return destination
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Set an existing guest account password in an offline ext4 disk."""
def disk_size_bytes(self, disk: Path) -> int:
"""Read logical disk capacity, including a resize published before state was saved."""
try:
return disk.stat().st_size
except OSError as error:
raise UvmError(f"could not inspect private disk capacity: {disk}: {error}") from error
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
"""Resize a proven-offline private disk on a copy, then publish atomically."""
self._validate_storage(storage_mib)
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
target_bytes = storage_mib * 1024 * 1024
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
return # A previous resize may have published its disk before saving metadata.
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(name)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
self._resize_filesystem(temporary, storage_mib)
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
finally:
temporary.unlink(missing_ok=True)
def configure_guest_network(self, disk: Path, guest_ip: str) -> bool:
"""Specialize an unedited UVM profile on a proven-offline private disk.
Lifecycle owns the operation lock and confirms process exit. Stage and
check a sparse copy so failed migrations retain the original disk. A
completed migration, custom image, or guest-edited profile is a no-op.
"""
try:
address = IPv4Address(guest_ip)
except ValueError as error:
raise ValidationError("guest network configuration requires a valid IPv4 address") from error
if address not in self._settings.network or address in {
self._settings.network.network_address,
self._settings.network.broadcast_address,
self._settings.gateway,
}:
raise ValidationError("guest network configuration requires a usable IP inside the UVM subnet")
try:
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to configure: {disk}")
with tempfile.TemporaryDirectory(prefix="uvm-guest-network-") as name:
files = Path(name)
if self._unconfigured_guest_network(disk, files) is None:
return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-network-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(candidate)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before guest network configuration: {detail}")
# Journal replay may reveal guest edits not visible to the
# initial debugfs read. Recheck ownership on the recovered copy.
profile = self._unconfigured_guest_network(temporary, files)
if profile is None:
return False
profile.path.write_text(
GUEST_NETWORK_TEMPLATE
+ f"Address={address}/{self._settings.network.prefixlen}\n"
+ f"Gateway={self._settings.gateway}\n",
encoding="utf-8",
)
self._write_guest_file(temporary, "/etc/systemd/network/10-uvm.network", profile)
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
with temporary.open("rb") as image:
os.fsync(image.fileno())
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return True
except (OSError, UnicodeError) as error:
raise UvmError(f"could not configure guest networking: {error}") from error
def _unconfigured_guest_network(self, disk: Path, temporary: Path) -> _GuestFile | None:
marker = self._read_guest_file(
disk, "/etc/uvm-image", temporary / "marker", required=False, regular_only=True,
)
if marker is None or marker.path.read_text(encoding="utf-8") not in {
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
"uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n",
}:
return None
profile = self._read_guest_file(
disk, "/etc/systemd/network/10-uvm.network", temporary / "network", required=False, regular_only=True,
)
if (profile is None or (profile.uid, profile.gid, profile.mode, profile.links) != (0, 0, 0o644, 1)
or profile.has_extended_attributes):
return None
if profile.path.read_text(encoding="utf-8") not in {
_LEGACY_GUEST_NETWORK_TEMPLATE, GUEST_NETWORK_TEMPLATE,
}:
return None
return profile
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool:
"""Ensure every guest has its vsock agent before booting its private disk.
The lifecycle lock and confirmed VMM exit are required. A checked sparse
copy preserves the original disk on failure and makes retries atomic.
Return False only when the managed files and service are already current.
An explicit install request always stages and rechecks the installation.
"""
parse_username(username)
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
raise ValidationError("invalid VM ID for guest agent configuration")
try:
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError("private VM disk is unsafe for guest agent installation")
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
files = Path(name)
if not install and self._guest_agent_current(disk, files, vm_id, username):
return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(candidate)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
if checked.returncode not in (0, 1):
raise UvmError("filesystem check failed before guest agent installation")
# All ownership/content checks happen AFTER journal replay.
self._install_agent_files(temporary, files, vm_id, username)
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
with temporary.open("rb") as image:
os.fsync(image.fileno())
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return True
except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest agent: {error}") from error
@staticmethod
def _agent_payloads(vm_id: str, username: str) -> dict[str, bytes]:
payloads = guest_agent_files()
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
return payloads
def _guest_agent_current(self, disk: Path, files: Path, vm_id: str, username: str) -> bool:
"""Read-only fast path; changed installations are checked again after journal replay."""
payloads = self._agent_payloads(vm_id, username)
for index, (path, content) in enumerate(payloads.items()):
existing = self._agent_file(disk, path, files / f"current-{index}")
mode = 0o600 if path.endswith(".json") else 0o644
if (existing is None or (existing.mode, existing.gid) != (mode, 0)
or existing.path.read_bytes() != content):
return False
manifest = self._agent_file(disk, "/etc/uvm-agent-files.json", files / "current-manifest")
if manifest is None or (manifest.mode, manifest.gid) != (0o600, 0):
return False
try:
hashes = json.loads(manifest.path.read_bytes())
except (ValueError, UnicodeError):
return False
if hashes != {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}:
return False
service = self._runner.run((
"debugfs", "-R", "stat /etc/systemd/system/multi-user.target.wants/uvm-agent.service", disk,
), capture=True)
return (re.search(r"Type:\s+symlink\b", service.stdout) is not None
and 'Fast link dest: "/etc/systemd/system/uvm-agent.service"' in service.stdout)
def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None:
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
if re.search(r"Type:\s+regular\b", result.stdout) is None:
raise UvmError(
"automatic vsock guest-agent setup requires a systemd/apt image (Ubuntu 22.04 or newer); "
"use the default Ubuntu rootfs or a compatible custom image"
)
for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
self._agent_directory(disk, directory)
payloads = self._agent_payloads(vm_id, username)
manifest_path = "/etc/uvm-agent-files.json"
previous = self._agent_file(disk, manifest_path, files / "manifest")
old_hashes = {}
if previous is not None:
try:
old_hashes = json.loads(previous.path.read_bytes())
except (ValueError, UnicodeError) as error:
raise UvmError("invalid guest agent ownership manifest") from error
if (not isinstance(old_hashes, dict) or set(old_hashes) - set(payloads)
or any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
for value in old_hashes.values())):
raise UvmError("invalid guest agent ownership manifest")
hashes = {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}
for index, (path, content) in enumerate(payloads.items()):
existing = self._agent_file(disk, path, files / f"existing-{index}")
if existing is not None:
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
allowed = {hashes[path], old_hashes.get(path)}
if not old_hashes and path == "/usr/lib/uvm/agent.py":
allowed.add(_V4_TEMPLATE_AGENT_SHA256)
if digest not in allowed:
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
source = files / f"payload-{index}"
source.write_bytes(content)
self._write_guest_file(disk, path, _GuestFile(source, 0o600 if path.endswith(".json") else 0o644, 0, 0))
source = files / "new-manifest"
source.write_text(json.dumps(hashes, sort_keys=True) + "\n", encoding="utf-8")
self._write_guest_file(disk, manifest_path, _GuestFile(source, 0o600, 0, 0))
link = "/etc/systemd/system/multi-user.target.wants/uvm-agent.service"
target = "/etc/systemd/system/uvm-agent.service"
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
self._runner.run(("debugfs", "-w", "-R", f"symlink {link} {target}", disk), capture=True)
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
if (re.search(r"Type:\s+symlink\b", result.stdout) is None
or f'Fast link dest: "{target}"' not in result.stdout):
raise UvmError("could not enable guest agent without replacing a customized service link")
def _agent_directory(self, disk: Path, path: str) -> None:
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
self._create_guest_directory(disk, path, 0o755, 0, 0)
return
mode, owner = _MODE_PATTERN.search(result.stdout), _OWNER_PATTERN.search(result.stdout)
if (re.search(r"Type:\s+directory\b", result.stdout) is None or mode is None or owner is None
or int(owner.group(1)) != 0 or int(mode.group(1), 8) & 0o022):
raise UvmError(f"unsafe guest agent directory: {path}")
def _agent_file(self, disk: Path, path: str, destination: Path) -> _GuestFile | None:
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
if "file not found" in (result.stdout + result.stderr).lower():
return None
if re.search(r"Type:\s+regular\b", result.stdout) is None:
raise UvmError(f"unsafe guest agent file: {path}")
source = self._read_guest_file(disk, path, destination)
if (source.uid != 0 or source.mode & 0o022 or source.links != 1 or source.has_extended_attributes):
raise UvmError(f"unsafe guest agent file: {path}")
return source
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
def _copy_sparse(self, source: Path, destination: Path) -> None:
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
def _filesystem_size(self, disk: Path) -> int:
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
if blocks is None or block_size is None:
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
return int(blocks.group(1)) * int(block_size.group(1))
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
"""Grow the file before ext4; shrink ext4 before truncating the file."""
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
target_bytes = storage_mib * 1024 * 1024
if disk.stat().st_size < target_bytes:
with disk.open("r+b") as image:
image.truncate(target_bytes)
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
if self._filesystem_size(disk) != target_bytes:
raise UvmError("resized ext4 filesystem does not match the requested capacity")
with disk.open("r+b") as image:
image.truncate(target_bytes)
image.flush()
os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Create a missing guest account and set its password in an offline ext4 disk."""
parse_username(username)
password_hash = self._password_hash(password)
try:
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
@@ -131,7 +515,7 @@ class ImageStore:
if ":" in line
}
if username not in account_names:
raise UvmError(f"guest user does not exist in rootfs: {username}")
self._create_guest_user(disk, username, passwd, shadow, temporary)
shadow.path.write_text(
_set_shadow_password(
@@ -142,6 +526,8 @@ class ImageStore:
encoding="utf-8",
)
shadow.path.chmod(shadow.mode)
if username != "root":
self._provision_guest_sudo(disk, username, temporary)
sshd_config.path.write_text(
_enable_ssh_password_authentication(
@@ -160,6 +546,136 @@ class ImageStore:
except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest credentials: {error}") from error
def _provision_guest_sudo(self, disk: Path, username: str, temporary: Path) -> None:
sudoers = self._read_guest_file(disk, "/etc/sudoers", temporary / "sudoers", required=False)
sudo = self._runner.run(("debugfs", "-R", "stat /usr/bin/sudo", disk), capture=True)
mode = _MODE_PATTERN.search(sudo.stdout)
owner = _OWNER_PATTERN.search(sudo.stdout)
if (
sudoers is None
or sudoers.uid != 0 or sudoers.mode & 0o022
or re.search(r"Type:\s+regular\b", sudo.stdout) is None
or mode is None or not int(mode.group(1), 8) & 0o4000
or not int(mode.group(1), 8) & 0o111
or owner is None or int(owner.group(1)) != 0
):
raise UvmError(
"guest image needs setuid-root sudo and /etc/sudoers for a custom administrator; "
"run uvm install --force to build the complete Ubuntu image"
)
contents = sudoers.path.read_text(encoding="utf-8").rstrip()
sudoers.path.write_text(
contents + "\n\n# Managed by uvm: guest administrator\n"
+ f"Defaults:{username} !rootpw, !runaspw, !targetpw\n"
+ f"{username} ALL=(ALL:ALL) PASSWD: ALL\n",
encoding="utf-8",
)
sudoers.path.chmod(sudoers.mode)
self._write_guest_file(disk, "/etc/sudoers", sudoers)
def _create_guest_user(
self,
disk: Path,
username: str,
passwd: _GuestFile,
shadow: _GuestFile,
temporary: Path,
) -> None:
if username == "root":
raise UvmError("guest rootfs is missing the root account")
group = self._read_guest_file(disk, "/etc/group", temporary / "group")
gshadow = self._read_guest_file(
disk, "/etc/gshadow", temporary / "gshadow", required=False,
)
assert group is not None
passwd_entries = [
line.split(":")
for line in passwd.path.read_text(encoding="utf-8").splitlines()
if ":" in line
]
group_entries = [
line.split(":")
for line in group.path.read_text(encoding="utf-8").splitlines()
if ":" in line
]
try:
used_uids = {int(fields[2]) for fields in passwd_entries}
used_gids = {int(fields[3]) for fields in passwd_entries}
used_gids.update(int(fields[2]) for fields in group_entries)
except (IndexError, ValueError) as error:
raise UvmError("guest rootfs has invalid passwd or group entries") from error
uid = _next_guest_id(used_uids)
existing_group = next((fields for fields in group_entries if fields[0] == username), None)
if existing_group is not None:
gid = int(existing_group[2])
else:
gid = uid if uid not in used_gids else _next_guest_id(used_gids)
home = f"/home/{username}"
updates = [
("/etc/passwd", passwd, f"{username}:x:{uid}:{gid}::{home}:/bin/sh"),
("/etc/shadow", shadow, f"{username}:!:{int(time.time() // 86400)}:0:99999:7:::"),
]
if existing_group is None:
updates.append(("/etc/group", group, f"{username}:x:{gid}:"))
if gshadow is not None:
updates.append(("/etc/gshadow", gshadow, f"{username}:!::"))
for guest_path, source, entry in updates:
contents = source.path.read_text(encoding="utf-8")
if any(line.split(":", 1)[0] == username for line in contents.splitlines()):
raise UvmError(f"guest rootfs already has an entry for {username} in {guest_path}")
separator = "" if not contents or contents.endswith("\n") else "\n"
source.path.write_text(f"{contents}{separator}{entry}\n", encoding="utf-8")
source.path.chmod(source.mode)
self._create_guest_directory(disk, "/home", 0o755, 0, 0, exist_ok=True)
self._create_guest_directory(disk, home, 0o700, uid, gid)
for guest_path, source, _entry in updates:
if guest_path != "/etc/shadow": # Published with its password below.
self._write_guest_file(disk, guest_path, source)
def _create_guest_directory(
self,
disk: Path,
guest_path: str,
mode: int,
uid: int,
gid: int,
*,
exist_ok: bool = False,
) -> None:
command = ("debugfs", "-R", f"stat {guest_path}", disk)
existing = self._runner.run(command, check=False, capture=True)
if exist_ok and re.search(r"Type:\s+directory\b", existing.stdout):
return
if "file not found" not in f"{existing.stdout}\n{existing.stderr}".lower():
raise UvmError(f"cannot create guest rootfs directory: {guest_path}")
self._runner.run(("debugfs", "-w", "-R", f"mkdir {guest_path}", disk), capture=True)
for field, value in (
("mode", f"0{0o40000 | mode:o}"),
("uid", str(uid)),
("gid", str(gid)),
):
self._runner.run(
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", disk),
capture=True,
)
# debugfs can report command failures while still returning exit status zero.
created = self._runner.run(command, capture=True)
mode_match = _MODE_PATTERN.search(created.stdout)
owner_match = _OWNER_PATTERN.search(created.stdout)
if (
re.search(r"Type:\s+directory\b", created.stdout) is None
or mode_match is None
or owner_match is None
or int(mode_match.group(1), 8) != mode
or (int(owner_match.group(1)), int(owner_match.group(2))) != (uid, gid)
):
raise UvmError(f"could not verify guest rootfs directory: {guest_path}")
def _password_hash(self, password: str) -> str:
result = self._runner.run(
("openssl", "passwd", "-6", "-stdin"),
@@ -181,12 +697,16 @@ class ImageStore:
destination: Path,
*,
required: bool = True,
regular_only: bool = False,
) -> _GuestFile | None:
stat_result = self._runner.run(
("debugfs", "-R", f"stat {guest_path}", disk),
check=False,
capture=True,
)
file_type = re.search(r"Type:\s+(\S+)", stat_result.stdout)
if regular_only and file_type is not None and file_type.group(1) != "regular":
return None
mode_match = _MODE_PATTERN.search(stat_result.stdout)
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)
@@ -348,6 +868,13 @@ class ImageStore:
raise UvmError("could not remove the insecure Firecracker demo SSH key")
def _next_guest_id(used: set[int]) -> int:
for candidate in range(1000, 60001):
if candidate not in used:
return candidate
raise UvmError("guest rootfs has no available regular user/group IDs")
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
lines = contents.splitlines()
for index, line in enumerate(lines):
@@ -368,12 +895,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
}
if username == "root":
managed_directives.add("permitrootlogin")
unmanaged_lines = []
global_lines = []
match_lines = []
in_match = False
for line in contents.splitlines():
stripped = line.lstrip()
directive = stripped.split(None, 1)[0].lower() if stripped else ""
if directive == "match":
in_match = True
if stripped.startswith("#") or directive not in managed_directives:
unmanaged_lines.append(line)
(match_lines if in_match else global_lines).append(line)
directives = [
"HostKey /etc/ssh/ssh_host_rsa_key",
@@ -385,13 +916,16 @@ def _enable_ssh_password_authentication(contents: str, username: str) -> str:
]
if username == "root":
directives.append("PermitRootLogin yes")
# Global-only directives (e.g. UsePAM and Subsystem) must precede every Match block.
globals_text = "\n".join(global_lines).strip("\n")
return (
"# Managed by uvm\n"
+ "\n".join(directives)
+ (f"\n{globals_text}" if globals_text else "")
+ f"\nMatch User {username}\n"
+ " PasswordAuthentication yes\n"
+ "Match all\n"
+ "\n".join(unmanaged_lines).rstrip()
+ "\n".join(match_lines).rstrip()
+ "\n"
)
+243 -26
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
import hashlib
import json
import os
import shlex
import shutil
import tarfile
import tempfile
@@ -11,14 +14,45 @@ import urllib.request
from collections.abc import Callable
from pathlib import Path
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
from .config import Settings
from .errors import UvmError
from .images import GuestAssets
from .integrity import require_checksum, sha256_file, verify_file, write_manifest
from .images import GUEST_NETWORK_TEMPLATE, GuestAssets, guest_agent_files
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
from .state import StateStore
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
_UBUNTU_RELEASE = "jammy"
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v5\n"
_GUEST_PACKAGES = (
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl", "python3",
)
# Keep the image mountable by the older Firecracker quickstart guest kernels.
_EXT4_FEATURES = (
"none,has_journal,ext_attr,resize_inode,dir_index,filetype,extent,64bit,"
"flex_bg,sparse_super,large_file,huge_file,dir_nlink,extra_isize,metadata_csum"
)
_ROOTFS_NAMESPACE = (
"unshare", "--mount", "--propagation", "private", "--pid", "--fork",
"--kill-child", "--mount-proc",
)
_GUEST_APT_SCRIPT = """\
root=$1
shift
mount --rbind /dev "$root/dev"
mount --make-rslave "$root/dev"
mount -t proc proc "$root/proc"
mount -t sysfs -o ro sysfs "$root/sys"
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -o APT::Update::Error-Mode=any update
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade
chroot "$root" /usr/bin/env DEBIAN_FRONTEND=noninteractive LC_ALL=C \\
apt-get -y --no-install-recommends install "$@"
"""
class Installer:
"""Install exactly the local host dependencies used by the uvm CLI."""
@@ -42,7 +76,6 @@ class Installer:
return self._install_locked(force_assets=force_assets)
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
self._validate_guest_asset_architecture()
self._validate_integrity_policy()
if self._settings.allow_unverified_downloads and self._emit is not None:
self._emit(
@@ -56,16 +89,6 @@ class Installer:
self._write_integrity_manifest(firecracker, assets)
return firecracker, assets
def _validate_guest_asset_architecture(self) -> None:
architecture = os.uname().machine
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
if architecture == "aarch64" and (default_kernel or default_rootfs):
raise UvmError(
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
"and UVM_ROOTFS_URL to compatible aarch64 assets"
)
def _validate_integrity_policy(self) -> None:
require_checksum(
self._settings.firecracker_sha256,
@@ -79,12 +102,18 @@ class Installer:
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
if self._settings.rootfs_url:
require_checksum(
self._settings.rootfs_sha256,
"guest root filesystem",
"UVM_ROOTFS_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
elif self._settings.rootfs_sha256:
raise UvmError(
"UVM_ROOTFS_SHA256 requires UVM_ROOTFS_URL for a prebuilt image; "
"unset UVM_ROOTFS_SHA256 to build the default Ubuntu rootfs"
)
def _write_integrity_manifest(self, firecracker: Path, assets: GuestAssets) -> None:
write_manifest(
@@ -99,7 +128,7 @@ class Installer:
(
self._settings.firecracker_sha256,
self._settings.kernel_sha256,
self._settings.rootfs_sha256,
self._settings.rootfs_sha256 if self._settings.rootfs_url else True,
)
),
)
@@ -110,19 +139,22 @@ class Installer:
"jq",
"iproute2",
"iptables",
"nftables",
"conntrack",
"coreutils",
"e2fsprogs",
"util-linux",
"ca-certificates",
"openssl",
"openssh-client",
)
if not self._settings.rootfs_url:
packages += ("debootstrap", "ubuntu-keyring")
self._runner.run(("apt-get", "update"))
self._runner.run(("apt-get", "install", "-y", *packages))
def install_firecracker_binary(self, *, force: bool = False) -> Path:
architecture = os.uname().machine
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {architecture}")
architecture = self._settings.architecture
firecracker = self._settings.firecracker_binary
jailer = self._settings.jailer_binary
@@ -193,14 +225,6 @@ class Installer:
label="guest kernel",
environment_name="UVM_KERNEL_SHA256",
)
if force or not rootfs.exists():
self.download(
self._settings.rootfs_url,
rootfs,
expected_sha256=self._settings.rootfs_sha256,
label="guest root filesystem",
environment_name="UVM_ROOTFS_SHA256",
)
verify_file(
kernel,
self._settings.kernel_sha256,
@@ -208,6 +232,17 @@ class Installer:
"UVM_KERNEL_SHA256",
allow_unverified=self._settings.allow_unverified_downloads,
)
if not self._settings.rootfs_url:
self._install_ubuntu_rootfs(rootfs, force=force)
elif force or not rootfs.exists():
self.download(
self._settings.rootfs_url,
rootfs,
expected_sha256=self._settings.rootfs_sha256,
label="guest root filesystem",
environment_name="UVM_ROOTFS_SHA256",
)
if self._settings.rootfs_url:
verify_file(
rootfs,
self._settings.rootfs_sha256,
@@ -217,6 +252,188 @@ class Installer:
)
return GuestAssets(kernel=kernel, rootfs=rootfs)
def _install_ubuntu_rootfs(self, destination: Path, *, force: bool) -> None:
if destination.is_file() and not force:
manifest = load_manifest(self._settings.integrity_manifest_path)
checksum = manifest.checksums.get("rootfs")
if manifest.verified and checksum:
verify_file(destination, checksum, "Ubuntu rootfs", "integrity manifest", allow_unverified=False)
marker = self._runner.run(
("debugfs", "-R", "cat /etc/uvm-image", destination), capture=True,
)
if marker.stdout == _ROOTFS_RECIPE:
return
# Rebuild old demo images and unverified caches rather than promoting their trust.
self._build_ubuntu_rootfs(destination)
def _build_ubuntu_rootfs(self, destination: Path) -> None:
architecture = "arm64" if self._settings.architecture == "aarch64" else "amd64"
mirror = (
"https://ports.ubuntu.com/ubuntu-ports" if architecture == "arm64"
else "https://archive.ubuntu.com/ubuntu"
)
if self._emit is not None:
self._emit("Building Ubuntu 22.04 rootfs with sudo, OpenSSH, and apt/dpkg metadata")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(prefix=".ubuntu-build-", dir=destination.parent) as name:
temporary = Path(name)
root = temporary / "root"
build_log = temporary / "build.log"
try:
# Bootstrap one consistent release; apt resolves updated package versions later.
self._run_rootfs_stage("Ubuntu bootstrap", (
*_ROOTFS_NAMESPACE, "env", "DEBIAN_FRONTEND=noninteractive",
"debootstrap", f"--arch={architecture}", "--variant=minbase", "--merged-usr",
"--components=main", "--include=ca-certificates,ubuntu-keyring",
"--force-check-gpg", "--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg",
_UBUNTU_RELEASE, root, mirror,
), build_log)
self._install_ubuntu_packages(root, mirror, build_log)
self._configure_ubuntu_rootfs(root)
self._validate_ubuntu_rootfs(root)
disk = temporary / "rootfs.ext4"
with disk.open("wb") as image:
image.truncate(2 * 1024 * 1024 * 1024)
disk.chmod(0o600)
self._run_rootfs_stage("Ubuntu ext4 formatting", (
"mkfs.ext4", "-q", "-F", "-b", "4096", "-O", _EXT4_FEATURES,
"-E", "lazy_itable_init=0,lazy_journal_init=0", "-d", root, disk,
), build_log)
self._run_rootfs_stage("Ubuntu filesystem check", ("e2fsck", "-f", "-n", disk), build_log)
except (UvmError, OSError, UnicodeError) as error:
try:
log = self._save_rootfs_build_log(destination, root, build_log, error=error)
except OSError as log_error:
raise UvmError(f"Ubuntu rootfs build failed: {error}; could not save diagnostics: {log_error}") from error
raise UvmError(f"Ubuntu rootfs build failed: {error}; details saved to {log}") from error
self._save_rootfs_build_log(destination, root, build_log)
with disk.open("rb") as image:
os.fsync(image.fileno())
os.replace(disk, destination)
directory = os.open(destination.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except (OSError, UnicodeError) as error:
raise UvmError(f"could not build Ubuntu guest rootfs: {error}") from error
def _install_ubuntu_packages(self, root: Path, mirror: str, build_log: Path) -> None:
sources = root / "etc/apt/sources.list"
sources.write_text("".join(
f"deb {mirror} {suite} main universe\n"
for suite in (_UBUNTU_RELEASE, f"{_UBUNTU_RELEASE}-updates", f"{_UBUNTU_RELEASE}-security")
), encoding="utf-8")
sources.chmod(0o644)
# Ubuntu's debootstrap script removes its own policy-rc.d on success.
policy = root / "usr/sbin/policy-rc.d"
policy.write_text("#!/bin/sh\nexit 101\n", encoding="utf-8")
policy.chmod(0o755)
try:
self._run_rootfs_stage("Ubuntu guest package installation", (
*_ROOTFS_NAMESPACE, "/bin/sh", "-eu", "-c", _GUEST_APT_SCRIPT,
"uvm-guest-packages", root, *_GUEST_PACKAGES,
), build_log)
finally:
policy.unlink(missing_ok=True)
def _run_rootfs_stage(self, stage: str, command: tuple[str | Path, ...], log: Path) -> None:
if self._emit is not None:
self._emit(stage)
with log.open("a", encoding="utf-8") as output:
output.write(f"\n=== {stage} ===\n+ {shlex.join(map(str, command))}\n")
result = self._runner.run(command, check=False, capture=True)
output.write(result.stdout)
output.write(result.stderr)
if result.returncode != 0:
raise UvmError(f"{stage} failed (exit {result.returncode})")
@staticmethod
def _save_rootfs_build_log(destination: Path, root: Path, build_log: Path, *, error: Exception | None = None) -> Path:
log = destination.with_name("ubuntu-build.log")
descriptor, name = tempfile.mkstemp(prefix=".ubuntu-log-", dir=destination.parent)
temporary = Path(name)
try:
with os.fdopen(descriptor, "wb") as output:
# Completed bootstrap details precede apt output; failed bootstrap details follow it.
for source in (root / "var/log/bootstrap.log", build_log, root / "debootstrap/debootstrap.log"):
if source.is_file():
output.write(f"\n=== {source.name} ===\n".encode("utf-8"))
with source.open("rb") as content:
shutil.copyfileobj(content, output)
if error is not None:
output.write(f"\nUbuntu rootfs build failed: {error}\n".encode("utf-8", errors="replace"))
output.flush()
os.fsync(output.fileno())
os.replace(temporary, log)
directory = os.open(log.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return log
def _configure_ubuntu_rootfs(self, root: Path) -> None:
files = {
"etc/fstab": "/dev/vda / ext4 defaults 0 1\n",
"etc/hostname": "uvm\n",
"etc/hosts": "127.0.0.1 localhost\n127.0.1.1 uvm\n",
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
"etc/machine-id": "",
"etc/uvm-image": _ROOTFS_RECIPE,
"etc/systemd/network/10-uvm.network": GUEST_NETWORK_TEMPLATE,
}
for relative, contents in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.unlink(missing_ok=True) # In particular, replace a resolver symlink with guest DNS.
path.write_text(contents, encoding="utf-8")
path.chmod(0o644)
machine_id = root / "var/lib/dbus/machine-id"
machine_id.parent.mkdir(parents=True, exist_ok=True)
machine_id.unlink(missing_ok=True)
machine_id.symlink_to("../../../etc/machine-id")
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
key.unlink()
agent_payloads = guest_agent_files()
for guest_path, contents in agent_payloads.items():
path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.parent.chmod(0o755)
path.write_bytes(contents)
path.chmod(0o644)
manifest = root / "etc/uvm-agent-files.json"
manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()},
sort_keys=True) + "\n", encoding="utf-8")
manifest.chmod(0o600)
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service",
"uvm-agent.service"), capture=True)
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
@staticmethod
def _validate_ubuntu_rootfs(root: Path) -> None:
for relative in (
"etc/passwd", "etc/shadow", "etc/group", "etc/sudoers", "etc/ssh/sshd_config",
"usr/bin/sudo", "usr/bin/apt-get", "usr/sbin/sshd", "usr/lib/systemd/systemd",
"var/lib/dpkg/status", "var/lib/dpkg/info/apt.list", "var/lib/dpkg/info/sudo.list",
"usr/bin/python3", "usr/lib/uvm/agent.py", "etc/systemd/system/uvm-agent.service",
):
if not (root / relative).is_file():
raise UvmError(f"built Ubuntu rootfs is missing /{relative}")
installed = set()
for paragraph in (root / "var/lib/dpkg/status").read_text(encoding="utf-8").split("\n\n"):
fields = dict(line.split(": ", 1) for line in paragraph.splitlines() if ": " in line)
if fields.get("Status") == "install ok installed":
installed.add(fields.get("Package"))
if not {"apt", "sudo", "openssh-server", "systemd-sysv", "python3"}.issubset(installed):
raise UvmError("built Ubuntu rootfs has incomplete apt/dpkg package metadata")
if not (root / "usr/bin/sudo").stat().st_mode & 0o4000:
raise UvmError("built Ubuntu rootfs sudo is missing its setuid permission")
def download(
self,
url: str,
+169
View File
@@ -0,0 +1,169 @@
"""Remove a deleted VM's entries from local OpenSSH host-key caches."""
from __future__ import annotations
import base64
import binascii
import fcntl
import hmac
import os
import pwd
import re
import stat
import uuid
from ipaddress import IPv4Address
from pathlib import Path
from .config import Settings
from .domain import VmRecord
from .errors import UvmError
_HOST_LINE = re.compile(
rb"^[ \t]*(?:@(?:cert-authority|revoked)[ \t]+)?"
rb"(?P<hosts>[^ \t\r\n]+)[ \t]+[^ \t\r\n]+[ \t]+[^ \t\r\n]+"
)
_PORT_HOST = re.compile(rb"\[([^\]]+)\]:(\d+)\Z")
class KnownHostsManager:
"""Deletion-only cleanup; lifecycle owns the lock and confirms VM teardown."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
def remove(self, vm: VmRecord) -> None:
try:
address = str(IPv4Address(vm.guest_ip)).encode("ascii")
except ValueError as error:
raise UvmError("known_hosts cleanup requires a valid guest IPv4 address") from error
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
raise UvmError("known_hosts cleanup requires a valid VM ID")
alias = f"uvm-{vm.id}".encode("ascii")
hosts = {address, alias, alias.lower()}
ports = {22, *(rule.port for rule in vm.ports or ())}
hashed_candidates = hosts | {
b"[" + host + b"]:" + str(port).encode("ascii") for host in hosts for port in ports
}
for path, owner in self._files().items():
self._remove_file(path, hosts, hashed_candidates, owner)
def _files(self) -> dict[Path, int | None]:
if self._settings.known_hosts_files is not None:
return dict.fromkeys(self._settings.known_hosts_files)
try:
effective_uid = os.geteuid()
accounts = [pwd.getpwuid(effective_uid)]
if effective_uid == 0:
sudo_uid = os.environ.get("SUDO_UID")
sudo_user = os.environ.get("SUDO_USER")
if sudo_uid:
if not sudo_uid.isascii() or not sudo_uid.isdigit():
raise ValueError("invalid SUDO_UID")
accounts.append(pwd.getpwuid(int(sudo_uid)))
elif sudo_user:
accounts.append(pwd.getpwnam(sudo_user))
return {
Path(account.pw_dir) / ".ssh" / filename: account.pw_uid
for account in accounts for filename in ("known_hosts", "known_hosts2")
}
except (KeyError, ValueError, OverflowError) as error:
raise UvmError("could not determine SSH user homes; configure UVM_KNOWN_HOSTS_FILES") from error
@staticmethod
def _remove_file(path: Path, hosts: set[bytes], candidates: set[bytes], owner: int | None) -> None:
directory = None
temporary = None
try:
# Pin the directory and reject symlink/FIFO/hard-link targets when
# editing another local user's cache from a privileged process.
try:
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
except FileNotFoundError:
return
with os.fdopen(descriptor, "rb") as source:
metadata = os.fstat(source.fileno())
if not stat.S_ISREG(metadata.st_mode):
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
fcntl.flock(source.fileno(), fcntl.LOCK_EX)
metadata = os.fstat(source.fileno())
if metadata.st_nlink != 1:
raise UvmError(f"known_hosts must be a regular, single-link file: {path}")
if owner is not None and metadata.st_uid != owner:
raise UvmError(f"known_hosts has an unexpected owner: {path}")
contents = source.read()
updated = _without_hosts(contents, hosts, candidates)
if updated == contents:
return
candidate = f".{path.name}.uvm-{uuid.uuid4().hex}"
descriptor = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory)
temporary = candidate
with os.fdopen(descriptor, "wb") as output:
output.write(updated)
output.flush()
created = os.fstat(output.fileno())
if (created.st_uid, created.st_gid) != (metadata.st_uid, metadata.st_gid):
os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid)
os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode))
for attribute in os.listxattr(source.fileno()):
os.setxattr(output.fileno(), attribute, os.getxattr(source.fileno(), attribute))
os.fsync(output.fileno())
current = os.stat(path.name, dir_fd=directory, follow_symlinks=False)
fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode", "st_uid", "st_gid", "st_nlink")
if any(getattr(current, field) != getattr(metadata, field) for field in fields):
raise UvmError(f"known_hosts changed during cleanup; retry deletion: {path}")
os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory)
os.fsync(directory)
except OSError as error:
raise UvmError(f"could not clean SSH known_hosts {path}: {error}") from error
finally:
if directory is not None:
try:
if temporary is not None:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
finally:
os.close(directory)
def _without_hosts(contents: bytes, hosts: set[bytes], candidates: set[bytes]) -> bytes:
"""Preserve unrelated aliases, comments, markers, key types, and line endings."""
def matches(token: bytes) -> bool:
if token.startswith(b"|1|"):
try:
_, _version, salt_text, digest_text = token.split(b"|")
salt = base64.b64decode(salt_text, validate=True)
digest = base64.b64decode(digest_text, validate=True)
except (ValueError, binascii.Error):
return False
return len(salt) == len(digest) == 20 and any(
hmac.compare_digest(digest, hmac.digest(salt, candidate, "sha1")) for candidate in candidates
)
token = token.lower()
if token in hosts:
return True
bracketed = _PORT_HOST.fullmatch(token)
return (bracketed is not None and bracketed[1] in hosts
and len(bracketed[2]) <= 5 and 1 <= int(bracketed[2]) <= 65535)
retained = []
for line in contents.splitlines(keepends=True):
if line.lstrip().startswith(b"#"):
retained.append(line)
continue
match = _HOST_LINE.match(line)
if match is None:
retained.append(line)
continue
names = match["hosts"].split(b",")
remaining = [name for name in names if not matches(name)]
if remaining == names:
retained.append(line)
elif remaining:
retained.append(line[:match.start("hosts")] + b",".join(remaining) + line[match.end("hosts"):])
return b"".join(retained)
+621 -198
View File
@@ -1,44 +1,68 @@
"""Ordered VM lifecycle operations and compensation for partial failures."""
"""Serialized lifecycle and private-port policy changes with durable intent."""
from __future__ import annotations
import os
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 ipaddress import IPv4Network
from pathlib import Path
from .config import Settings
from .domain import VmRecord, VmSpec, new_vm_id
from .errors import TapCreationError, UvmError
from .config import MIN_STORAGE_MIB, Settings
from .domain import PortRule, VmRecord, VmResources, 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 .guest_agent import DEFAULT_TIMEOUT_SECONDS
from .guest_exec import ExecResult, GuestSession, validate_exec
from .guest_terminal import GuestTerminal, validate_size
from .images import ImageStore
from .integrity import load_manifest, verify_file
from .known_hosts import KnownHostsManager
from .network import NetworkManager
from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root
from .validation import parse_name, parse_password, parse_ports, parse_timeout, 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
@dataclass(frozen=True, slots=True)
class LogTail:
content: str
truncated: bool
_MAX_LOG_TAIL_BYTES = 262_144
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, *,
known_hosts: KnownHostsManager,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
guest_connector: Callable[[Path, str, str, int], GuestSession] = GuestSession.connect,
terminal_connector: Callable[[Path, str, str, int], GuestTerminal] = GuestTerminal.connect,
clock: Callable[[], float] = time.time,
) -> None:
self._settings = settings
@@ -46,239 +70,638 @@ class LifecycleService:
self._images = images
self._network = network
self._process = process
self._known_hosts = known_hosts
self._client_factory = client_factory
self._guest_connector = guest_connector
self._terminal_connector = terminal_connector
self._clock = clock
def create(self, spec: VmSpec) -> VmRecord:
"""Reserve identity first, then create all VM resources in dependency order."""
require_root()
VmResources(spec.cpu, spec.ram_mib, spec.storage_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:
self._check_boot()
assets = self._images.installed_assets()
vm = self._reserve_vm(spec)
tap_created = False
vm = self._reserve_vm(replace(spec, ports=ports))
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))
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), storage_mib=spec.storage_mib)
self._images.provision_credentials(disk, vm.username, vm.password)
self._network.ensure_bridge()
try:
self._network.create_tap(vm.tap)
except TapCreationError as error:
tap_created = error.tap_created
raise
else:
tap_created = True
config = build_config(self._settings, vm, assets.kernel, disk)
write_config(Path(vm.config), config)
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)
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
client.configure_and_start(config)
self._images.configure_guest_network(disk, vm.guest_ip)
self._images.provision_guest_agent(disk, vm.id, vm.username)
vm.disk_ready = True
self._save(vm)
self._boot(vm, assets.kernel)
return vm
except BaseException as error:
cleanup_errors = self._rollback_create(vm, tap_created)
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_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)
if cleanup_error is not None:
message += f"; cleanup incomplete: {cleanup_error}"
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)
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()
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:
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)
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)
# 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, *, kernel: Path | None = None) -> 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.pending_resize is not None:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
self._validate_retained_network(vm)
vm = self._sync_ports_locked(vm)
self._network.ensure_bridge()
return vm
self._validate_resume(vm)
if kernel is None:
self._check_boot()
kernel = self._images.installed_kernel()
try:
self._cleanup_runtime(vm)
self._apply_pending_resize(vm)
self._images.configure_guest_network(Path(vm.disk), vm.guest_ip)
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel)
return vm
except BaseException as error:
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
raise UvmError(message) from error
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"
pending = "delete" if deleting else "resize" if vm.pending_resize is not None else None
vm.status, vm.operation = "stopping", pending or "stop"
self._save(vm)
try:
self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped"
vm.operation = pending
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 resize(
self, identifier: str, *, cpu: float | None = None, ram_mib: int | None = None,
storage_mib: int | None = None, timeout: float | None = None,
) -> VmRecord:
"""Stop, apply resource changes to the retained disk, then boot under one lock."""
require_root()
if cpu is None and ram_mib is None and storage_mib is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
VmResources(cpu if cpu is not None else 1, ram_mib if ram_mib is not None else 128,
storage_mib if storage_mib is not None else MIN_STORAGE_MIB)
if timeout is not None:
timeout = parse_timeout(str(timeout))
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._validate_resume(vm)
if self._process.observe(vm) == "unknown":
raise ConflictError(f"cannot resize {vm.id}: process identity is unknown")
# Verify boot prerequisites before stopping an otherwise healthy VM.
self._check_boot()
kernel = self._images.installed_kernel()
previous = vm.pending_resize
capacity = previous.storage_mib if previous is not None else vm.storage_mib
if capacity is None:
capacity = max(MIN_STORAGE_MIB, (self._images.disk_size_bytes(Path(vm.disk)) + 1024 * 1024 - 1) // (1024 * 1024))
vm.pending_resize = VmResources(
cpu=cpu if cpu is not None else previous.cpu if previous is not None else vm.cpu,
ram_mib=ram_mib if ram_mib is not None else previous.ram_mib if previous is not None else vm.ram_mib,
storage_mib=storage_mib if storage_mib is not None else capacity,
)
vm.operation = "resize"
self._save(vm)
try:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm, kernel=kernel)
except UvmError as error:
raise UvmError(f"failed to resize {vm.id}: {error}") from error
def _validate_resume(self, vm: VmRecord) -> None:
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
self._validate_retained_network(vm)
def _validate_retained_network(self, vm: VmRecord) -> None:
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
def _apply_pending_resize(self, vm: VmRecord) -> None:
target = vm.pending_resize
if target is None:
return
if self._process.observe(vm) != "gone":
raise ConflictError("cannot resize a disk without confirmed process exit")
# A prior attempt may have published a resized image without saving
# its allocation. Check the disk too, including when a retry changes
# the target back to the previously recorded capacity.
if (target.storage_mib != vm.storage_mib
or self._images.disk_size_bytes(Path(vm.disk)) != target.storage_mib * 1024 * 1024):
self._images.resize_vm_disk(Path(vm.disk), target.storage_mib)
vm.cpu, vm.ram_mib, vm.storage_mib = target.cpu, target.ram_mib, target.storage_mib
vm.pending_resize = None
self._save(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
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._known_hosts.remove(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:
"""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, exact_id=True)
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)
if self._network.has_tap(vm) else None),
):
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 execute(
self, vm_id: str, command: str, *, sudo: bool = False,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> ExecResult:
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
require_root()
validate_exec(command, sudo, timeout_seconds)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
# This session stays attached to that VMM even if the VM is stopped,
# deleted, or restarted concurrently. Never reconnect/retry a command.
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
finally:
session.close()
def open_terminal(self, vm_id: str, *, rows: int = 24, cols: int = 80) -> tuple[GuestTerminal, str]:
"""Connect to the exact running VMM under the lock, then open its guest PTY."""
require_root()
validate_size(rows, cols)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("terminal sessions require a running VM with no pending lifecycle operation")
terminal = self._terminal_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
terminal.start(rows, cols)
return terminal, vm.username
except BaseException:
terminal.close()
raise
def install_guest_agent(self, vm_id: str) -> VmRecord:
"""Explicitly install/upgrade the agent on a cleanly stopped private disk."""
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)
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "stopped" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "gone"):
raise ConflictError("stop the VM and finish pending operations before installing the guest agent")
if not vm.disk_ready or not vm.disk:
raise ConflictError("guest agent installation requires a completely provisioned private disk")
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username, install=True)
return vm
def destroy(self, vm_id: str) -> VmRecord:
"""Stop a VM, remove its private resources, then release its allocation."""
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._destroy_locked(vm_id)
def _destroy_locked(self, vm_id: str) -> VmRecord:
vm = self._begin_transition(vm_id, "terminating")
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:
self._process.terminate(vm)
self._network.delete_tap(vm.tap)
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
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
self._remove_vm_if_present(vm.id)
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),
("guest assets", self._images.installed_assets),
("private firewall", self._network.firewall.check_available)]
results = []
for name, check in checks:
try:
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", "e2fsck", "resize2fs", "dumpe2fs",
"cp", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results
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")
if self._settings.guest_socket_path(vm.id).is_symlink():
raise ConflictError("unsafe guest vsock path")
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:
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()
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, storage_mib=spec.storage_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())
+95 -119
View File
@@ -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 CommandError, ConflictError, 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,28 +97,75 @@ 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 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
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", 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:
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(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
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) 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:
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 = (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}")
return link
def delete_tap(self, name: str) -> None:
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
if deleted.returncode == 0:
@@ -129,111 +178,38 @@ class NetworkManager:
raise UvmError(f"could not delete TAP device {name}{suffix}")
def _ensure_masquerade(self) -> None:
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
match = re.search(r"\bdev\s+(\S+)", route.stdout)
if match is None:
return
uplink = match.group(1)
rule = (
"iptables",
"-t",
"nat",
"-C",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
present = self._runner.run(rule, check=False)
if present.returncode != 0:
self._runner.run(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
# Match the guest network, not a snapshot of the host's default uplink.
# Routing selects the current interface for each destination, even after
# an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN.
bridge = self._settings.bridge
network = str(self._settings.network)
owner = ("-m", "comment", "--comment", "uvm-egress")
self._ensure_iptables_rule(
"nat", "POSTROUTING",
("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"),
)
# Native bridge policy still checks guest identity and private ports.
# This also permits peer traffic when br_netfilter is already enabled.
self._ensure_iptables_rule(
"filter", "FORWARD",
("-i", bridge, "-s", network, *owner, "-j", "ACCEPT"),
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
"filter", "FORWARD",
("-o", bridge, "-d", network, "-m", "conntrack",
"--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"),
)
def _ensure_iptables_rule(
self,
check_rule: tuple[str, ...],
add_rule: tuple[str, ...],
self, table: str, chain: str, rule: tuple[str, ...],
) -> None:
present = self._runner.run(check_rule, check=False)
if present.returncode != 0:
self._runner.run(add_rule)
command = ("iptables", "--wait", "5", "-t", table)
present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True)
if present.returncode == 0:
return
if present.returncode != 1:
detail = present.stderr.strip() or present.stdout.strip()
raise CommandError(f"could not inspect UVM {table}/{chain} rule ({present.returncode}): {detail}")
# Scoped exceptions must precede terminal drops in the host's chains.
# Keep unrelated rules and the global forwarding policy intact.
self._runner.run((*command, "-I", chain, "1", *rule), capture=True)
+18
View File
@@ -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()]
+35
View File
@@ -0,0 +1,35 @@
"""Authenticated graceful shutdown endpoint for the UVM API server."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from ..api_models import ServerExitResponse
from ..app import Application
from .dependencies import get_authorized_application
router = APIRouter(tags=["server"])
@router.post("/exit", response_model=ServerExitResponse, status_code=status.HTTP_202_ACCEPTED)
def exit_server(
background_tasks: BackgroundTasks,
request: Request,
_application: Application = Depends(get_authorized_application),
) -> ServerExitResponse:
"""Stop the Uvicorn process after this response has been delivered."""
server = getattr(request.app.state, "uvicorn_server", None)
if server is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="server shutdown is unavailable outside nest --serve or uvm --serve",
)
background_tasks.add_task(_request_server_exit, server)
return ServerExitResponse()
def _request_server_exit(server: Any) -> None:
server.should_exit = True
+197 -13
View File
@@ -2,24 +2,65 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, 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,
VmAgentInstallResponse,
VmBulkActionRequest,
VmCreateRequest,
VmExecRequest,
VmExecResponse,
VmLogResponse,
VmResponse,
VmResizeRequest,
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, VmRecord, VmSpec
from ..errors import ValidationError
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_storage,
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),
) -> 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,42 +70,185 @@ 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)),
ram_mib=parse_ram(str(request.ram)),
storage_mib=parse_storage(str(request.storage)),
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)
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))
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.post("/{vm_id}/resize", response_model=VmResponse)
def resize_vm(
vm_id: str,
request: VmResizeRequest,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
if request.cpu is None and request.ram is None and request.storage is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
vm = application.lifecycle.resize(
vm_id,
cpu=parse_cpu(str(request.cpu)) if request.cpu is not None else None,
ram_mib=parse_ram(str(request.ram)) if request.ram is not None else None,
storage_mib=parse_storage(str(request.storage)) if request.storage is not None else None,
timeout=parse_timeout(str(timeout)) if timeout is not None else None,
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
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.post("/{vm_id}/exec", response_model=VmExecResponse)
def execute_in_vm(
vm_id: str,
request: VmExecRequest,
application: Application = Depends(get_authorized_application),
) -> VmExecResponse:
"""Execute a bounded, non-interactive guest command over vsock (exact VM ID)."""
result = application.lifecycle.execute(
vm_id, request.command, sudo=request.sudo, timeout_seconds=request.timeout_seconds,
)
return VmExecResponse(
exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr,
timed_out=result.timed_out, truncated=result.truncated,
)
@router.post("/{vm_id}/agent/install", response_model=VmAgentInstallResponse)
def install_guest_agent(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmAgentInstallResponse:
"""Install/upgrade the execution agent on a stopped VM's retained private disk."""
vm = application.lifecycle.install_guest_agent(vm_id)
return VmAgentInstallResponse(id=vm.id)
@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,
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,
)
+34 -10
View File
@@ -2,13 +2,17 @@
from __future__ import annotations
from collections.abc import Callable
from ipaddress import ip_address
from pathlib import Path
import ssl
from typing import Any
from .app import Application, build_application
from .errors import ConfigurationError, UvmError, ValidationError
from .errors import (
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
NotFoundError, UvmError, ValidationError,
)
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
@@ -34,8 +38,10 @@ def create_api(application: Application | None = None, *, host: str | None = Non
) from error
from .routers.health import router as health_router
from .routers.exit import router as exit_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
api = FastAPI(
title="uvm",
@@ -46,18 +52,30 @@ def create_api(application: Application | None = None, *, host: str | None = Non
@api.exception_handler(UvmError)
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
detail: dict[str, Any] = {"message": str(error)}
if isinstance(error, GuestExecutionError):
detail["outcome_unknown"] = error.outcome_unknown
return JSONResponse(
status_code=_http_status_for(error),
content={"error": {"message": str(error)}},
content={"error": detail},
)
api.include_router(health_router)
api.include_router(exit_router)
api.include_router(installation_router)
api.include_router(vms_router)
api.include_router(diagnostics_router)
return api
def run_server(application: Application, *, host: str, port: int) -> None:
def run_server(
application: Application,
*,
host: str,
port: int,
api_factory: Callable[..., Any] | None = None,
) -> None:
"""Run Uvicorn after enforcing the local-management security boundary."""
settings = application.settings
@@ -78,7 +96,10 @@ def run_server(application: Application, *, host: str, port: int) -> None:
if settings.api_tls_cert is not None:
options["ssl_certfile"] = str(settings.api_tls_cert)
options["ssl_keyfile"] = str(settings.api_tls_key)
uvicorn.run(create_api(application, host=host), **options)
api = (api_factory or create_api)(application, host=host)
server = uvicorn.Server(uvicorn.Config(api, **options))
api.state.uvicorn_server = server
server.run()
def _is_loopback_host(host: str) -> bool:
@@ -115,13 +136,16 @@ def _validate_server_settings(
def _http_status_for(error: UvmError) -> int:
if isinstance(error, GuestAgentUnavailableError):
return 503
if isinstance(error, GuestExecutionError):
return 502
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
+17 -8
View File
@@ -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 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]
for vm in state.vms.values():
if vm.guest_ip == identifier:
return vm
raise UvmError(f"VM not found: {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]:
+2 -1
View File
@@ -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,
)
+55 -1
View File
@@ -4,12 +4,16 @@ from __future__ import annotations
import math
import re
from decimal import Decimal, InvalidOperation
from collections.abc import Sequence
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import ValidationError
from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
_STORAGE_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?\s*", re.IGNORECASE)
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
_MIB_FACTORS = {
"b": Decimal(1) / Decimal(1024 * 1024),
@@ -60,6 +64,20 @@ def parse_cpu(value: str) -> float:
return cpu
def parse_storage(value: str) -> int:
"""Parse GiB-default storage, rounding capacity up to a whole MiB."""
match = _STORAGE_PATTERN.fullmatch(value)
if match is None:
raise ValidationError(f"invalid storage value: {value}; use e.g. 30G, 50G, or 1T")
factors = {**_MIB_FACTORS, **dict.fromkeys(("t", "tb", "ti", "tib"), Decimal(1024 * 1024))}
amount = Decimal(match.group(1)) * factors[(match.group(2) or "g").lower()]
if amount < MIN_STORAGE_MIB:
raise ValidationError("storage must be at least 30 GiB")
if amount > MAX_STORAGE_MIB:
raise ValidationError(f"storage must not exceed {MAX_STORAGE_MIB} MiB")
return int(amount.to_integral_value(rounding=ROUND_CEILING))
def parse_username(value: str) -> str:
"""Validate a conventional Linux account name."""
@@ -81,3 +99,39 @@ 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 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")
return value
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:
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