Enhance documentation and validation for VM lifecycle and network management; add tests for legacy cleanup and address validation.
This commit is contained in:
@@ -43,7 +43,7 @@ class LifecycleLookupTests(LifecycleTestCase):
|
||||
before = self.settings.state_path.read_bytes()
|
||||
for kwargs, message in (
|
||||
({"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"),
|
||||
):
|
||||
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(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")
|
||||
@@ -68,8 +78,9 @@ class LifecycleLookupTests(LifecycleTestCase):
|
||||
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_id_never_selects_an_arbitrary_vm(self) -> None:
|
||||
first = self.create_vm(name="first")
|
||||
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):
|
||||
@@ -85,6 +96,34 @@ class LifecycleLookupTests(LifecycleTestCase):
|
||||
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()
|
||||
@@ -102,6 +141,36 @@ class LifecycleLookupTests(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:
|
||||
vm = self.create_vm()
|
||||
stopped = self.service.stop(vm.id)
|
||||
@@ -195,8 +264,61 @@ class LifecyclePortTests(LifecycleTestCase):
|
||||
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.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:
|
||||
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")
|
||||
|
||||
def test_bulk_exact_ids_do_not_retarget_a_new_vm_named_after_a_deleted_id(self) -> None:
|
||||
original = self.create_vm(name="original")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user