Refactor documentation structure and improve validation checks; update VM state handling and error management.
This commit is contained in:
+10
-8
@@ -58,7 +58,8 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
||||
```text
|
||||
./
|
||||
├── DEVELOPER.md # This document
|
||||
├── README.md # Operator-facing usage guide
|
||||
├── README.md # Project overview
|
||||
├── USAGE.md # Operator command/deployment guide
|
||||
├── pyproject.toml # Package metadata and dependencies
|
||||
├── uvm.py # Executable source-tree launcher
|
||||
├── uvm/
|
||||
@@ -345,12 +346,12 @@ processes, and allocation reuse.
|
||||
|
||||
### Start and Restart
|
||||
|
||||
Start requires managed ports, a proven-gone process (or an already identified
|
||||
running VM), a complete safe private disk, and compatible retained gateway,
|
||||
network, and bridge settings. It verifies the VMM and installed **kernel**;
|
||||
the original rootfs template is not needed. It never recopies the rootfs,
|
||||
reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC,
|
||||
ports, and guest disk changes survive stop/start; guest RAM does not.
|
||||
To boot a stopped/proven-gone VM, start requires managed ports, a complete safe
|
||||
private disk, and compatible retained gateway, network, and bridge settings.
|
||||
It verifies the VMM and installed **kernel**; the original rootfs template is
|
||||
not needed. It never recopies the rootfs, reprovisions credentials, or
|
||||
regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes
|
||||
survive stop/start; guest RAM does not.
|
||||
|
||||
Start on a healthy running VM synchronizes policy without spawning another
|
||||
VMM. An ambiguous process or interrupted live boot/stop is not adopted as a
|
||||
@@ -779,6 +780,7 @@ The packet test skipped in this workspace because namespace creation was
|
||||
denied (`Operation not permitted`). Document that permission limitation, not
|
||||
an assertion that real packets or a real host were validated. Unit tests and
|
||||
nft JSON parser checks are not substitutes for kernel packet-path execution.
|
||||
|
||||
### Isolated Host Validation
|
||||
|
||||
No current test automatically boots a real microVM. Before a release, validate
|
||||
@@ -841,7 +843,7 @@ The code is intentionally honest about current limits:
|
||||
- Firecracker is launched directly, even though the Jailer binary is
|
||||
downloaded.
|
||||
- No cgroup CPU, memory, I/O, or process limits are applied.
|
||||
- Fractional CPU is represented by rounding vCPUs up and printing an advisory.
|
||||
- Fractional CPU requests round vCPUs up without host quota enforcement.
|
||||
- Guest health is inferred from VMM process state, not application readiness.
|
||||
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
|
||||
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
|
||||
|
||||
@@ -386,9 +386,13 @@ Host and API settings can be overridden through environment variables:
|
||||
|
||||
## Project Layout
|
||||
|
||||
Selected files:
|
||||
|
||||
```text
|
||||
./
|
||||
├── README.md, USAGE.md, DEVELOPER.md
|
||||
├── README.md
|
||||
├── USAGE.md
|
||||
├── DEVELOPER.md
|
||||
├── pyproject.toml
|
||||
├── uvm.py # Executable compatibility launcher
|
||||
├── uvm/ # Private application implementation
|
||||
|
||||
@@ -97,6 +97,8 @@ SSH to a known guest IP does not require root.
|
||||
The following is syntax notation, not a shell script. `VM` means an exact VM
|
||||
ID, local name, or guest IPv4 address. Flags are command-specific; in
|
||||
particular, create has no `--dry-run`, and start has no `--timeout`.
|
||||
Examples elsewhere are separate scenarios; substitute your VM identifiers
|
||||
and do not run every destructive example as a single script.
|
||||
|
||||
```text
|
||||
uvm install [--force]
|
||||
@@ -235,7 +237,7 @@ literal secret in the command line or history. Do not enable shell tracing:
|
||||
|
||||
```bash
|
||||
sudo -v
|
||||
read -r -s -p 'Guest password: ' guest_password
|
||||
IFS= read -r -s -p 'Guest password: ' guest_password
|
||||
printf '\n'
|
||||
printf '%s\n' "$guest_password" | sudo ./uvm.py create --name app \
|
||||
--cpu 1 --ram 1G --guest-ip 10.42.0.10 \
|
||||
@@ -346,8 +348,9 @@ adopt a legacy process, or reconcile a VM. Policy status is a separate snapshot:
|
||||
| `unknown` | Inspection failed or metadata could not establish policy status. |
|
||||
|
||||
`enforced` is not a service-connectivity test or a lasting guarantee against
|
||||
external rule changes. Mutation JSON may report policy `unknown` without a
|
||||
fresh observation; use inspect or ports list for a current readback.
|
||||
external rule changes. Dry-run target snapshots may show policy `unknown`
|
||||
because a preview does not verify enforcement. Use inspect or ports list for
|
||||
a current readback.
|
||||
|
||||
### Logs and Doctor
|
||||
|
||||
@@ -441,11 +444,12 @@ support, and compatible network settings are still required. Removing the
|
||||
original rootfs template does not prevent retained-disk start, although doctor
|
||||
checks templates and can report it missing.
|
||||
|
||||
Start on an already running, identified VM synchronizes its managed port
|
||||
policy without rebooting. Restart is stop followed by start under the same
|
||||
operation lock. Unknown process identity, an incomplete/unsafe disk, or pending
|
||||
deletion blocks a new boot; inspect the error rather than editing registry
|
||||
flags. An interrupted boot with a live VMM must be stopped before start.
|
||||
Start on a VM recorded as running with a verified process identity synchronizes
|
||||
its managed port policy without rebooting. Restart is stop followed by start
|
||||
under the same operation lock. Unknown process identity, an incomplete/unsafe
|
||||
disk, or pending deletion blocks a new boot; inspect the error rather than
|
||||
editing registry flags. An interrupted boot with a live VMM must be stopped
|
||||
before start.
|
||||
|
||||
### Scenario: Permanently Remove a VM
|
||||
|
||||
@@ -508,10 +512,11 @@ or NAT can change reachability; a private IP is not by itself an exposure
|
||||
guarantee, and UVM does not integrate with external firewall managers yet.
|
||||
|
||||
UDP and IPv6 service policies are not yet supported. Unsolicited inbound UDP
|
||||
is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4 TCP/UDP (including
|
||||
DNS queries), replies to that egress, ARP, ICMP echo, and required related ICMP
|
||||
errors remain allowed. An empty TCP allowlist is not a complete network
|
||||
disconnect. Other VMs' policies still apply to guest-to-guest egress.
|
||||
is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4
|
||||
TCP/UDP (including DNS queries), replies to that egress, ARP, ICMP echo, and
|
||||
required related ICMP errors remain allowed. An empty TCP allowlist is not a
|
||||
complete network disconnect. Other VMs' policies still apply to guest-to-guest
|
||||
egress.
|
||||
|
||||
### List, Open, Close, and Set
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ class NetworkManagerTests(unittest.TestCase):
|
||||
self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}}
|
||||
self.runner.calls.clear()
|
||||
self.vm.tap_owned = True
|
||||
with self.assertRaises(UvmError):
|
||||
with self.assertRaises(ConflictError):
|
||||
getattr(self.network, operation)(self.vm)
|
||||
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
|
||||
self.assertIs(self.vm.tap_owned, True)
|
||||
|
||||
+26
-3
@@ -86,7 +86,7 @@ class StateStoreTests(unittest.TestCase):
|
||||
self.assertIsNone(vm.network)
|
||||
self.assertIsNone(vm.bridge)
|
||||
self.assertFalse(vm.disk_ready)
|
||||
self.assertFalse(vm.runtime_owned)
|
||||
self.assertTrue(vm.runtime_owned)
|
||||
self.assertFalse(vm.tap_owned)
|
||||
self.assertFalse(vm.spawn_pending)
|
||||
self.assertEqual(vm.updated_at, vm.created_at)
|
||||
@@ -103,6 +103,8 @@ class StateStoreTests(unittest.TestCase):
|
||||
self.assertEqual(persisted["next_mac_index"], 2)
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
||||
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
|
||||
self.assertIs(persisted["vms"]["vm-test"]["runtime_owned"], True)
|
||||
self.assertIs(persisted["vms"]["vm-test"]["disk_ready"], False)
|
||||
|
||||
def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None:
|
||||
for ports in (None, (), (PortRule(22), PortRule(8080, "http"))):
|
||||
@@ -258,8 +260,8 @@ class DomainStateTests(unittest.TestCase):
|
||||
("stopped", "/tmp/rootfs.ext4", None, (True, True, False)),
|
||||
("error", "/tmp/rootfs.ext4", 123, (False, True, True)),
|
||||
("starting", "/tmp/rootfs.ext4", None, (False, True, False)),
|
||||
("running", "", 123, (False, False, True)),
|
||||
("unknown", "", None, (False, False, False)),
|
||||
("running", "", 123, (False, True, True)),
|
||||
("unknown", "", None, (False, True, False)),
|
||||
)
|
||||
for status, disk, pid, expected in cases:
|
||||
with self.subTest(status=status, disk=disk, pid=pid):
|
||||
@@ -270,6 +272,27 @@ class DomainStateTests(unittest.TestCase):
|
||||
self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected)
|
||||
self.assertIsNone(vm.ports)
|
||||
|
||||
def test_empty_disk_legacy_runtime_is_owned_but_explicit_false_is_respected(self) -> None:
|
||||
for disk_fields in ({}, {"disk": ""}):
|
||||
with self.subTest(disk_fields=disk_fields):
|
||||
raw = replace(make_vm(), status="stopped").to_dict()
|
||||
for field in ("disk", "ports", "disk_ready", "runtime_owned"):
|
||||
raw.pop(field)
|
||||
raw.update(disk_fields)
|
||||
|
||||
legacy = VmRecord.from_dict(raw)
|
||||
self.assertEqual(legacy.disk, "")
|
||||
self.assertIs(legacy.runtime_owned, True)
|
||||
self.assertIs(legacy.disk_ready, False)
|
||||
self.assertIsNone(legacy.ports)
|
||||
self.assertEqual(VmRecord.from_dict(legacy.to_dict()), legacy)
|
||||
|
||||
new = VmRecord.from_dict({**raw, "ports": [], "runtime_owned": False})
|
||||
self.assertIs(new.runtime_owned, False)
|
||||
self.assertIs(new.disk_ready, False)
|
||||
self.assertEqual(new.ports, ())
|
||||
self.assertEqual(VmRecord.from_dict(new.to_dict()), new)
|
||||
|
||||
def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None:
|
||||
for operation in (None, "spawning", "configuring", "stopping", "policy"):
|
||||
with self.subTest(operation=operation):
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ class VmRecord:
|
||||
name=str(value["name"]) if value.get("name") is not None else None,
|
||||
ports=ports,
|
||||
disk_ready=value.get("disk_ready", legacy_ready),
|
||||
runtime_owned=value.get("runtime_owned", bool(value.get("disk"))),
|
||||
runtime_owned=value.get("runtime_owned", True),
|
||||
tap_owned=value.get("tap_owned", bool(value.get("pid"))),
|
||||
operation=str(value["operation"]) if value.get("operation") is not None else None,
|
||||
boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None,
|
||||
|
||||
+14
-2
@@ -7,6 +7,7 @@ import shutil
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from ipaddress import IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
@@ -99,6 +100,9 @@ class LifecycleService:
|
||||
raise UvmError(message) from error
|
||||
|
||||
def _check_boot(self) -> None:
|
||||
if not any(self._settings.network.subnet_of(IPv4Network(network))
|
||||
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")):
|
||||
raise ValidationError("private ports require a network inside an RFC 1918 IPv4 range")
|
||||
check_kvm()
|
||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||
checksum = self._settings.firecracker_binary_sha256
|
||||
@@ -258,12 +262,13 @@ class LifecycleService:
|
||||
self._network.firewall.remove(vm)
|
||||
|
||||
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
|
||||
"""Mutate a frozen registry ID; the CLI resolves names before confirmation."""
|
||||
require_root()
|
||||
ports = self._validated_ports(ports)
|
||||
if mode not in {"open", "close", "set"}:
|
||||
raise ValidationError(f"invalid port operation: {mode}")
|
||||
with self._state_store.operation_lock():
|
||||
vm = self._load_for_operation(identifier)
|
||||
vm = self._load_for_operation(identifier, exact_id=True)
|
||||
self._not_deleting(vm)
|
||||
observation = self._process.observe(vm)
|
||||
if observation == "unknown":
|
||||
@@ -321,7 +326,8 @@ class LifecycleService:
|
||||
containment_errors = []
|
||||
for contain in (
|
||||
lambda: self._network.deactivate_tap(vm),
|
||||
lambda: self._network.firewall.apply(vm, quarantine=True),
|
||||
lambda: (self._network.firewall.apply(vm, quarantine=True)
|
||||
if self._network.has_tap(vm) else None),
|
||||
):
|
||||
try:
|
||||
contain()
|
||||
@@ -457,6 +463,12 @@ class LifecycleService:
|
||||
|
||||
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
||||
with self._state_store.transaction() as state:
|
||||
for existing in state.vms.values():
|
||||
if (existing.guest_ip == str(self._settings.gateway)
|
||||
or existing.gateway != str(self._settings.gateway)
|
||||
or existing.network not in {None, str(self._settings.network)}
|
||||
or existing.bridge not in {None, self._settings.bridge}):
|
||||
raise ConflictError("configured topology conflicts with retained VMs; keep their network settings")
|
||||
if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()):
|
||||
raise ConflictError(f"VM name already exists: {spec.name}")
|
||||
vm_id = new_vm_id()
|
||||
|
||||
+7
-2
@@ -141,7 +141,10 @@ class NetworkManager:
|
||||
result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True)
|
||||
try:
|
||||
links = json.loads(result.stdout)
|
||||
if not isinstance(links, list) or any(not isinstance(link, dict) for link in links):
|
||||
if not isinstance(links, list) or any(
|
||||
not isinstance(link, dict) or not isinstance(link.get("ifname"), str)
|
||||
or not link["ifname"] for link in links
|
||||
):
|
||||
raise ValueError("invalid interface inventory")
|
||||
matches = [link for link in links if link.get("ifname") == name]
|
||||
if len(matches) > 1:
|
||||
@@ -155,7 +158,9 @@ class NetworkManager:
|
||||
if link is None:
|
||||
return None
|
||||
info = link.get("linkinfo", {})
|
||||
is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap"
|
||||
is_tap = (isinstance(info, dict) and info.get("info_kind") == "tun"
|
||||
and isinstance(info.get("info_data"), dict)
|
||||
and info["info_data"].get("type") == "tap")
|
||||
legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias")
|
||||
if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy):
|
||||
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
||||
|
||||
+5
-1
@@ -86,7 +86,7 @@ def parse_password(value: str) -> str:
|
||||
|
||||
|
||||
def parse_name(value: str) -> str:
|
||||
if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
|
||||
if not isinstance(value, str) or re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
|
||||
raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter")
|
||||
if re.fullmatch(r"vm-[0-9a-f]{32}", value):
|
||||
raise ValidationError("VM names must not impersonate generated VM IDs")
|
||||
@@ -94,8 +94,12 @@ def parse_name(value: str) -> str:
|
||||
|
||||
|
||||
def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]:
|
||||
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
||||
raise ValidationError("ports must be a sequence of PORT[:TYPE] strings")
|
||||
ports: dict[int, PortRule] = {}
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError("each port specification must be a string")
|
||||
for item in value.split(","):
|
||||
match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE)
|
||||
if match is None:
|
||||
|
||||
Reference in New Issue
Block a user