Compare commits
36
Commits
4ce14c3378
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2ceb3bcc | ||
|
|
a4e9cefbc3 | ||
|
|
6573f483e6 | ||
|
|
d870c98bfa | ||
|
|
caaea12163 | ||
|
|
d202f9e2f8 | ||
|
|
6aa70133c5 | ||
|
|
73d85fefe4 | ||
|
|
9541e29b76 | ||
|
|
3b94ac1b48 | ||
|
|
4663fd1923 | ||
|
|
b0bf99a6ee | ||
|
|
dca7f78dae | ||
|
|
283eb4ecf7 | ||
|
|
660ba9efee | ||
|
|
dca3cbde2f | ||
|
|
eb6119c797 | ||
|
|
0f72ac693a | ||
|
|
ed9c5306af | ||
|
|
534a78f2a9 | ||
|
|
bd92573158 | ||
|
|
1d3644ab7c | ||
|
|
4bf4c0853c | ||
|
|
10a58daa56 | ||
|
|
71425a98e3 | ||
|
|
309f28018e | ||
|
|
c75c7d9baf | ||
|
|
871afcf454 | ||
|
|
4a46685a4c | ||
|
|
fb80ce7c8a | ||
|
|
99f9ffa57d | ||
|
|
781ee753ec | ||
|
|
b3b1b527e1 | ||
|
|
3d7af4adb8 | ||
|
|
ee26962902 | ||
|
|
d8a25209b9 |
@@ -0,0 +1,5 @@
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
**.pyc
|
||||
+894
-328
File diff suppressed because it is too large
Load Diff
+735
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Nest workspace application."""
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Run the Nest workspace with ``python -m nest``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+44
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Packaged frontend assets for the Nest workspace."""
|
||||
@@ -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")
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
@@ -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 & 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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; });
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)])
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
+758
-106
@@ -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,117 +160,734 @@ FASTAPI_AVAILABLE = (
|
||||
if FASTAPI_AVAILABLE:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.lifecycle import ListedVm
|
||||
from uvm.server import create_api
|
||||
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec = None
|
||||
self.vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
status="running",
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec: VmSpec | None = None
|
||||
self.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",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
password="guest-secret",
|
||||
status="running",
|
||||
ports=(PortRule(22),),
|
||||
operation=None,
|
||||
disk_ready=True,
|
||||
boot_id="test-boot",
|
||||
)
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
return [ListedVm(
|
||||
vm=self.vm,
|
||||
observed_status=self.observed_status,
|
||||
policy_status=self.policy_status,
|
||||
)]
|
||||
|
||||
def inspect(self, identifier: str) -> ListedVm:
|
||||
if identifier not in {self.vm.id, self.vm.guest_ip, self.vm.name}:
|
||||
raise NotFoundError(f"VM not found: {identifier}")
|
||||
return ListedVm(
|
||||
vm=self.vm,
|
||||
observed_status=self.observed_status,
|
||||
policy_status=self.policy_status,
|
||||
)
|
||||
|
||||
def create(self, spec: VmSpec) -> VmRecord:
|
||||
self.last_spec = spec
|
||||
self.vm.cpu = spec.cpu
|
||||
self.vm.ram_mib = spec.ram_mib
|
||||
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 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 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
|
||||
|
||||
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")
|
||||
self.lifecycle = FakeLifecycle()
|
||||
application.lifecycle = self.lifecycle
|
||||
application.installer = SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
)
|
||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||
self.headers = {"X-UVM-Token": "test-token"}
|
||||
self.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")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
created = self.client.post(
|
||||
"/vms",
|
||||
headers=self.headers,
|
||||
json={
|
||||
"cpu": 1,
|
||||
"ram": "512",
|
||||
"username": "root",
|
||||
"password": "api-secret",
|
||||
},
|
||||
)
|
||||
detail = self.client.get("/vms/vm-test", headers=self.headers)
|
||||
stopped = self.client.post("/vms/vm-test/stop", headers=self.headers)
|
||||
destroyed = self.client.delete("/vms/vm-test", headers=self.headers)
|
||||
invalid = self.client.post("/vms", headers=self.headers, json={"forse": True})
|
||||
|
||||
self.assertEqual(created.status_code, 201)
|
||||
self.assertEqual(created.json()["username"], "root")
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
|
||||
for response in (created, detail, stopped, destroyed):
|
||||
self.assertNotIn("password", response.json())
|
||||
self.assertNotIn("api-secret", response.text)
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(stopped.json()["status"], "stopped")
|
||||
self.assertEqual(stopped.json()["policy_status"], "inactive")
|
||||
self.assertEqual(destroyed.json(), {"id": "vm-test", "status": "terminated"})
|
||||
self.assertEqual(invalid.status_code, 422)
|
||||
|
||||
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
|
||||
response = self.client.post(
|
||||
"/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_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,
|
||||
)
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
return [ListedVm(vm=self.vm, observed_status=self.vm.status)]
|
||||
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 create(self, spec):
|
||||
self.last_spec = spec
|
||||
self.vm.username = spec.username
|
||||
self.vm.password = spec.password
|
||||
self.vm.status = "running"
|
||||
return self.vm
|
||||
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")
|
||||
|
||||
def stop(self, _vm_id: str):
|
||||
self.vm.status = "stopped"
|
||||
return self.vm
|
||||
restarted = self.client.post("/vms/vm-test/restart", headers=self.headers)
|
||||
self.assertEqual(restarted.status_code, 200)
|
||||
self.assertEqual(restarted.json()["status"], "running")
|
||||
|
||||
def destroy(self, _vm_id: str):
|
||||
return self.vm
|
||||
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")
|
||||
|
||||
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed")
|
||||
class ApiRouterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
application = fake_application(api_token="test-token")
|
||||
self.lifecycle = FakeLifecycle()
|
||||
application.lifecycle = self.lifecycle
|
||||
application.installer = SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
)
|
||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||
self.client = TestClient(create_api(application, host="127.0.0.1"))
|
||||
def test_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_health_is_available_without_credentials(self) -> None:
|
||||
response = self.client.get("/health")
|
||||
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)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
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_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_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)
|
||||
|
||||
self.assertEqual(unauthorized.status_code, 401)
|
||||
self.assertEqual(authorized.status_code, 200)
|
||||
self.assertEqual(authorized.json()[0]["id"], "vm-test")
|
||||
self.assertNotIn("password", authorized.json()[0])
|
||||
def test_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_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||
headers = {"X-UVM-Token": "test-token"}
|
||||
created = self.client.post(
|
||||
"/vms",
|
||||
headers=headers,
|
||||
json={
|
||||
"cpu": 1,
|
||||
"ram": "512",
|
||||
"username": "root",
|
||||
"password": "api-secret",
|
||||
},
|
||||
)
|
||||
detail = self.client.get("/vms/vm-test", headers=headers)
|
||||
stopped = self.client.post("/vms/vm-test/stop", headers=headers)
|
||||
destroyed = self.client.delete("/vms/vm-test", headers=headers)
|
||||
invalid = self.client.post("/vms", headers=headers, json={"forse": True})
|
||||
|
||||
self.assertEqual(created.status_code, 201)
|
||||
self.assertEqual(created.json()["username"], "root")
|
||||
self.assertNotIn("password", created.json())
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(stopped.json()["status"], "stopped")
|
||||
self.assertEqual(destroyed.json()["status"], "terminated")
|
||||
self.assertEqual(invalid.status_code, 422)
|
||||
|
||||
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
|
||||
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
|
||||
with (
|
||||
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
|
||||
patch.object(self.lifecycle, "run_many", wraps=self.lifecycle.run_many) as run_many,
|
||||
):
|
||||
response = self.client.post(
|
||||
"/vms",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"cpu": 1, "ram": "512"},
|
||||
"/vms/actions",
|
||||
headers=self.headers,
|
||||
json={"action": "restart", "identifiers": ["test-vm"], "timeout": 1.5},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self.lifecycle.last_spec.username, "root")
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
||||
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_install_route_uses_the_existing_installer(self) -> None:
|
||||
response = self.client.post(
|
||||
"/install",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"force": True},
|
||||
)
|
||||
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)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
+265
-18
@@ -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")
|
||||
self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
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)
|
||||
self.assertEqual(loaded.next_mac_index, 2)
|
||||
loaded = self.store.load()
|
||||
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"], 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-test"].status = "stopped"
|
||||
state.vms[vm.id] = vm
|
||||
|
||||
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["schema_version"], 1)
|
||||
self.assertEqual(persisted["next_mac_index"], 2)
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
||||
for identifier in (vm.id, vm.name, vm.guest_ip):
|
||||
with self.subTest(identifier=identifier):
|
||||
self.assertEqual(self.store.find_by_id_or_ip(identifier), vm)
|
||||
self.assertEqual(self.store.get(vm.id), vm)
|
||||
for identifier in (
|
||||
"", vm.id[:15], vm.id.upper(), "app", "APP-SERVER", " app-server ", "10.42.0",
|
||||
):
|
||||
with self.subTest(identifier=identifier), self.assertRaises(NotFoundError):
|
||||
self.store.find_by_id_or_ip(identifier)
|
||||
for identifier in (vm.name, vm.guest_ip):
|
||||
with self.subTest(get_identifier=identifier), self.assertRaises(NotFoundError):
|
||||
self.store.get(identifier)
|
||||
|
||||
def test_load_rejects_a_record_whose_id_does_not_match_its_key(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
for version in (0, 1, 2, 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)
|
||||
|
||||
@@ -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)
|
||||
+69
-16
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
@@ -9,9 +11,41 @@ from uvm.system import CommandRunner
|
||||
|
||||
|
||||
class CommandRunnerTests(unittest.TestCase):
|
||||
def test_uncaptured_child_stdout_is_redirected_to_current_stderr(self) -> None:
|
||||
stderr = io.StringIO()
|
||||
completed = SimpleNamespace(returncode=0, stdout=None, stderr=None)
|
||||
|
||||
with (
|
||||
patch("uvm.system.sys.stderr", stderr),
|
||||
patch("uvm.system.subprocess.run", return_value=completed) as run,
|
||||
):
|
||||
result = CommandRunner(emit=None).run(("host-tool", "--check"), timeout=2.5)
|
||||
|
||||
run.assert_called_once_with(
|
||||
("host-tool", "--check"), check=False, text=True, input=None,
|
||||
stdout=stderr, stderr=None, timeout=2.5,
|
||||
)
|
||||
self.assertEqual(result.args, ("host-tool", "--check"))
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
def test_captured_stdout_and_stderr_still_use_separate_pipes(self) -> None:
|
||||
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="diagnostic\n")
|
||||
|
||||
with patch("uvm.system.subprocess.run", return_value=completed) as run:
|
||||
result = CommandRunner(emit=None).run(("host-tool",), capture=True)
|
||||
|
||||
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE)
|
||||
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE)
|
||||
self.assertEqual(result.stdout, "result\n")
|
||||
self.assertEqual(result.stderr, "diagnostic\n")
|
||||
|
||||
def test_passes_sensitive_input_over_stdin_without_logging_it(self) -> None:
|
||||
emitted: list[str] = []
|
||||
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="")
|
||||
completed = SimpleNamespace(
|
||||
returncode=0, stdout="result secret-value\n", stderr="warning secret-value\n",
|
||||
)
|
||||
|
||||
with patch("uvm.system.subprocess.run", return_value=completed) as run:
|
||||
result = CommandRunner(emit=emitted.append).run(
|
||||
@@ -21,26 +55,45 @@ class CommandRunnerTests(unittest.TestCase):
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.stdout, "result\n")
|
||||
self.assertEqual(result.stdout, "result secret-value\n")
|
||||
self.assertEqual(result.stderr, "warning secret-value\n")
|
||||
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.PIPE)
|
||||
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.PIPE)
|
||||
self.assertEqual(run.call_args.kwargs["input"], "secret-value\n")
|
||||
self.assertNotIn("secret-value", emitted[0])
|
||||
self.assertEqual(emitted, ["+ credential-tool --stdin"])
|
||||
|
||||
def test_redacts_sensitive_command_output_from_errors(self) -> None:
|
||||
for stdout, stderr in (
|
||||
("failure mentioning secret-value", ""),
|
||||
("", "failure mentioning secret-value"),
|
||||
("secret-value", "failure mentioning secret-value"),
|
||||
):
|
||||
with self.subTest(stdout=stdout, stderr=stderr):
|
||||
completed = SimpleNamespace(returncode=1, stdout=stdout, stderr=stderr)
|
||||
emitted: list[str] = []
|
||||
with (
|
||||
patch("uvm.system.subprocess.run", return_value=completed),
|
||||
self.assertRaises(CommandError) as raised,
|
||||
):
|
||||
CommandRunner(emit=emitted.append).run(
|
||||
("credential-tool", "--stdin"), capture=True,
|
||||
input_text="secret-value\n", sensitive=True,
|
||||
)
|
||||
|
||||
self.assertNotIn("secret-value", str(raised.exception))
|
||||
self.assertEqual(emitted, ["+ credential-tool --stdin"])
|
||||
|
||||
def test_unchecked_sensitive_failure_preserves_captured_output_for_the_caller(self) -> None:
|
||||
completed = SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="failure mentioning secret-value",
|
||||
returncode=1, stdout="secret-value\n", stderr="failure mentioning secret-value",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("uvm.system.subprocess.run", return_value=completed),
|
||||
self.assertRaises(CommandError) as raised,
|
||||
):
|
||||
CommandRunner(emit=None).run(
|
||||
("credential-tool", "--stdin"),
|
||||
capture=True,
|
||||
input_text="secret-value\n",
|
||||
sensitive=True,
|
||||
with patch("uvm.system.subprocess.run", return_value=completed):
|
||||
result = CommandRunner(emit=None).run(
|
||||
("credential-tool", "--stdin"), check=False, capture=True,
|
||||
input_text="secret-value\n", sensitive=True,
|
||||
)
|
||||
|
||||
self.assertNotIn("secret-value", str(raised.exception))
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stdout, completed.stdout)
|
||||
self.assertEqual(result.stderr, completed.stderr)
|
||||
|
||||
+120
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+536
-46
@@ -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;"
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; "
|
||||
"fractional CPU enforcement is not yet applied.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" fractional CPU enforcement is not yet applied by this MVP.")
|
||||
print()
|
||||
print(f"VM created: {vm.id}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" CPU: {vm.cpu}")
|
||||
print(f" TAP: {vm.tap}")
|
||||
print(f" Username: {vm.username}")
|
||||
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
|
||||
print()
|
||||
if vm.password == "root":
|
||||
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
|
||||
if password == "root":
|
||||
print(
|
||||
"WARNING: the guest is using the default password 'root'. Change it promptly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if args.json:
|
||||
_print_json(_vm_output(
|
||||
vm, policy_status="unmanaged" if vm.ports is None else "enforced"
|
||||
))
|
||||
else:
|
||||
print(f"VM created: {vm.id}")
|
||||
if vm.name is not None:
|
||||
print(f" Name: {vm.name}")
|
||||
print(f" IP: {vm.guest_ip}")
|
||||
print(f" RAM: {vm.ram_mib} MiB")
|
||||
print(f" 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}")
|
||||
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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+376
-98
@@ -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():
|
||||
def start(
|
||||
self,
|
||||
vm: VmRecord,
|
||||
*,
|
||||
on_spawn: Callable[[ProcessInfo], None] | None = None,
|
||||
) -> ProcessInfo:
|
||||
"""Publish identity before readiness; never replace an ambiguous process."""
|
||||
|
||||
observation = self.observe(vm)
|
||||
if observation != "gone":
|
||||
raise FirecrackerError(
|
||||
f"refusing to start Firecracker: existing process is {observation}"
|
||||
)
|
||||
boot_id = self._boot_id()
|
||||
if boot_id is None:
|
||||
raise FirecrackerError("could not establish the current host boot identity")
|
||||
|
||||
# Check support before creating a child that we might be unable to stop safely.
|
||||
try:
|
||||
pidfd = _open_pidfd(os.getpid())
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except OSError as error:
|
||||
raise FirecrackerError(
|
||||
f"could not remove stale Firecracker socket {socket_path}: {error}"
|
||||
) from error
|
||||
_signal_pidfd(pidfd, 0)
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except (OSError, NotImplementedError) as error:
|
||||
raise FirecrackerError(
|
||||
f"safe pidfd support is required to start Firecracker: {error}"
|
||||
) from error
|
||||
|
||||
socket_path = Path(vm.socket)
|
||||
self._remove_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:
|
||||
process = self._popen(
|
||||
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
previous_identity = (
|
||||
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
|
||||
)
|
||||
vm.pid = None
|
||||
vm.process_start_time = None
|
||||
vm.boot_id = boot_id
|
||||
vm.operation = "spawning"
|
||||
vm.spawn_pending = True
|
||||
try:
|
||||
process = self._popen(
|
||||
[str(self._settings.firecracker_binary), "--api-sock", str(socket_path)],
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
# Popen cleans up an exec/fork failure, but an interrupt is uncertain.
|
||||
if not isinstance(error, InterruptedError):
|
||||
(
|
||||
vm.pid, vm.process_start_time, vm.boot_id, vm.operation, vm.spawn_pending,
|
||||
) = previous_identity
|
||||
raise
|
||||
vm.pid = process.pid
|
||||
self._children[process.pid] = process
|
||||
vm.process_start_time = self.process_start_time(process.pid)
|
||||
if vm.process_start_time is None:
|
||||
raise FirecrackerError(
|
||||
f"could not establish a safe process identity for Firecracker process {process.pid}"
|
||||
)
|
||||
info = ProcessInfo(process.pid, vm.process_start_time, boot_id)
|
||||
if on_spawn is not None:
|
||||
on_spawn(info)
|
||||
else:
|
||||
vm.spawn_pending = False
|
||||
self._wait_for_socket(process, socket_path, log_path)
|
||||
start_time = self.process_start_time(process.pid)
|
||||
if start_time is None:
|
||||
raise FirecrackerError(
|
||||
f"could not establish a safe process identity for Firecracker process {process.pid}"
|
||||
)
|
||||
return ProcessInfo(pid=process.pid, start_time=start_time)
|
||||
except OSError as error:
|
||||
return info
|
||||
except BaseException as error:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
raise FirecrackerError(f"could not start Firecracker: {error}") from error
|
||||
except BaseException:
|
||||
if process is not None:
|
||||
self._cleanup_started_process(process, socket_path)
|
||||
try:
|
||||
self.terminate(vm)
|
||||
except BaseException as cleanup_error:
|
||||
raise FirecrackerError(
|
||||
f"Firecracker startup failed ({type(error).__name__}: {error}); "
|
||||
f"cleanup incomplete for PID {vm.pid}: {cleanup_error}"
|
||||
) from error
|
||||
if isinstance(error, OSError):
|
||||
raise FirecrackerError(f"could not start Firecracker: {error}") from error
|
||||
raise
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
if vm.pid is None or vm.pid <= 0 or vm.process_start_time is None:
|
||||
def adopt_legacy_identity(self, vm: VmRecord) -> bool:
|
||||
"""Validate a v1 process; the caller must lock and persist the adopted boot ID."""
|
||||
|
||||
pid, start_time = vm.pid, vm.process_start_time
|
||||
if (
|
||||
vm.boot_id is not None
|
||||
or type(pid) is not int
|
||||
or not 0 < pid < 2**31
|
||||
or not isinstance(start_time, str)
|
||||
or not start_time.isascii()
|
||||
or not start_time.isdigit()
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
os.kill(vm.pid, 0)
|
||||
except ProcessLookupError:
|
||||
pidfd = _open_pidfd(pid)
|
||||
try:
|
||||
process_stat = self._process_stat(pid)
|
||||
if (
|
||||
process_stat is None
|
||||
or process_stat[1] != start_time
|
||||
or process_stat[0] in {"Z", "X", "x"}
|
||||
):
|
||||
return False
|
||||
|
||||
binary_path = self._settings.firecracker_binary
|
||||
socket_path = self._settings.vm_dir(vm.id) / "firecracker.sock"
|
||||
if (
|
||||
not binary_path.is_absolute()
|
||||
or ".." in binary_path.parts
|
||||
or not vm.id
|
||||
or vm.id in {".", ".."}
|
||||
or Path(vm.id).name != vm.id
|
||||
or vm.socket != str(socket_path)
|
||||
):
|
||||
return False
|
||||
|
||||
# Sticky ancestors are safe only because every existing entry is root-owned.
|
||||
for directory in set(binary_path.parents) | set(socket_path.parents):
|
||||
metadata = directory.lstat()
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != 0
|
||||
or (directory == socket_path.parent and metadata.st_mode & 0o077)
|
||||
or (metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX)
|
||||
):
|
||||
return False
|
||||
binary_stat = binary_path.lstat()
|
||||
socket_stat = socket_path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(binary_stat.st_mode)
|
||||
or binary_stat.st_uid != 0
|
||||
or binary_stat.st_mode & 0o022
|
||||
or not stat.S_ISSOCK(socket_stat.st_mode)
|
||||
or socket_stat.st_uid != 0
|
||||
):
|
||||
return False
|
||||
|
||||
proc_path = Path(f"/proc/{pid}")
|
||||
executable = proc_path / "exe"
|
||||
if executable.readlink() != binary_path or not executable.samefile(binary_path):
|
||||
return False
|
||||
expected_cmdline = b"\0".join(
|
||||
(os.fsencode(binary_path), b"--api-sock", os.fsencode(socket_path), b"")
|
||||
)
|
||||
if (proc_path / "cmdline").read_bytes() != expected_cmdline:
|
||||
return False
|
||||
boot_id = self._boot_id()
|
||||
if boot_id is None or _pidfd_exited(pidfd):
|
||||
return False
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except (OSError, ValueError, NotImplementedError):
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
return self.process_start_time(vm.pid) == vm.process_start_time
|
||||
if (vm.pid, vm.process_start_time, vm.boot_id) != (pid, start_time, None):
|
||||
return False
|
||||
vm.boot_id = boot_id
|
||||
return True
|
||||
|
||||
def terminate(self, vm: VmRecord) -> None:
|
||||
def observe(self, vm: VmRecord) -> str:
|
||||
"""Distinguish proven exit from missing or unreadable process identity."""
|
||||
|
||||
if vm.pid is not None and vm.pid <= 0:
|
||||
return "unknown"
|
||||
try:
|
||||
self._reap_child(vm.pid)
|
||||
except OSError:
|
||||
return "unknown"
|
||||
boot_id = self._boot_id()
|
||||
if vm.boot_id and boot_id and vm.boot_id != boot_id:
|
||||
return "gone"
|
||||
if vm.pid is None:
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
if vm.pid <= 0:
|
||||
if vm.spawn_pending or vm.operation == "spawning" or vm.process_start_time is not None:
|
||||
return "unknown"
|
||||
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 has_start_time and vm.process_start_time != start_time:
|
||||
return "gone"
|
||||
if state in {"Z", "X", "x"}:
|
||||
# A zombie thread-group leader can still have live worker threads.
|
||||
try:
|
||||
pidfd = _open_pidfd(vm.pid)
|
||||
try:
|
||||
return "gone" if _pidfd_exited(pidfd) else "unknown"
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
except ProcessLookupError:
|
||||
return "gone"
|
||||
except (OSError, NotImplementedError):
|
||||
return "unknown"
|
||||
if not has_start_time or not vm.boot_id or not boot_id:
|
||||
return "unknown"
|
||||
return "alive"
|
||||
|
||||
def is_alive(self, vm: VmRecord) -> bool:
|
||||
"""Conservative compatibility query: unknown processes may still be alive."""
|
||||
|
||||
return self.observe(vm) != "gone"
|
||||
|
||||
def terminate(self, vm: VmRecord, *, timeout: float | None = None) -> None:
|
||||
"""Wait for exit before unlinking; timeout controls the SIGTERM grace period."""
|
||||
|
||||
if vm.pid is not None and vm.pid <= 0:
|
||||
raise FirecrackerError(f"invalid persisted Firecracker PID: {vm.pid}")
|
||||
if vm.process_start_time is None:
|
||||
if not self._pid_exists(vm.pid):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
return
|
||||
grace_timeout = self._settings.terminate_timeout_s if timeout is None else timeout
|
||||
if not math.isfinite(grace_timeout) or grace_timeout < 0:
|
||||
raise FirecrackerError("Firecracker termination timeout must be finite and non-negative")
|
||||
observation = self.observe(vm)
|
||||
if observation == "unknown":
|
||||
raise FirecrackerError(
|
||||
f"refusing to signal Firecracker PID {vm.pid} without a process identity token"
|
||||
f"refusing to stop Firecracker PID {vm.pid}: process identity is ambiguous"
|
||||
)
|
||||
if not self.is_alive(vm):
|
||||
self._remove_socket(Path(vm.socket))
|
||||
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)
|
||||
raise FirecrackerError("timed out waiting for Firecracker API socket")
|
||||
if socket_path.is_socket():
|
||||
return
|
||||
remaining = deadline - self._monotonic()
|
||||
if remaining <= 0:
|
||||
raise FirecrackerError("timed out waiting for Firecracker API socket")
|
||||
self._sleep(min(0.05, remaining))
|
||||
|
||||
def _wait_for_exit(self, pidfd: int, timeout: float) -> bool:
|
||||
deadline = self._monotonic() + timeout
|
||||
while True:
|
||||
if _pidfd_exited(pidfd):
|
||||
return True
|
||||
remaining = deadline - self._monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
self._sleep(min(0.05, remaining))
|
||||
|
||||
def _reap_child(self, pid: int | None) -> None:
|
||||
if pid is None:
|
||||
return
|
||||
process = self._children.get(pid)
|
||||
if process is not None and process.poll() is not None:
|
||||
self._children.pop(pid, None)
|
||||
|
||||
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
@@ -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=(",", ":"))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"})
|
||||
+557
-23
@@ -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
|
||||
raise UvmError(f"could not create VM disk {destination}: {error}") from error
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
+256
-39
@@ -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,
|
||||
)
|
||||
require_checksum(
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_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,15 +232,208 @@ class Installer:
|
||||
"UVM_KERNEL_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
verify_file(
|
||||
rootfs,
|
||||
self._settings.rootfs_sha256,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_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,
|
||||
"guest root filesystem",
|
||||
"UVM_ROOTFS_SHA256",
|
||||
allow_unverified=self._settings.allow_unverified_downloads,
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
assets = self._images.installed_assets()
|
||||
vm = self._reserve_vm(spec)
|
||||
|
||||
tap_created = False
|
||||
try:
|
||||
runtime_dir = self._settings.vm_dir(vm.id)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=False)
|
||||
runtime_dir.chmod(0o700)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
self._network.ensure_bridge()
|
||||
self._check_boot()
|
||||
assets = self._images.installed_assets()
|
||||
vm = self._reserve_vm(replace(spec, ports=ports))
|
||||
try:
|
||||
self._network.create_tap(vm.tap)
|
||||
except TapCreationError as error:
|
||||
tap_created = error.tap_created
|
||||
raise
|
||||
else:
|
||||
tap_created = True
|
||||
runtime = self._settings.vm_dir(vm.id)
|
||||
runtime.mkdir(mode=0o700, exist_ok=False)
|
||||
vm.runtime_owned = True
|
||||
self._save(vm)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
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_error = None
|
||||
try:
|
||||
if vm.operation != "preparing":
|
||||
self._cleanup_runtime(vm)
|
||||
self._remove_runtime(vm)
|
||||
self._remove_record(vm.id)
|
||||
except BaseException as cleanup:
|
||||
cleanup_error = cleanup
|
||||
message = f"failed to create {vm.id}: {error}"
|
||||
if cleanup_error is not None:
|
||||
message += f"; cleanup incomplete: {cleanup_error}"
|
||||
self._record_failure(vm, message)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise UvmError(message) from error
|
||||
|
||||
config = build_config(self._settings, vm, assets.kernel, disk)
|
||||
write_config(Path(vm.config), config)
|
||||
def _check_boot(self) -> None:
|
||||
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()
|
||||
|
||||
process_info = self._process.start(vm)
|
||||
vm.pid = process_info.pid
|
||||
vm.process_start_time = process_info.start_time
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
def _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)
|
||||
|
||||
client = self._client_factory(Path(vm.socket), self._settings.api_timeout_s)
|
||||
client.configure_and_start(config)
|
||||
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:
|
||||
cleanup_errors = self._rollback_create(vm, tap_created)
|
||||
message = f"failed to create {vm.id}: {error}"
|
||||
if cleanup_errors:
|
||||
message = f"{message}; cleanup failed: {'; '.join(cleanup_errors)}"
|
||||
vm.status = "failed"
|
||||
vm.last_error = message
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
else:
|
||||
self._remove_vm_if_present(vm.id)
|
||||
message = f"failed to start {vm.id}: {error}"
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
except BaseException as cleanup:
|
||||
message += f"; cleanup incomplete: {cleanup}"
|
||||
self._record_failure(vm, message)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(message) from error
|
||||
|
||||
vm.status = "running"
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._stop_locked(self._load_for_operation(identifier), timeout=timeout)
|
||||
|
||||
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
|
||||
deleting = vm.operation == "delete" or vm.status == "terminating"
|
||||
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)
|
||||
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 _stop_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "stopping")
|
||||
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)
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
|
||||
vm.status = "stopped"
|
||||
vm.pid = None
|
||||
vm.process_start_time = None
|
||||
vm.last_error = None
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm(vm)
|
||||
return vm
|
||||
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 destroy(self, vm_id: str) -> VmRecord:
|
||||
"""Stop a VM, remove its private resources, then release its allocation."""
|
||||
|
||||
require_root()
|
||||
with self._state_store.operation_lock():
|
||||
return self._destroy_locked(vm_id)
|
||||
|
||||
def _destroy_locked(self, vm_id: str) -> VmRecord:
|
||||
vm = self._begin_transition(vm_id, "terminating")
|
||||
try:
|
||||
self._process.terminate(vm)
|
||||
self._network.delete_tap(vm.tap)
|
||||
def diagnostics(self) -> list[dict]:
|
||||
checks = [("root", require_root), ("kvm", check_kvm),
|
||||
("Firecracker", self._verify_firecracker),
|
||||
("guest assets", self._images.installed_assets),
|
||||
("private firewall", self._network.firewall.check_available)]
|
||||
results = []
|
||||
for name, check in checks:
|
||||
try:
|
||||
shutil.rmtree(self._settings.vm_dir(vm.id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except BaseException as error:
|
||||
vm.status = "failed"
|
||||
vm.last_error = f"failed to destroy VM: {error}"
|
||||
vm.updated_at = self._now()
|
||||
self._replace_vm_if_present(vm)
|
||||
if isinstance(error, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
if isinstance(error, UvmError):
|
||||
raise error
|
||||
raise UvmError(vm.last_error) from error
|
||||
check()
|
||||
results.append({"name": name, "ok": True, "message": "available"})
|
||||
except (UvmError, OSError) as error:
|
||||
results.append({"name": name, "ok": False, "message": str(error)})
|
||||
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "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
|
||||
|
||||
self._remove_vm_if_present(vm.id)
|
||||
def _load_for_operation(self, identifier: str, *, exact_id: bool = False) -> VmRecord:
|
||||
vm = (self._state_store.get(identifier) if exact_id
|
||||
else self._state_store.find_by_id_or_ip(identifier))
|
||||
self._validate_paths(vm)
|
||||
if self._process.adopt_legacy_identity(vm):
|
||||
self._save(vm)
|
||||
return vm
|
||||
|
||||
def _validate_paths(self, vm: VmRecord) -> None:
|
||||
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm.id) is None:
|
||||
raise ConflictError("invalid persisted VM ID; refusing resource access")
|
||||
directory = self._settings.vm_dir(vm.id).absolute()
|
||||
if directory.is_symlink():
|
||||
raise ConflictError("VM directory must not be a symbolic link")
|
||||
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
@@ -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(
|
||||
(
|
||||
"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",
|
||||
),
|
||||
"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",
|
||||
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)
|
||||
|
||||
@@ -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()]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+18
-9
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -12,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .domain import State, VmRecord
|
||||
from .errors import StateError, UvmError
|
||||
from .errors import ConflictError, NotFoundError, StateError
|
||||
from .system import ensure_data_directories
|
||||
|
||||
|
||||
@@ -63,16 +64,24 @@ class StateStore:
|
||||
try:
|
||||
return state.vms[vm_id]
|
||||
except KeyError as error:
|
||||
raise UvmError(f"VM not found: {vm_id}") from error
|
||||
raise NotFoundError(f"VM not found: {vm_id}") from error
|
||||
|
||||
def find_by_id_or_ip(self, identifier: str) -> VmRecord:
|
||||
state = self.load()
|
||||
if identifier in state.vms:
|
||||
return state.vms[identifier]
|
||||
for vm in state.vms.values():
|
||||
if vm.guest_ip == identifier:
|
||||
return vm
|
||||
raise UvmError(f"VM not found: {identifier}")
|
||||
return self.resolve(self.load(), identifier)
|
||||
|
||||
@staticmethod
|
||||
def resolve(state: State, identifier: str) -> VmRecord:
|
||||
if re.fullmatch(r"vm-[0-9a-f]{32}", identifier):
|
||||
try:
|
||||
return state.vms[identifier]
|
||||
except KeyError as error:
|
||||
raise NotFoundError(f"VM not found: {identifier}") from error
|
||||
matches = [vm for vm in state.vms.values() if identifier in {vm.id, vm.name, vm.guest_ip}]
|
||||
if len(matches) > 1:
|
||||
raise ConflictError(f"ambiguous VM identifier: {identifier}; use an exact VM ID")
|
||||
if not matches:
|
||||
raise NotFoundError(f"VM not found: {identifier}")
|
||||
return matches[0]
|
||||
|
||||
@contextmanager
|
||||
def _locked(self) -> Iterator[None]:
|
||||
|
||||
+2
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user