feat: implement resource resizing for VMs with storage management

- Added KnownHostsManager to handle cleanup of SSH known_hosts entries upon VM deletion.
- Introduced disk_size_bytes method in ImageStore to read logical disk capacity.
- Enhanced LifecycleService to manage VM resource resizing, including CPU, RAM, and storage.
- Updated application configuration to support known_hosts_files for SSH management.
- Modified dashboard to include resource resizing options in the UI.
- Improved error handling and validation for resource inputs in the dashboard.
- Removed obsolete .pyc files from the repository.
This commit is contained in:
2026-09-21 20:37:52 +05:30
parent fb80ce7c8a
commit 4a46685a4c
40 changed files with 1048 additions and 63 deletions
+26 -11
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from unittest.mock import patch
from uvm.config import Settings
from uvm.domain import STATE_SCHEMA_VERSION, PortRule, State, VmRecord, VmSpec, new_vm_id
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,13 +61,13 @@ class StateStoreTests(unittest.TestCase):
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
self.base.mkdir(parents=True)
for version in (None, 1):
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",
"spawn_pending", "network", "bridge", "storage_mib", "pending_resize",
):
raw_vm.pop(field)
legacy = {"vms": {"vm-test": raw_vm}}
@@ -77,7 +77,7 @@ class StateStoreTests(unittest.TestCase):
loaded = self.store.load()
vm = loaded.vms["vm-test"]
self.assertEqual(loaded.schema_version, 2)
self.assertEqual(loaded.schema_version, 3)
self.assertEqual(vm.disk, "")
self.assertEqual(vm.username, "root")
self.assertIsNone(vm.password)
@@ -85,6 +85,8 @@ class StateStoreTests(unittest.TestCase):
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)
@@ -99,7 +101,7 @@ class StateStoreTests(unittest.TestCase):
state.vms["vm-test"].status = "stopped"
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
self.assertEqual(persisted["schema_version"], 2)
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"])
@@ -114,16 +116,17 @@ class StateStoreTests(unittest.TestCase):
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",
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, 2)
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"], 2)
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],
@@ -151,7 +154,7 @@ class StateStoreTests(unittest.TestCase):
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):
for version in (0, 1, 2, 3):
with self.subTest(version=version):
raw = {
"schema_version": version,
@@ -302,11 +305,23 @@ class DomainStateTests(unittest.TestCase):
self.assertIs(vm.spawn_pending, operation == "spawning")
self.assertEqual(VmRecord.from_dict(vm.to_dict()), vm)
def test_new_state_uses_schema_two_and_rejects_future_versions(self) -> None:
self.assertEqual(State().schema_version, 2)
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)