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