Dev #1
+5
-1
@@ -272,6 +272,9 @@ mode. There is no persisted boolean asserting that a policy is enforced.
|
|||||||
- Missing `disk` and process start time remain absent. Legacy `disk_ready` is
|
- Missing `disk` and process start time remain absent. Legacy `disk_ready` is
|
||||||
inferred only for running/stopped records with a recorded private disk;
|
inferred only for running/stopped records with a recorded private disk;
|
||||||
start still validates the path, file, and provisioning state.
|
start still validates the path, file, and provisioning state.
|
||||||
|
- Legacy records own their canonical runtime directory independently of whether
|
||||||
|
they had a private disk. Deletion never removes the shared rootfs template;
|
||||||
|
explicit `runtime_owned=False` on a new interrupted creation stays authoritative.
|
||||||
- Records created before credential provisioning default to username `root`
|
- Records created before credential provisioning default to username `root`
|
||||||
and password `null`; UVM does not apply a password retroactively.
|
and password `null`; UVM does not apply a password retroactively.
|
||||||
- `next_mac_index` is reconstructed from older locally administered MACs when
|
- `next_mac_index` is reconstructed from older locally administered MACs when
|
||||||
@@ -384,7 +387,8 @@ merely because its PID was never persisted or its socket has not appeared.
|
|||||||
|
|
||||||
### Policy and Bulk Operations
|
### Policy and Bulk Operations
|
||||||
|
|
||||||
`set_ports()` persists the new allowlist with `operation=policy` before
|
`set_ports()` takes a frozen registry ID, not a reusable name/IP; the CLI resolves
|
||||||
|
the user's identifier before confirmation. It persists the new allowlist with `operation=policy` before
|
||||||
applying it. Live updates apply atomically and retain conntrack; cleanly
|
applying it. Live updates apply atomically and retain conntrack; cleanly
|
||||||
stopped edits are metadata-only. On apply/readback failure, preserve the new
|
stopped edits are metadata-only. On apply/readback failure, preserve the new
|
||||||
intent, attempt link-down/quarantine, and leave a failed pending record. Never
|
intent, attempt link-down/quarantine, and leave a failed pending record. Never
|
||||||
|
|||||||
@@ -308,6 +308,9 @@ forward rules for return traffic. Guest IPs, gateway, bridge name, and guest
|
|||||||
boot netmask derive from the configured CIDR.
|
boot netmask derive from the configured CIDR.
|
||||||
|
|
||||||
The existing `iptables` egress NAT/forwarding behavior is unchanged. Private
|
The existing `iptables` egress NAT/forwarding behavior is unchanged. Private
|
||||||
|
VM creation and start require an RFC 1918 subnet (`10.0.0.0/8`,
|
||||||
|
`172.16.0.0/12`, or `192.168.0.0/16`). Retained VMs prevent incompatible
|
||||||
|
gateway, subnet, or bridge changes within the same registry. Private
|
||||||
port enforcement uses separate per-VM native nftables `bridge` tables, not
|
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
|
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
|
is activated and before the guest starts. The software bridge must have STP
|
||||||
@@ -325,6 +328,9 @@ There is no autostart service or daemon reconciliation. After externally lost
|
|||||||
or changed UVM rules, inspect policy status and explicitly run `ports sync`.
|
or changed UVM rules, inspect policy status and explicitly run `ports sync`.
|
||||||
Read-only commands do not repair rules; sync does not boot stopped VMs.
|
Read-only commands do not repair rules; sync does not boot stopped VMs.
|
||||||
|
|
||||||
|
Use one authoritative registry per bridge/subnet. Independent `UVM_BASE`
|
||||||
|
directories do not coordinate IP or MAC allocation with each other.
|
||||||
|
|
||||||
## Persistent State
|
## Persistent State
|
||||||
|
|
||||||
The default data directory is `/var/lib/uvm`:
|
The default data directory is `/var/lib/uvm`:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ description = "Local Firecracker microVM command-line manager"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1",
|
"fastapi>=0.115,<1",
|
||||||
|
"pydantic>=2,<3",
|
||||||
"uvicorn>=0.30,<1",
|
"uvicorn>=0.30,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,80 @@ class FirewallManagerTests(unittest.TestCase):
|
|||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(CommandError):
|
||||||
self.manager.cleanup_connections(self.vm)
|
self.manager.cleanup_connections(self.vm)
|
||||||
|
|
||||||
|
def test_legacy_cleanup_outside_current_network_uses_only_recorded_ip(self):
|
||||||
|
record = dict(id="vm-legacy", cpu=1, ram_mib=128, guest_ip="203.0.113.7",
|
||||||
|
gateway="203.0.113.1", tap="tap-legacy", mac=self.vm.mac,
|
||||||
|
socket="", config="", log="", status="stopped")
|
||||||
|
for stored_network in (None, "203.0.113.0/24"):
|
||||||
|
with self.subTest(network=stored_network):
|
||||||
|
vm = VmRecord.from_dict({**record, **({"network": stored_network} if stored_network else {})})
|
||||||
|
self.assertIsNone(vm.ports)
|
||||||
|
self.runner.calls.clear()
|
||||||
|
self.manager.cleanup_connections(vm)
|
||||||
|
expected = [("ip", "-j", "-d", "link", "show")]
|
||||||
|
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
|
||||||
|
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst")
|
||||||
|
for command in ("--delete", "--list"))
|
||||||
|
# No nft policy, publishing, or unrelated address/conntrack flush.
|
||||||
|
self.assertEqual([args for args, _ in self.runner.calls], expected)
|
||||||
|
|
||||||
|
def test_cleanup_host_boundaries_use_stored_network_not_current_settings(self):
|
||||||
|
cases = (
|
||||||
|
("10.77.8.2", "10.77.0.0/16"),
|
||||||
|
("100.64.3.4", "100.64.0.0/16"),
|
||||||
|
("10.42.0.1", None), # Current gateway is not a cleanup constraint.
|
||||||
|
("10.42.0.0", None), # Do not guess the old prefix from current settings.
|
||||||
|
("10.42.0.255", None),
|
||||||
|
("203.0.113.0", "203.0.0.0/16"),
|
||||||
|
("203.0.113.255", "203.0.0.0/16"),
|
||||||
|
("203.0.113.8", "203.0.113.8/31"),
|
||||||
|
("203.0.113.9", "203.0.113.8/31"),
|
||||||
|
("203.0.113.8", "203.0.113.8/32"),
|
||||||
|
)
|
||||||
|
for address, network in cases:
|
||||||
|
with self.subTest(address=address, network=network):
|
||||||
|
self.runner.calls.clear()
|
||||||
|
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
|
||||||
|
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")]
|
||||||
|
self.assertEqual(len(deletes), 4)
|
||||||
|
self.assertTrue(all(args[-1] == address for args in deletes))
|
||||||
|
|
||||||
|
def test_cleanup_rejects_invalid_or_non_host_persisted_addresses(self):
|
||||||
|
cases = [(address, None) for address in (
|
||||||
|
"not-an-ip", "203.0.113.999", "203.0.113.7/24", "2001:db8::7",
|
||||||
|
"::ffff:203.0.113.7", None, True, 3405803783,
|
||||||
|
"0.0.0.0", "127.0.0.1", "127.12.2.3", "169.254.1.2",
|
||||||
|
"224.0.0.1", "239.255.255.255", "255.255.255.255",
|
||||||
|
)]
|
||||||
|
cases.extend((
|
||||||
|
("203.0.113.0", "203.0.113.0/24"),
|
||||||
|
("203.0.113.255", "203.0.113.0/24"),
|
||||||
|
("203.0.113.7", "203.0.114.0/24"),
|
||||||
|
("203.0.113.7", "not-a-network"),
|
||||||
|
("203.0.113.7", ""),
|
||||||
|
("203.0.113.7", "2001:db8::/64"),
|
||||||
|
))
|
||||||
|
for address, network in cases:
|
||||||
|
with self.subTest(address=address, network=network):
|
||||||
|
self.runner.calls.clear()
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
|
||||||
|
self.assertEqual([args for args, _ in self.runner.calls], [("ip", "-j", "-d", "link", "show")])
|
||||||
|
|
||||||
|
def test_legacy_public_cleanup_still_requires_tap_absence(self):
|
||||||
|
self.runner.links = [{"ifname": "tap-legacy"}]
|
||||||
|
vm = vm_record(tap="tap-legacy", guest_ip="203.0.113.7", ports=None)
|
||||||
|
with self.assertRaisesRegex(UvmError, "still exists"):
|
||||||
|
self.manager.cleanup_connections(vm)
|
||||||
|
self.assertFalse(any(args[0] == "conntrack" for args, _ in self.runner.calls))
|
||||||
|
|
||||||
|
def test_cleanup_compatibility_does_not_allow_a_public_managed_policy(self):
|
||||||
|
vm = vm_record(guest_ip="203.0.113.7", network="203.0.113.0/24", ports=())
|
||||||
|
with self.assertRaisesRegex(ValidationError, "private guest IP"):
|
||||||
|
self.manager.apply(vm)
|
||||||
|
self.assertEqual(self.runner.calls, [])
|
||||||
|
self.manager.cleanup_connections(vm)
|
||||||
|
|
||||||
def test_availability_checks_kernel_and_full_policy_without_committing(self):
|
def test_availability_checks_kernel_and_full_policy_without_committing(self):
|
||||||
self.manager.check_available()
|
self.manager.check_available()
|
||||||
self.assertEqual(self.runner.tables, {})
|
self.assertEqual(self.runner.tables, {})
|
||||||
|
|||||||
+123
-3
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Callable, Iterable, Iterator
|
from collections.abc import Callable, Iterable, Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -485,6 +486,81 @@ class LifecycleTests(LifecycleTestCase):
|
|||||||
self.provision.assert_not_called()
|
self.provision.assert_not_called()
|
||||||
self.copy_disk.assert_not_called()
|
self.copy_disk.assert_not_called()
|
||||||
|
|
||||||
|
def test_create_rejects_retained_topology_changes_before_allocation_or_bridge_work(self) -> None:
|
||||||
|
vm = self.create_vm()
|
||||||
|
self.copy_disk.reset_mock()
|
||||||
|
self.provision.reset_mock()
|
||||||
|
changes = (
|
||||||
|
{"gateway": IPv4Address(vm.guest_ip)},
|
||||||
|
{"gateway": IPv4Address("10.42.0.10")},
|
||||||
|
{"network": IPv4Network("10.42.0.0/25")},
|
||||||
|
{"bridge": "uvm-other"},
|
||||||
|
{"gateway": IPv4Address(vm.guest_ip), "network": IPv4Network("10.42.0.0/25")},
|
||||||
|
)
|
||||||
|
for status in ("running", "stopped"):
|
||||||
|
if status == "stopped":
|
||||||
|
self.service.stop(vm.id)
|
||||||
|
before = self.settings.state_path.read_bytes()
|
||||||
|
before_directories = set(self.settings.vms_dir.iterdir())
|
||||||
|
before_taps = self.network.taps.copy()
|
||||||
|
before_active = self.network.active_taps.copy()
|
||||||
|
before_tables = self.network.firewall.tables.copy()
|
||||||
|
for changed in changes:
|
||||||
|
with self.subTest(status=status, changed=changed):
|
||||||
|
settings = replace(self.settings, **changed)
|
||||||
|
service = LifecycleService(
|
||||||
|
settings, self.store, self.images, self.network, self.process,
|
||||||
|
client_factory=self.make_client,
|
||||||
|
)
|
||||||
|
self.host.events.clear()
|
||||||
|
with (
|
||||||
|
patch.object(self.network, "_settings", settings),
|
||||||
|
patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate,
|
||||||
|
self.assertRaisesRegex(ConflictError, "topology conflicts with retained VMs"),
|
||||||
|
):
|
||||||
|
service.create(VmSpec(cpu=1, ram_mib=512))
|
||||||
|
allocate.assert_not_called()
|
||||||
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
|
self.assertEqual(set(self.settings.vms_dir.iterdir()), before_directories)
|
||||||
|
self.assertEqual(self.network.taps, before_taps)
|
||||||
|
self.assertEqual(self.network.active_taps, before_active)
|
||||||
|
self.assertEqual(self.network.firewall.tables, before_tables)
|
||||||
|
self.assertEqual(self.host.events, [("firewall.check_available", None)])
|
||||||
|
self.copy_disk.assert_not_called()
|
||||||
|
self.provision.assert_not_called()
|
||||||
|
self.assertEqual(len(self.process.started), 1)
|
||||||
|
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
|
||||||
|
|
||||||
|
def test_non_rfc1918_network_fails_before_kvm_assets_or_registry_reservation(self) -> None:
|
||||||
|
for network, gateway in (
|
||||||
|
("203.0.113.0/24", "203.0.113.1"),
|
||||||
|
("100.64.0.0/24", "100.64.0.1"),
|
||||||
|
("8.8.8.0/24", "8.8.8.1"),
|
||||||
|
("10.0.0.0/7", "10.0.0.1"),
|
||||||
|
):
|
||||||
|
with self.subTest(network=network):
|
||||||
|
settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway))
|
||||||
|
service = LifecycleService(
|
||||||
|
settings, self.store, self.images, self.network, self.process,
|
||||||
|
client_factory=self.make_client,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(self.network, "_settings", settings),
|
||||||
|
patch.object(self.images, "installed_assets", wraps=self.images.installed_assets) as assets,
|
||||||
|
patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate,
|
||||||
|
self.assertRaisesRegex(ValidationError, "RFC 1918"),
|
||||||
|
):
|
||||||
|
service.create(VmSpec(cpu=1, ram_mib=512))
|
||||||
|
assets.assert_not_called()
|
||||||
|
allocate.assert_not_called()
|
||||||
|
self.check_kvm.assert_not_called()
|
||||||
|
self.assertFalse(self.settings.state_path.exists())
|
||||||
|
self.assertEqual(self.store.load().vms, {})
|
||||||
|
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||||
|
self.assertEqual(self.host.events, [])
|
||||||
|
self.provision.assert_not_called()
|
||||||
|
self.copy_disk.assert_not_called()
|
||||||
|
|
||||||
def test_create_cleans_up_if_policy_readback_is_not_enforced_after_boot(self) -> None:
|
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 patch.object(self.network.firewall, "status", return_value="drift"):
|
||||||
with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"):
|
with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"):
|
||||||
@@ -605,10 +681,54 @@ class LifecycleTests(LifecycleTestCase):
|
|||||||
self.copy_disk.assert_not_called()
|
self.copy_disk.assert_not_called()
|
||||||
self.assertEqual(self.network.created_taps, [])
|
self.assertEqual(self.network.created_taps, [])
|
||||||
self.assertEqual(self.process.started, [])
|
self.assertEqual(self.process.started, [])
|
||||||
retained = self.store.load().vms.get("vm-existing")
|
retained = self.store.get("vm-existing")
|
||||||
if retained is not None:
|
|
||||||
self.assertFalse(retained.runtime_owned)
|
self.assertFalse(retained.runtime_owned)
|
||||||
self.assertFalse(retained.disk_ready)
|
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:
|
def test_stale_stopping_and_deleting_records_can_finish_cleanup(self) -> None:
|
||||||
for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")):
|
for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")):
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class LifecycleLookupTests(LifecycleTestCase):
|
|||||||
before = self.settings.state_path.read_bytes()
|
before = self.settings.state_path.read_bytes()
|
||||||
for kwargs, message in (
|
for kwargs, message in (
|
||||||
({"name": vm.name}, "VM name already exists"),
|
({"name": vm.name}, "VM name already exists"),
|
||||||
({"name": vm.id}, "VM name already exists"),
|
({"name": vm.id}, "must not impersonate generated VM IDs"),
|
||||||
({"guest_ip": IPv4Address(vm.guest_ip)}, "IP already allocated"),
|
({"guest_ip": IPv4Address(vm.guest_ip)}, "IP already allocated"),
|
||||||
):
|
):
|
||||||
with self.subTest(kwargs=kwargs), self.assertRaisesRegex(UvmError, message):
|
with self.subTest(kwargs=kwargs), self.assertRaisesRegex(UvmError, message):
|
||||||
@@ -53,6 +53,16 @@ class LifecycleLookupTests(LifecycleTestCase):
|
|||||||
self.assertEqual(self.copy_disk.call_count, 1)
|
self.assertEqual(self.copy_disk.call_count, 1)
|
||||||
self.assertEqual(len(self.process.started), 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:
|
def test_generated_id_retries_id_name_and_tap_collisions(self) -> None:
|
||||||
with patch("uvm.lifecycle.new_vm_id", return_value="vm-first-same1234567"):
|
with patch("uvm.lifecycle.new_vm_id", return_value="vm-first-same1234567"):
|
||||||
first = self.create_vm(name="reserved-name")
|
first = self.create_vm(name="reserved-name")
|
||||||
@@ -68,7 +78,8 @@ class LifecycleLookupTests(LifecycleTestCase):
|
|||||||
self.assertNotEqual(second.guest_ip, first.guest_ip)
|
self.assertNotEqual(second.guest_ip, first.guest_ip)
|
||||||
self.assertEqual(self.network.taps, {first.tap: first.id, second.tap: second.id})
|
self.assertEqual(self.network.taps, {first.tap: first.id, second.tap: second.id})
|
||||||
|
|
||||||
def test_ambiguous_persisted_name_ip_or_id_never_selects_an_arbitrary_vm(self) -> None:
|
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")
|
first = self.create_vm(name="first")
|
||||||
second = self.create_vm(name="second")
|
second = self.create_vm(name="second")
|
||||||
for identifier in (first.guest_ip, first.id, first.name):
|
for identifier in (first.guest_ip, first.id, first.name):
|
||||||
@@ -85,6 +96,34 @@ class LifecycleLookupTests(LifecycleTestCase):
|
|||||||
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
self.assertEqual(self.host.events, [])
|
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:
|
def test_target_resolution_rejects_invalid_or_missing_targets_without_mutation(self) -> None:
|
||||||
vm = self.create_vm(name="web")
|
vm = self.create_vm(name="web")
|
||||||
before = self.settings.state_path.read_bytes()
|
before = self.settings.state_path.read_bytes()
|
||||||
@@ -102,6 +141,36 @@ class LifecycleLookupTests(LifecycleTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class LifecyclePortTests(LifecycleTestCase):
|
class LifecyclePortTests(LifecycleTestCase):
|
||||||
|
def test_set_ports_requires_an_exact_registry_id_not_a_name_or_ip(self) -> None:
|
||||||
|
vm = self.create_vm(name="web")
|
||||||
|
before = self.settings.state_path.read_bytes()
|
||||||
|
self.host.events.clear()
|
||||||
|
for identifier in (vm.name, vm.guest_ip):
|
||||||
|
with self.subTest(identifier=identifier), self.assertRaises(NotFoundError):
|
||||||
|
self.service.set_ports(identifier, ())
|
||||||
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
|
self.assertEqual(self.host.events, [])
|
||||||
|
self.assertEqual(self.service.set_ports(vm.id, ()).ports, ())
|
||||||
|
|
||||||
|
def test_frozen_legacy_id_cannot_retarget_policy_after_name_reuse(self) -> None:
|
||||||
|
with patch("uvm.lifecycle.new_vm_id", return_value="vm-legacy"):
|
||||||
|
original = self.create_vm(name="original")
|
||||||
|
selected = self.service.inspect(original.name).vm.id
|
||||||
|
self.service.destroy(original.id)
|
||||||
|
replacement = self.create_vm(name=original.id, ports=(PortRule(22), PortRule(80)))
|
||||||
|
self.assertEqual(self.service.inspect(selected).vm.id, replacement.id)
|
||||||
|
before = self.settings.state_path.read_bytes()
|
||||||
|
before_tables = self.network.firewall.tables.copy()
|
||||||
|
self.host.events.clear()
|
||||||
|
for mode, ports in (("open", (PortRule(443),)), ("close", (PortRule(80),)), ("set", ())):
|
||||||
|
with self.subTest(mode=mode), self.assertRaises(NotFoundError):
|
||||||
|
self.service.set_ports(selected, ports, mode=mode)
|
||||||
|
self.assertEqual(self.settings.state_path.read_bytes(), before)
|
||||||
|
self.assertEqual(self.network.firewall.tables, before_tables)
|
||||||
|
self.assertEqual(self.host.events, [])
|
||||||
|
self.assertIn(replacement.tap, self.network.active_taps)
|
||||||
|
self.assertEqual(self.process.observe(replacement), "alive")
|
||||||
|
|
||||||
def test_stopped_port_edits_and_sync_are_metadata_only(self) -> None:
|
def test_stopped_port_edits_and_sync_are_metadata_only(self) -> None:
|
||||||
vm = self.create_vm()
|
vm = self.create_vm()
|
||||||
stopped = self.service.stop(vm.id)
|
stopped = self.service.stop(vm.id)
|
||||||
@@ -195,8 +264,61 @@ class LifecyclePortTests(LifecycleTestCase):
|
|||||||
pending = self.store.get(vm.id)
|
pending = self.store.get(vm.id)
|
||||||
self.assertEqual(pending.ports, (PortRule(22),))
|
self.assertEqual(pending.ports, (PortRule(22),))
|
||||||
self.assertEqual(pending.operation, "policy")
|
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.assertEqual(self.network.firewall.status(pending), "quarantined")
|
||||||
self.assertIn("firewall.quarantine", self.host.actions(vm.id))
|
self.assert_call_order(vm, "firewall.apply", "network.deactivate_tap", "firewall.quarantine")
|
||||||
|
|
||||||
|
def test_policy_failure_never_quarantines_a_missing_or_foreign_tap(self) -> None:
|
||||||
|
for owner in (None, "operator-owned"):
|
||||||
|
with self.subTest(owner=owner):
|
||||||
|
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
|
||||||
|
if owner is None:
|
||||||
|
del self.network.taps[vm.tap]
|
||||||
|
self.network.active_taps.discard(vm.tap)
|
||||||
|
else:
|
||||||
|
self.network.taps[vm.tap] = owner
|
||||||
|
with self.assertRaisesRegex(ConflictError, "unowned TAP"):
|
||||||
|
self.network.has_tap(vm)
|
||||||
|
before_taps = self.network.taps.copy()
|
||||||
|
before_active = self.network.active_taps.copy()
|
||||||
|
before_tables = self.network.firewall.tables.copy()
|
||||||
|
self.host.events.clear()
|
||||||
|
with self.assertRaisesRegex(UvmError, "policy pending"):
|
||||||
|
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
|
||||||
|
pending = self.store.get(vm.id)
|
||||||
|
self.assertEqual((pending.ports, pending.operation), ((PortRule(22),), "policy"))
|
||||||
|
self.assertEqual(pending.pid, vm.pid)
|
||||||
|
self.assertEqual(self.network.taps, before_taps)
|
||||||
|
self.assertEqual(self.network.active_taps, before_active)
|
||||||
|
self.assertEqual(self.network.firewall.tables, before_tables)
|
||||||
|
self.assertFalse(set(self.host.actions(vm.id)) & {
|
||||||
|
"firewall.apply", "firewall.quarantine", "firewall.remove",
|
||||||
|
"firewall.cleanup_connections", "network.delete_vm_tap", "process.terminate",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_policy_fallback_rechecks_ownership_after_a_tap_is_replaced(self) -> None:
|
||||||
|
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
|
||||||
|
before_tables = self.network.firewall.tables.copy()
|
||||||
|
apply = self.network.firewall.apply
|
||||||
|
|
||||||
|
def replace_tap_and_fail(record, *, quarantine=False):
|
||||||
|
if not quarantine:
|
||||||
|
self.network.taps[record.tap] = "operator-owned"
|
||||||
|
self.host.failures["firewall.apply", record.id] = UvmError("policy rejected")
|
||||||
|
return apply(record, quarantine=quarantine)
|
||||||
|
|
||||||
|
self.host.events.clear()
|
||||||
|
with patch.object(self.network.firewall, "apply", side_effect=replace_tap_and_fail) as applied:
|
||||||
|
with self.assertRaisesRegex(UvmError, "policy pending.*unowned TAP"):
|
||||||
|
self.service.set_ports(vm.id, (PortRule(80),), mode="close")
|
||||||
|
self.assertEqual(applied.call_count, 1)
|
||||||
|
self.assertEqual(self.network.taps[vm.tap], "operator-owned")
|
||||||
|
self.assertIn(vm.tap, self.network.active_taps)
|
||||||
|
self.assertEqual(self.network.firewall.tables, before_tables)
|
||||||
|
self.assertNotIn("firewall.quarantine", self.host.actions(vm.id))
|
||||||
|
self.assertEqual(self.store.get(vm.id).ports, (PortRule(22),))
|
||||||
|
|
||||||
def test_failed_close_keeps_tap_down_if_quarantine_also_fails(self) -> None:
|
def test_failed_close_keeps_tap_down_if_quarantine_also_fails(self) -> None:
|
||||||
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
|
vm = self.create_vm(ports=(PortRule(22), PortRule(80)))
|
||||||
@@ -348,10 +470,12 @@ class LifecycleBulkTests(LifecycleTestCase):
|
|||||||
self.assertEqual(self.store.get(retained.id).status, "stopped")
|
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:
|
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")
|
original = self.create_vm(name="original")
|
||||||
selected = tuple(vm.id for vm in self.service.resolve_targets([original.name]))
|
selected = tuple(vm.id for vm in self.service.resolve_targets([original.name]))
|
||||||
self.service.destroy(original.id)
|
self.service.destroy(original.id)
|
||||||
replacement = self.create_vm(name=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()
|
before = self.store.get(replacement.id).to_dict()
|
||||||
self.host.events.clear()
|
self.host.events.clear()
|
||||||
results = self.service.run_many("delete", selected)
|
results = self.service.run_many("delete", selected)
|
||||||
|
|||||||
+20
-1
@@ -89,6 +89,8 @@ class FirewallManager:
|
|||||||
current = self._read_table(table["name"])
|
current = self._read_table(table["name"])
|
||||||
if current is not None:
|
if current is not None:
|
||||||
self._transaction([self._delete_owned(current, table)])
|
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:
|
def status(self, vm: VmRecord) -> str:
|
||||||
"""Inspect actual hooks, ordered rules, identity and complete port set.
|
"""Inspect actual hooks, ordered rules, identity and complete port set.
|
||||||
@@ -137,9 +139,26 @@ class FirewallManager:
|
|||||||
All four tuple addresses are checked, including NAT reply tuples. A
|
All four tuple addresses are checked, including NAT reply tuples. A
|
||||||
failed deletion is tolerated only if a successful list proves absence.
|
failed deletion is tolerated only if a successful list proves absence.
|
||||||
The caller must retain the address reservation until this succeeds.
|
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)
|
self._require_offline(vm)
|
||||||
address = self._guest_ip(vm)
|
try:
|
||||||
|
address = IPv4Address(str(vm.guest_ip))
|
||||||
|
stored_network = getattr(vm, "network", None)
|
||||||
|
network = IPv4Network(str(stored_network)) if stored_network is not None else None
|
||||||
|
except (ValueError, TypeError) as error:
|
||||||
|
raise ValidationError("conntrack cleanup requires a valid persisted IPv4 address/network") from error
|
||||||
|
if (address.is_unspecified or address.is_loopback or address.is_link_local
|
||||||
|
or address.is_multicast or address == IPv4Address("255.255.255.255")):
|
||||||
|
raise ValidationError("conntrack cleanup requires a usable persisted unicast IPv4 address")
|
||||||
|
# /31 point-to-point subnets and /32 host routes have no broadcast address.
|
||||||
|
if network is not None and (
|
||||||
|
address not in network or (network.prefixlen < 31
|
||||||
|
and address in {network.network_address, network.broadcast_address})
|
||||||
|
):
|
||||||
|
raise ValidationError("conntrack cleanup requires a usable guest IP inside its persisted network")
|
||||||
|
address = str(address)
|
||||||
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
|
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
|
||||||
selector = ("--family", "ipv4", field, address)
|
selector = ("--family", "ipv4", field, address)
|
||||||
self._run(("conntrack", "--delete", *selector), check=False)
|
self._run(("conntrack", "--delete", *selector), check=False)
|
||||||
|
|||||||
+5
-1
@@ -104,6 +104,10 @@ class LifecycleService:
|
|||||||
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")):
|
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")
|
raise ValidationError("private ports require a network inside an RFC 1918 IPv4 range")
|
||||||
check_kvm()
|
check_kvm()
|
||||||
|
self._verify_firecracker()
|
||||||
|
self._network.firewall.check_available()
|
||||||
|
|
||||||
|
def _verify_firecracker(self) -> None:
|
||||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||||
checksum = self._settings.firecracker_binary_sha256
|
checksum = self._settings.firecracker_binary_sha256
|
||||||
if manifest.verified or self._settings.allow_unverified_downloads:
|
if manifest.verified or self._settings.allow_unverified_downloads:
|
||||||
@@ -111,7 +115,6 @@ class LifecycleService:
|
|||||||
verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary",
|
verify_file(self._settings.firecracker_binary, checksum, "Firecracker binary",
|
||||||
"UVM_FIRECRACKER_BINARY_SHA256",
|
"UVM_FIRECRACKER_BINARY_SHA256",
|
||||||
allow_unverified=self._settings.allow_unverified_downloads)
|
allow_unverified=self._settings.allow_unverified_downloads)
|
||||||
self._network.firewall.check_available()
|
|
||||||
|
|
||||||
def _boot(self, vm: VmRecord, kernel: Path) -> None:
|
def _boot(self, vm: VmRecord, kernel: Path) -> None:
|
||||||
vm.status, vm.operation = "starting", "network"
|
vm.status, vm.operation = "starting", "network"
|
||||||
@@ -414,6 +417,7 @@ class LifecycleService:
|
|||||||
|
|
||||||
def diagnostics(self) -> list[dict]:
|
def diagnostics(self) -> list[dict]:
|
||||||
checks = [("root", require_root), ("kvm", check_kvm),
|
checks = [("root", require_root), ("kvm", check_kvm),
|
||||||
|
("Firecracker", self._verify_firecracker),
|
||||||
("guest assets", self._images.installed_assets),
|
("guest assets", self._images.installed_assets),
|
||||||
("private firewall", self._network.firewall.check_available)]
|
("private firewall", self._network.firewall.check_available)]
|
||||||
results = []
|
results = []
|
||||||
|
|||||||
+1
-22
@@ -9,7 +9,7 @@ from ipaddress import IPv4Address
|
|||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .domain import VmRecord
|
from .domain import VmRecord
|
||||||
from .errors import ConflictError, TapCreationError, UvmError, ValidationError
|
from .errors import ConflictError, UvmError, ValidationError
|
||||||
from .firewall import FirewallManager
|
from .firewall import FirewallManager
|
||||||
from .system import CommandRunner
|
from .system import CommandRunner
|
||||||
|
|
||||||
@@ -166,27 +166,6 @@ class NetworkManager:
|
|||||||
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
||||||
return link
|
return link
|
||||||
|
|
||||||
def create_tap(self, name: str) -> None:
|
|
||||||
existing = self._runner.run(("ip", "link", "show", name), check=False, capture=True)
|
|
||||||
if existing.returncode == 0:
|
|
||||||
raise UvmError(f"TAP device already exists: {name}")
|
|
||||||
created = False
|
|
||||||
try:
|
|
||||||
self._runner.run(("ip", "tuntap", "add", "dev", name, "mode", "tap"))
|
|
||||||
created = True
|
|
||||||
self._runner.run(("ip", "link", "set", name, "master", self._settings.bridge))
|
|
||||||
self._runner.run(("ip", "link", "set", name, "up"))
|
|
||||||
except BaseException as error:
|
|
||||||
if created:
|
|
||||||
try:
|
|
||||||
self.delete_tap(name)
|
|
||||||
except UvmError as cleanup_error:
|
|
||||||
raise TapCreationError(
|
|
||||||
f"could not configure TAP device {name}; cleanup also failed: {cleanup_error}",
|
|
||||||
tap_created=True,
|
|
||||||
) from error
|
|
||||||
raise
|
|
||||||
|
|
||||||
def delete_tap(self, name: str) -> None:
|
def delete_tap(self, name: str) -> None:
|
||||||
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
|
deleted = self._runner.run(("ip", "link", "del", name), check=False, capture=True)
|
||||||
if deleted.returncode == 0:
|
if deleted.returncode == 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user