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:
+80
-2
@@ -11,7 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
from uvm.cli import build_parser, main
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import PortRule, VmRecord, VmSpec
|
||||
from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
|
||||
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
|
||||
from uvm.lifecycle import ListedVm, OperationResult
|
||||
from uvm.server import _http_status_for, create_api, run_server
|
||||
@@ -22,6 +22,7 @@ def fake_application(*, api_token: str | None = None):
|
||||
app_name="uvm",
|
||||
default_vcpu=1,
|
||||
default_ram_mib=512,
|
||||
default_storage_mib=30 * 1024,
|
||||
default_ssh_user="root",
|
||||
api_token=api_token,
|
||||
api_tls_cert=None,
|
||||
@@ -145,6 +146,7 @@ if FASTAPI_AVAILABLE:
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec: VmSpec | None = None
|
||||
self.last_resize: dict | None = None
|
||||
self.observed_status = "running"
|
||||
self.policy_status = "enforced"
|
||||
self.vm = VmRecord(
|
||||
@@ -152,6 +154,7 @@ class FakeLifecycle:
|
||||
name="test-vm",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
storage_mib=30 * 1024,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
@@ -187,6 +190,7 @@ class FakeLifecycle:
|
||||
self.last_spec = spec
|
||||
self.vm.cpu = spec.cpu
|
||||
self.vm.ram_mib = spec.ram_mib
|
||||
self.vm.storage_mib = spec.storage_mib
|
||||
if spec.guest_ip is not None:
|
||||
self.vm.guest_ip = str(spec.guest_ip)
|
||||
self.vm.username = spec.username
|
||||
@@ -222,6 +226,16 @@ class FakeLifecycle:
|
||||
self.policy_status = "inactive"
|
||||
return self.vm
|
||||
|
||||
def resize(self, identifier: str, *, cpu=None, ram_mib=None, storage_mib=None, timeout=None) -> VmRecord:
|
||||
self.inspect(identifier)
|
||||
self.last_resize = dict(cpu=cpu, ram_mib=ram_mib, storage_mib=storage_mib, timeout=timeout)
|
||||
for key, value in (("cpu", cpu), ("ram_mib", ram_mib), ("storage_mib", storage_mib)):
|
||||
if value is not None:
|
||||
setattr(self.vm, key, value)
|
||||
self.vm.pending_resize = None
|
||||
self.vm.operation = None
|
||||
return self.start(identifier)
|
||||
|
||||
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
|
||||
self.inspect(identifier)
|
||||
return self.vm
|
||||
@@ -319,6 +333,7 @@ class ApiRouterTests(unittest.TestCase):
|
||||
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
|
||||
("POST", "/vms/test-vm/start", {}),
|
||||
("POST", "/vms/test-vm/restart", {}),
|
||||
("POST", "/vms/test-vm/resize", {"cpu": 2}),
|
||||
("POST", "/vms/test-vm/stop", {}),
|
||||
("DELETE", "/vms/test-vm", {}),
|
||||
("POST", "/vms/test-vm/ports", {}),
|
||||
@@ -376,6 +391,68 @@ class ApiRouterTests(unittest.TestCase):
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
||||
self.assertIsNone(self.lifecycle.last_spec.name)
|
||||
self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),))
|
||||
self.assertEqual(self.lifecycle.last_spec.storage_mib, 30 * 1024)
|
||||
self.assertEqual(response.json()["storage_mib"], 30 * 1024)
|
||||
|
||||
def test_create_accepts_custom_storage_and_rejects_capacities_below_minimum(self) -> None:
|
||||
for capacity, expected in ((50, 50 * 1024), ("50G", 50 * 1024), ("1T", 1024 * 1024)):
|
||||
with self.subTest(capacity=capacity):
|
||||
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.assertEqual(self.lifecycle.last_spec.storage_mib, expected)
|
||||
self.assertEqual(response.json()["storage_mib"], expected)
|
||||
self.lifecycle.last_spec = None
|
||||
for capacity in ("29G", 29, "30M", "bad", True, None, [], {}):
|
||||
with self.subTest(capacity=capacity):
|
||||
response = self.client.post("/vms", headers=self.headers, json={"storage": capacity})
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertIsNone(self.lifecycle.last_spec)
|
||||
|
||||
def test_resize_accepts_partial_resources_and_returns_the_same_vm_identity(self) -> None:
|
||||
before = self.lifecycle.vm.to_dict()
|
||||
response = self.client.post("/vms/test-vm/resize?timeout=0.5", headers=self.headers,
|
||||
json={"cpu": 2, "ram": "4G", "storage": "50G"})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(self.lifecycle.last_resize, {"cpu": 2, "ram_mib": 4096, "storage_mib": 51200, "timeout": 0.5})
|
||||
for field in ("id", "name", "guest_ip", "gateway", "mac", "username"):
|
||||
self.assertEqual(response.json()[field], before[field])
|
||||
self.assertEqual(response.json()["status"], "running")
|
||||
self.assertEqual(response.json()["policy_status"], "enforced")
|
||||
self.assertEqual(response.json()["storage_mib"], 51200)
|
||||
self.assertIsNone(response.json()["pending_resize"])
|
||||
self.assertNotIn("password", response.json())
|
||||
self.assertEqual(self.lifecycle.vm.password, before["password"])
|
||||
partial = self.client.post("/vms/10.42.0.2/resize", headers=self.headers, json={"ram": "2G"})
|
||||
self.assertEqual(partial.status_code, 200)
|
||||
self.assertEqual(self.lifecycle.last_resize, {"cpu": None, "ram_mib": 2048, "storage_mib": None, "timeout": None})
|
||||
self.assertEqual(partial.json()["storage_mib"], 51200)
|
||||
|
||||
def test_resize_rejects_invalid_or_non_resource_edits_before_lifecycle_calls(self) -> None:
|
||||
for payload in ({}, {"cpu": None}, {"cpu": 0}, {"cpu": True}, {"ram": "127M"},
|
||||
{"storage": "29G"}, {"storage": False}, {"storage": []},
|
||||
{"cpu": 2, "username": "new-user"}, {"cpu": 2, "password": "new-secret"},
|
||||
{"cpu": 2, "guest_ip": "10.42.0.9"}):
|
||||
with self.subTest(payload=payload):
|
||||
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json=payload)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertIsNone(self.lifecycle.last_resize)
|
||||
for timeout in ("-1", "nan", "inf"):
|
||||
response = self.client.post(f"/vms/vm-test/resize?timeout={timeout}", headers=self.headers, json={"cpu": 2})
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.lifecycle.last_resize)
|
||||
|
||||
def test_resize_errors_and_pending_resources_are_exposed_without_credentials(self) -> None:
|
||||
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("unknown process"), 409),
|
||||
(UvmError("filesystem does not fit"), 500)):
|
||||
with patch.object(self.lifecycle, "resize", side_effect=error):
|
||||
response = self.client.post("/vms/vm-test/resize", headers=self.headers, json={"storage": "30G"})
|
||||
self.assertEqual(response.status_code, expected)
|
||||
self.assertEqual(response.json(), {"error": {"message": str(error)}})
|
||||
target = VmResources(2, 4096, 51200)
|
||||
self.lifecycle.vm.pending_resize = target
|
||||
response = self.client.get("/vms/vm-test", headers=self.headers)
|
||||
self.assertEqual(response.json()["pending_resize"], target.to_dict())
|
||||
self.assertNotIn("guest-secret", response.text)
|
||||
|
||||
def test_create_accepts_valid_names(self) -> None:
|
||||
for name in (None, "a", "web-1", "a" * 63):
|
||||
@@ -454,7 +531,7 @@ class ApiRouterTests(unittest.TestCase):
|
||||
|
||||
def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None:
|
||||
expected_fields = {
|
||||
"id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username",
|
||||
"id", "name", "cpu", "ram_mib", "storage_mib", "pending_resize", "guest_ip", "gateway", "mac", "username",
|
||||
"status", "observed_status", "ports", "policy_status", "operation", "pid",
|
||||
"created_at", "updated_at", "last_error",
|
||||
}
|
||||
@@ -683,6 +760,7 @@ class ApiRouterTests(unittest.TestCase):
|
||||
("/vms/{identifier}/logs", "get"),
|
||||
("/vms/{vm_id}/start", "post"),
|
||||
("/vms/{vm_id}/restart", "post"),
|
||||
("/vms/{vm_id}/resize", "post"),
|
||||
("/vms/{vm_id}/stop", "post"),
|
||||
("/vms/{vm_id}", "delete"),
|
||||
("/vms/{vm_id}/ports", "post"),
|
||||
|
||||
Reference in New Issue
Block a user