__init__
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for the local uvm command-line application."""
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.cli import _run_command
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.images import GuestAssets
|
||||
|
||||
|
||||
def make_vm() -> VmRecord:
|
||||
return VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
username="admin",
|
||||
password="stored-secret",
|
||||
)
|
||||
|
||||
|
||||
class CliSshTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.vm = make_vm()
|
||||
self.application = SimpleNamespace(
|
||||
lifecycle=SimpleNamespace(find_for_ssh=lambda _identifier: self.vm)
|
||||
)
|
||||
|
||||
def test_ssh_uses_a_stable_vm_host_key_alias_by_default(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user="root",
|
||||
insecure_host_key=False,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
command = execvp.call_args.args[1]
|
||||
self.assertIn("HostKeyAlias=uvm-vm-test", command)
|
||||
self.assertIn("StrictHostKeyChecking=accept-new", command)
|
||||
self.assertNotIn("StrictHostKeyChecking=no", command)
|
||||
|
||||
def test_ssh_only_disables_verification_when_explicitly_requested(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user="root",
|
||||
insecure_host_key=True,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
command = execvp.call_args.args[1]
|
||||
self.assertIn("StrictHostKeyChecking=no", command)
|
||||
self.assertNotIn("HostKeyAlias=uvm-vm-test", command)
|
||||
|
||||
def test_ssh_uses_the_username_stored_for_the_vm(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
command="ssh",
|
||||
vm=self.vm.id,
|
||||
key=None,
|
||||
user=None,
|
||||
insecure_host_key=False,
|
||||
)
|
||||
|
||||
with patch("uvm.cli.os.execvp") as execvp:
|
||||
_run_command(self.application, args)
|
||||
|
||||
self.assertEqual(execvp.call_args.args[1][-1], "admin@10.42.0.2")
|
||||
|
||||
|
||||
class CliCreateTests(unittest.TestCase):
|
||||
def test_create_passes_credentials_without_printing_the_password(self) -> None:
|
||||
captured = None
|
||||
|
||||
def create(spec):
|
||||
nonlocal captured
|
||||
captured = spec
|
||||
vm = make_vm()
|
||||
vm.username = spec.username
|
||||
vm.password = spec.password
|
||||
return vm
|
||||
|
||||
application = SimpleNamespace(lifecycle=SimpleNamespace(create=create))
|
||||
args = argparse.Namespace(
|
||||
command="create",
|
||||
cpu="1",
|
||||
ram="512",
|
||||
host_ip=None,
|
||||
username="admin",
|
||||
password="custom-secret",
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
result = _run_command(application, args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured.username, "admin")
|
||||
self.assertEqual(captured.password, "custom-secret")
|
||||
self.assertNotIn("custom-secret", output.getvalue())
|
||||
|
||||
|
||||
class CliInstallTests(unittest.TestCase):
|
||||
def test_install_next_step_does_not_assume_an_installed_console_command(self) -> None:
|
||||
state_store = SimpleNamespace(initialize=lambda: None)
|
||||
application = SimpleNamespace(
|
||||
settings=SimpleNamespace(allow_unverified_downloads=False),
|
||||
installer=SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
GuestAssets(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
),
|
||||
state_store=state_store,
|
||||
)
|
||||
args = argparse.Namespace(command="install", force=False)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
result = _run_command(application, args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertIn("Run your UVM command with: create --cpu 1 --ram 512", output.getvalue())
|
||||
self.assertNotIn("sudo uvm create", output.getvalue())
|
||||
|
||||
def test_unverified_install_reminds_the_operator_to_keep_the_opt_out(self) -> None:
|
||||
application = SimpleNamespace(
|
||||
settings=SimpleNamespace(allow_unverified_downloads=True),
|
||||
installer=SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
GuestAssets(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
),
|
||||
state_store=SimpleNamespace(initialize=lambda: None),
|
||||
)
|
||||
args = argparse.Namespace(command="install", force=False)
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
_run_command(application, args)
|
||||
|
||||
self.assertIn("UVM_ALLOW_UNVERIFIED_DOWNLOADS=1", output.getvalue())
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.firecracker.api import FirecrackerClient
|
||||
from uvm.firecracker.config import build_config, vcpu_count
|
||||
|
||||
|
||||
class RecordingFirecrackerClient(FirecrackerClient):
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, object]] = []
|
||||
|
||||
def _request(self, method: str, path: str, body: object) -> None:
|
||||
self.calls.append((method, path, body))
|
||||
|
||||
|
||||
class FirecrackerConfigTests(unittest.TestCase):
|
||||
def test_builds_a_writable_vm_disk_configuration(self) -> None:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=0.5,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
disk="/vm/rootfs.ext4",
|
||||
)
|
||||
config = build_config(
|
||||
Settings(),
|
||||
vm,
|
||||
Path("/images/vmlinux"),
|
||||
Path("/vm/rootfs.ext4"),
|
||||
)
|
||||
|
||||
self.assertEqual(vcpu_count(0.5), 1)
|
||||
self.assertEqual(config["machine-config"]["mem_size_mib"], 512)
|
||||
self.assertEqual(config["drives"][0]["path_on_host"], "/vm/rootfs.ext4")
|
||||
self.assertIn("ip=10.42.0.2::10.42.0.1", config["boot-source"]["boot_args"])
|
||||
self.assertNotIn("password", str(config))
|
||||
|
||||
def test_configures_firecracker_in_required_order_before_start(self) -> None:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
disk="/vm/rootfs.ext4",
|
||||
)
|
||||
config = build_config(
|
||||
Settings(), vm, Path("/images/vmlinux"), Path("/vm/rootfs.ext4")
|
||||
)
|
||||
client = RecordingFirecrackerClient()
|
||||
|
||||
client.configure_and_start(config)
|
||||
|
||||
self.assertEqual(
|
||||
[path for _method, path, _body in client.calls],
|
||||
[
|
||||
"/machine-config",
|
||||
"/boot-source",
|
||||
"/drives/rootfs",
|
||||
"/network-interfaces/eth0",
|
||||
"/actions",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
client.calls[0][2],
|
||||
{"vcpu_count": 1, "mem_size_mib": 512, "smt": False},
|
||||
)
|
||||
self.assertEqual(client.calls[-1][2], {"action_type": "InstanceStart"})
|
||||
|
||||
def test_uses_the_configured_network_netmask_in_guest_boot_arguments(self) -> None:
|
||||
settings = Settings(
|
||||
network=IPv4Network("10.50.0.0/16"),
|
||||
gateway=IPv4Address("10.50.0.1"),
|
||||
)
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.50.0.2",
|
||||
gateway="10.50.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
)
|
||||
|
||||
config = build_config(settings, vm, Path("/images/vmlinux"), Path("/vm/rootfs.ext4"))
|
||||
|
||||
self.assertIn("ip=10.50.0.2::10.50.0.1:255.255.0.0", config["boot-source"]["boot_args"])
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.errors import UvmError
|
||||
from uvm.images import (
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY,
|
||||
_enable_ssh_password_authentication,
|
||||
_set_shadow_password,
|
||||
_without_firecracker_demo_key,
|
||||
ImageStore,
|
||||
)
|
||||
from uvm.integrity import write_manifest
|
||||
from uvm.system import CommandResult
|
||||
|
||||
|
||||
class PasswordHashRunner:
|
||||
def __init__(self) -> None:
|
||||
self.command: tuple[str, ...] | None = None
|
||||
self.options: dict[str, object] = {}
|
||||
|
||||
def run(self, command, **options) -> CommandResult:
|
||||
self.command = tuple(str(part) for part in command)
|
||||
self.options = options
|
||||
return CommandResult(self.command, 0, stdout="$6$salt$password-hash\n")
|
||||
|
||||
|
||||
class ImageStoreTests(unittest.TestCase):
|
||||
def test_uses_the_persisted_install_manifest_when_strict_mode_is_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = Settings(base=Path(temporary_directory), allow_unverified_downloads=False)
|
||||
settings.images_dir.mkdir(parents=True)
|
||||
settings.kernel_image.write_bytes(b"kernel")
|
||||
settings.rootfs_image.write_bytes(b"rootfs")
|
||||
write_manifest(
|
||||
settings.integrity_manifest_path,
|
||||
{
|
||||
"kernel": hashlib.sha256(b"kernel").hexdigest(),
|
||||
"rootfs": hashlib.sha256(b"rootfs").hexdigest(),
|
||||
"firecracker": "0" * 64,
|
||||
"jailer": "1" * 64,
|
||||
},
|
||||
verified=True,
|
||||
)
|
||||
|
||||
assets = ImageStore(settings).installed_assets()
|
||||
|
||||
self.assertEqual(assets.kernel.name, "vmlinux")
|
||||
self.assertEqual(assets.rootfs.name, "ubuntu.ext4")
|
||||
|
||||
def test_hashes_password_through_stdin_without_putting_it_in_argv(self) -> None:
|
||||
runner = PasswordHashRunner()
|
||||
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
|
||||
|
||||
password_hash = store._password_hash("secret-value")
|
||||
|
||||
self.assertEqual(password_hash, "$6$salt$password-hash")
|
||||
self.assertEqual(runner.command, ("openssl", "passwd", "-6", "-stdin"))
|
||||
self.assertNotIn("secret-value", runner.command)
|
||||
self.assertEqual(runner.options["input_text"], "secret-value\n")
|
||||
self.assertTrue(runner.options["sensitive"])
|
||||
|
||||
def test_updates_only_the_requested_shadow_entry(self) -> None:
|
||||
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
|
||||
|
||||
updated = _set_shadow_password(original, "root", "$6$salt$hash")
|
||||
|
||||
self.assertEqual(
|
||||
updated,
|
||||
"root:$6$salt$hash:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n",
|
||||
)
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
_set_shadow_password(original, "missing", "$6$salt$hash")
|
||||
|
||||
def test_enables_root_password_login_before_existing_sshd_settings(self) -> None:
|
||||
updated = _enable_ssh_password_authentication(
|
||||
"PasswordAuthentication no\nPermitRootLogin prohibit-password\n",
|
||||
"root",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
updated.startswith(
|
||||
"# Managed by uvm\nPasswordAuthentication yes\nPermitRootLogin yes\n"
|
||||
)
|
||||
)
|
||||
|
||||
def test_removes_only_the_public_firecracker_demo_key(self) -> None:
|
||||
own_key = "ssh-ed25519 AAAA-own-key developer@example"
|
||||
|
||||
updated = _without_firecracker_demo_key(
|
||||
f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\n{own_key}\n"
|
||||
)
|
||||
|
||||
self.assertEqual(updated, f"{own_key}\n")
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.errors import UvmError
|
||||
from uvm.integrity import load_manifest, verify_file, write_manifest
|
||||
|
||||
|
||||
class IntegrityTests(unittest.TestCase):
|
||||
def test_verifies_a_matching_sha256(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"trusted artifact")
|
||||
digest = hashlib.sha256(b"trusted artifact").hexdigest()
|
||||
|
||||
verify_file(
|
||||
artifact,
|
||||
digest,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
|
||||
def test_rejects_missing_or_mismatched_checksums_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"artifact")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
verify_file(
|
||||
artifact,
|
||||
None,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
with self.assertRaises(UvmError):
|
||||
verify_file(
|
||||
artifact,
|
||||
"0" * 64,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=False,
|
||||
)
|
||||
|
||||
def test_allows_an_explicit_local_development_opt_out(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
artifact = Path(temporary_directory) / "artifact"
|
||||
artifact.write_bytes(b"artifact")
|
||||
|
||||
verify_file(
|
||||
artifact,
|
||||
None,
|
||||
"artifact",
|
||||
"UVM_ARTIFACT_SHA256",
|
||||
allow_unverified=True,
|
||||
)
|
||||
|
||||
def test_unverified_manifest_is_not_promoted_to_a_verified_install(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
manifest_path = Path(temporary_directory) / "integrity.json"
|
||||
write_manifest(manifest_path, {"kernel": "0" * 64}, verified=False)
|
||||
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
self.assertFalse(manifest.verified)
|
||||
self.assertEqual(manifest.checksums["kernel"], "0" * 64)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmSpec
|
||||
from uvm.errors import FirecrackerError, UvmError
|
||||
from uvm.firecracker.process import ProcessInfo
|
||||
from uvm.images import ImageStore
|
||||
from uvm.lifecycle import LifecycleService
|
||||
from uvm.state import StateStore
|
||||
|
||||
|
||||
class FakeNetwork:
|
||||
def __init__(self) -> None:
|
||||
self.created_taps: list[str] = []
|
||||
self.deleted_taps: list[str] = []
|
||||
|
||||
def allocate_ip(self, _vms, requested: IPv4Address | None) -> IPv4Address:
|
||||
return requested or IPv4Address("10.42.0.2")
|
||||
|
||||
@staticmethod
|
||||
def mac_for(index: int) -> str:
|
||||
return f"02:fc:00:00:00:{index:02x}"
|
||||
|
||||
@staticmethod
|
||||
def tap_name(vm_id: str) -> str:
|
||||
return f"uvm-{vm_id[-8:]}"[:15]
|
||||
|
||||
def ensure_bridge(self) -> None:
|
||||
return None
|
||||
|
||||
def create_tap(self, name: str) -> None:
|
||||
self.created_taps.append(name)
|
||||
|
||||
def delete_tap(self, name: str) -> None:
|
||||
self.deleted_taps.append(name)
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self) -> None:
|
||||
self.started = False
|
||||
self.terminated: list[int | None] = []
|
||||
self.alive = True
|
||||
|
||||
def start(self, _vm) -> ProcessInfo:
|
||||
self.started = True
|
||||
return ProcessInfo(pid=12345, start_time="42")
|
||||
|
||||
def is_alive(self, _vm) -> bool:
|
||||
return self.alive
|
||||
|
||||
def terminate(self, vm) -> None:
|
||||
self.terminated.append(vm.pid)
|
||||
self.alive = False
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, should_fail: bool = False) -> None:
|
||||
self.should_fail = should_fail
|
||||
self.config = None
|
||||
|
||||
def configure_and_start(self, config) -> None:
|
||||
self.config = config
|
||||
if self.should_fail:
|
||||
raise FirecrackerError("simulated API failure")
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||
base = Path(self._temporary_directory.name) / "uvm"
|
||||
self.settings = replace(Settings(), base=base, allow_unverified_downloads=True)
|
||||
self.settings.bin_dir.mkdir(parents=True)
|
||||
self.settings.firecracker_binary.touch()
|
||||
self.settings.images_dir.mkdir(parents=True)
|
||||
self.settings.kernel_image.write_bytes(b"kernel")
|
||||
self.settings.rootfs_image.write_bytes(b"rootfs")
|
||||
self.store = StateStore(self.settings)
|
||||
self.images = ImageStore(self.settings)
|
||||
self.images.provision_credentials = Mock() # type: ignore[method-assign]
|
||||
self.network = FakeNetwork()
|
||||
self.process = FakeProcess()
|
||||
self.client = FakeClient()
|
||||
self.service = LifecycleService(
|
||||
self.settings,
|
||||
self.store,
|
||||
self.images,
|
||||
self.network, # type: ignore[arg-type]
|
||||
self.process, # type: ignore[arg-type]
|
||||
client_factory=lambda _socket, _timeout: self.client, # type: ignore[arg-type]
|
||||
)
|
||||
self._root_patch = patch("uvm.lifecycle.require_root")
|
||||
self._kvm_patch = patch("uvm.lifecycle.check_kvm")
|
||||
self._root_patch.start()
|
||||
self._kvm_patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._kvm_patch.stop()
|
||||
self._root_patch.stop()
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def test_create_uses_a_private_disk_and_persists_running_state(self) -> None:
|
||||
vm = self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(vm.status, "running")
|
||||
self.assertNotEqual(Path(vm.disk), self.settings.rootfs_image)
|
||||
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
|
||||
self.assertEqual(vm.username, "root")
|
||||
self.assertEqual(vm.password, "root")
|
||||
self.assertEqual(self.store.load().vms[vm.id].pid, 12345)
|
||||
self.assertEqual(self.network.created_taps, [vm.tap])
|
||||
self.assertEqual(self.client.config["drives"][0]["path_on_host"], vm.disk)
|
||||
self.images.provision_credentials.assert_called_once_with(
|
||||
Path(vm.disk),
|
||||
"root",
|
||||
"root",
|
||||
)
|
||||
|
||||
def test_create_persists_and_provisions_custom_guest_credentials(self) -> None:
|
||||
vm = self.service.create(
|
||||
VmSpec(cpu=1, ram_mib=512, username="admin", password="secret-value")
|
||||
)
|
||||
|
||||
persisted = self.store.load().vms[vm.id]
|
||||
self.assertEqual(persisted.username, "admin")
|
||||
self.assertEqual(persisted.password, "secret-value")
|
||||
self.images.provision_credentials.assert_called_once_with(
|
||||
Path(vm.disk),
|
||||
"admin",
|
||||
"secret-value",
|
||||
)
|
||||
|
||||
def test_create_rolls_back_when_firecracker_configuration_fails(self) -> None:
|
||||
self.client.should_fail = True
|
||||
|
||||
with self.assertRaises(FirecrackerError):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(len(self.network.deleted_taps), 1)
|
||||
self.assertEqual(self.process.terminated, [12345])
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_create_rolls_back_before_network_setup_when_provisioning_fails(self) -> None:
|
||||
self.images.provision_credentials.side_effect = UvmError("invalid guest image")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(self.network.created_taps, [])
|
||||
self.assertFalse(self.process.started)
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_create_rolls_back_when_interrupted(self) -> None:
|
||||
def interrupting_create_tap(_name: str) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
self.network.create_tap = interrupting_create_tap
|
||||
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
|
||||
|
||||
def test_stop_keeps_disk_and_destroy_releases_state(self) -> None:
|
||||
vm = self.service.create(VmSpec(cpu=1, ram_mib=512))
|
||||
disk = Path(vm.disk)
|
||||
|
||||
stopped = self.service.stop(vm.id)
|
||||
self.assertEqual(stopped.status, "stopped")
|
||||
self.assertTrue(disk.exists())
|
||||
self.assertIsNone(stopped.pid)
|
||||
|
||||
destroyed = self.service.destroy(vm.id)
|
||||
self.assertEqual(destroyed.id, vm.id)
|
||||
self.assertFalse(self.settings.vm_dir(vm.id).exists())
|
||||
self.assertEqual(self.store.load().vms, {})
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.errors import UvmError, ValidationError
|
||||
from uvm.network import NetworkManager
|
||||
from uvm.system import CommandResult
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
self.existing_links: set[str] = set()
|
||||
self.link_details: dict[str, str] = {}
|
||||
|
||||
def run(self, command, *, check=True, capture=False, timeout=None) -> CommandResult:
|
||||
del check, capture, timeout
|
||||
args = tuple(str(part) for part in command)
|
||||
self.calls.append(args)
|
||||
if args[:3] == ("ip", "link", "show") and args[3] in self.existing_links:
|
||||
return CommandResult(args=args, returncode=0)
|
||||
if args[:6] == ("ip", "-j", "-d", "link", "show", "dev"):
|
||||
return CommandResult(args=args, returncode=0, stdout=self.link_details[args[6]])
|
||||
if args[:4] == ("ip", "link", "show", "uvm0"):
|
||||
return CommandResult(args=args, returncode=1)
|
||||
if args == ("ip", "route", "show", "default"):
|
||||
return CommandResult(args=args, returncode=0, stdout="default via 192.0.2.1 dev eth0\n")
|
||||
if len(args) > 3 and args[0] == "iptables" and "-C" in args:
|
||||
return CommandResult(args=args, returncode=1)
|
||||
return CommandResult(args=args, returncode=0)
|
||||
|
||||
|
||||
class NetworkManagerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.runner = FakeRunner()
|
||||
self.network = NetworkManager(Settings(), runner=self.runner) # type: ignore[arg-type]
|
||||
|
||||
def test_allocates_first_available_guest_address(self) -> None:
|
||||
self.assertEqual(self.network.allocate_ip([], None), IPv4Address("10.42.0.2"))
|
||||
|
||||
def test_rejects_gateway_and_used_requested_addresses(self) -> None:
|
||||
vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([vm], IPv4Address("10.42.0.1"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([vm], IPv4Address("10.42.0.2"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([], IPv4Address("10.42.0.0"))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.network.allocate_ip([], IPv4Address("10.42.0.255"))
|
||||
|
||||
def test_mac_and_tap_names_are_bounded_and_deterministic(self) -> None:
|
||||
self.assertEqual(NetworkManager.mac_for(1), "02:fc:00:00:00:01")
|
||||
tap = NetworkManager.tap_name("vm-0123456789abcdef")
|
||||
self.assertLessEqual(len(tap), 15)
|
||||
self.assertTrue(tap.startswith("uvm-"))
|
||||
|
||||
def test_bridge_setup_adds_a_missing_bridge_and_one_nat_rule(self) -> None:
|
||||
self.network.ensure_bridge()
|
||||
|
||||
self.assertIn(("ip", "link", "add", "uvm0", "type", "bridge"), self.runner.calls)
|
||||
self.assertIn(
|
||||
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
||||
self.runner.calls,
|
||||
)
|
||||
self.assertIn(
|
||||
(
|
||||
"iptables",
|
||||
"-A",
|
||||
"FORWARD",
|
||||
"-i",
|
||||
"uvm0",
|
||||
"-o",
|
||||
"eth0",
|
||||
"-s",
|
||||
"10.42.0.0/24",
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
),
|
||||
self.runner.calls,
|
||||
)
|
||||
checks = [
|
||||
call
|
||||
for call in self.runner.calls
|
||||
if call[:5] == ("iptables", "-t", "nat", "-C", "POSTROUTING")
|
||||
]
|
||||
self.assertEqual(len(checks), 1)
|
||||
self.assertIn(
|
||||
(
|
||||
"iptables",
|
||||
"-t",
|
||||
"nat",
|
||||
"-A",
|
||||
"POSTROUTING",
|
||||
"-s",
|
||||
"10.42.0.0/24",
|
||||
"-o",
|
||||
"eth0",
|
||||
"-j",
|
||||
"MASQUERADE",
|
||||
),
|
||||
self.runner.calls,
|
||||
)
|
||||
|
||||
def test_bridge_and_guest_network_support_non_default_prefixes(self) -> None:
|
||||
settings = Settings(
|
||||
network=IPv4Network("10.50.0.0/16"),
|
||||
gateway=IPv4Address("10.50.0.1"),
|
||||
)
|
||||
runner = FakeRunner()
|
||||
NetworkManager(settings, runner=runner).ensure_bridge() # type: ignore[arg-type]
|
||||
|
||||
self.assertIn(
|
||||
("ip", "addr", "replace", "10.50.0.1/16", "dev", "uvm0"),
|
||||
runner.calls,
|
||||
)
|
||||
|
||||
def test_refuses_to_adopt_an_existing_tap_device(self) -> None:
|
||||
self.runner.existing_links.add("uvm-existing")
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.network.create_tap("uvm-existing")
|
||||
|
||||
self.assertNotIn(
|
||||
("ip", "tuntap", "add", "dev", "uvm-existing", "mode", "tap"),
|
||||
self.runner.calls,
|
||||
)
|
||||
|
||||
def test_refuses_to_reconfigure_an_existing_non_bridge_interface(self) -> None:
|
||||
self.runner.existing_links.add("uvm0")
|
||||
self.runner.link_details["uvm0"] = '[{"linkinfo": {"info_kind": "dummy"}}]'
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
self.network.ensure_bridge()
|
||||
|
||||
self.assertNotIn(
|
||||
("ip", "addr", "replace", "10.42.0.1/24", "dev", "uvm0"),
|
||||
self.runner.calls,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.errors import FirecrackerError
|
||||
from uvm.firecracker.process import FirecrackerProcessManager
|
||||
|
||||
|
||||
def make_vm(pid: int | None, start_time: str | None) -> VmRecord:
|
||||
return VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/uvm-missing.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
pid=pid,
|
||||
process_start_time=start_time,
|
||||
)
|
||||
|
||||
|
||||
class FirecrackerProcessSafetyTests(unittest.TestCase):
|
||||
def test_refuses_to_signal_an_unidentified_legacy_pid(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=12345, start_time=None)
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(FirecrackerError):
|
||||
manager.terminate(vm)
|
||||
|
||||
kill.assert_called_once_with(12345, 0)
|
||||
|
||||
def test_allows_cleanup_when_an_unidentified_legacy_pid_is_already_gone(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=12345, start_time=None)
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill", side_effect=ProcessLookupError):
|
||||
manager.terminate(vm)
|
||||
|
||||
def test_rejects_non_positive_pids_without_signaling(self) -> None:
|
||||
manager = FirecrackerProcessManager(Settings())
|
||||
vm = make_vm(pid=0, start_time="42")
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(FirecrackerError):
|
||||
manager.terminate(vm)
|
||||
|
||||
kill.assert_not_called()
|
||||
|
||||
def test_interrupt_during_startup_terminates_the_child_process(self) -> None:
|
||||
class FakePopen:
|
||||
pid = 12345
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
settings = Settings(base=Path(temporary_directory), terminate_timeout_s=0)
|
||||
manager = FirecrackerProcessManager(settings, popen=lambda *_args, **_kwargs: FakePopen())
|
||||
manager._wait_for_socket = lambda *_args: (_ for _ in ()).throw(KeyboardInterrupt)
|
||||
vm = make_vm(pid=None, start_time=None)
|
||||
vm.log = str(Path(temporary_directory) / "firecracker.log")
|
||||
vm.socket = str(Path(temporary_directory) / "firecracker.sock")
|
||||
|
||||
with patch("uvm.firecracker.process.os.kill") as kill:
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
manager.start(vm)
|
||||
|
||||
self.assertEqual(kill.call_count, 2)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.cli import build_parser, main
|
||||
from uvm.config import Settings
|
||||
from uvm.errors import ConfigurationError, UvmError
|
||||
from uvm.server import create_api, run_server
|
||||
|
||||
|
||||
def fake_application(*, api_token: str | None = None):
|
||||
settings = SimpleNamespace(
|
||||
app_name="uvm",
|
||||
default_vcpu=1,
|
||||
default_ram_mib=512,
|
||||
default_ssh_user="root",
|
||||
api_token=api_token,
|
||||
api_tls_cert=None,
|
||||
api_tls_key=None,
|
||||
)
|
||||
return SimpleNamespace(settings=settings)
|
||||
|
||||
|
||||
class ServerCliTests(unittest.TestCase):
|
||||
def test_parser_accepts_the_requested_serve_invocation(self) -> None:
|
||||
parser = build_parser(fake_application())
|
||||
|
||||
args = parser.parse_args(["--serve", "--port", "8123", "--host", "127.0.0.1"])
|
||||
|
||||
self.assertTrue(args.serve)
|
||||
self.assertEqual(args.port, 8123)
|
||||
self.assertEqual(args.host, "127.0.0.1")
|
||||
self.assertIsNone(args.command)
|
||||
|
||||
def test_parser_rejects_an_invalid_server_port(self) -> None:
|
||||
parser = build_parser(fake_application())
|
||||
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--serve", "--port", "70000"])
|
||||
|
||||
def test_create_parser_defaults_guest_credentials_to_root(self) -> None:
|
||||
args = build_parser(fake_application()).parse_args(["create"])
|
||||
|
||||
self.assertEqual(args.username, "root")
|
||||
self.assertEqual(args.password, "root")
|
||||
|
||||
def test_main_delegates_serve_mode_to_the_server_launcher(self) -> None:
|
||||
application = fake_application()
|
||||
with (
|
||||
patch("uvm.cli.build_application", return_value=application),
|
||||
patch("uvm.server.run_server") as run_server_mock,
|
||||
):
|
||||
result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"])
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
run_server_mock.assert_called_once_with(application, host="127.0.0.1", port=8123)
|
||||
|
||||
def test_server_requires_an_api_token_even_on_loopback(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(fake_application(api_token=None), host="127.0.0.1", port=8000)
|
||||
|
||||
def test_non_loopback_server_requires_tls(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(fake_application(api_token="test-token"), host="0.0.0.0", port=8000)
|
||||
|
||||
def test_factory_requires_an_explicit_host(self) -> None:
|
||||
with self.assertRaises(UvmError):
|
||||
create_api(fake_application(api_token="test-token"))
|
||||
|
||||
def test_invalid_tls_material_is_rejected_before_uvicorn_starts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
certificate = Path(temporary_directory) / "cert.pem"
|
||||
key = Path(temporary_directory) / "key.pem"
|
||||
certificate.touch()
|
||||
key.touch()
|
||||
application = fake_application(api_token="test-token")
|
||||
application.settings.api_tls_cert = certificate
|
||||
application.settings.api_tls_key = key
|
||||
|
||||
with self.assertRaises(UvmError):
|
||||
run_server(application, host="127.0.0.1", port=8000)
|
||||
|
||||
def test_non_loopback_server_uses_configured_tls(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
certificate = Path(temporary_directory) / "cert.pem"
|
||||
key = Path(temporary_directory) / "key.pem"
|
||||
certificate.touch()
|
||||
key.touch()
|
||||
application = fake_application(api_token="test-token")
|
||||
application.settings.api_tls_cert = certificate
|
||||
application.settings.api_tls_key = key
|
||||
fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None)
|
||||
|
||||
with (
|
||||
patch("uvm.server._validate_server_settings"),
|
||||
patch("uvm.server.create_api", return_value=object()),
|
||||
patch.dict(sys.modules, {"uvicorn": fake_uvicorn}),
|
||||
patch.object(fake_uvicorn, "run") as run_mock,
|
||||
):
|
||||
run_server(application, host="0.0.0.0", port=8443)
|
||||
|
||||
run_mock.assert_called_once()
|
||||
self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate))
|
||||
self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key))
|
||||
|
||||
def test_api_token_rejects_non_ascii_or_whitespace(self) -> None:
|
||||
for token in ("s\u00e9cret", "contains space", ""):
|
||||
with self.subTest(token=token):
|
||||
with self.assertRaises(ConfigurationError):
|
||||
Settings(api_token=token)
|
||||
|
||||
|
||||
FASTAPI_AVAILABLE = (
|
||||
importlib.util.find_spec("fastapi") is not None
|
||||
and importlib.util.find_spec("httpx") is not None
|
||||
)
|
||||
|
||||
if FASTAPI_AVAILABLE:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.lifecycle import ListedVm
|
||||
from uvm.server import create_api
|
||||
|
||||
class FakeLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.last_spec = None
|
||||
self.vm = VmRecord(
|
||||
id="vm-test",
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
status="running",
|
||||
)
|
||||
|
||||
def list_vms(self) -> list[ListedVm]:
|
||||
return [ListedVm(vm=self.vm, observed_status=self.vm.status)]
|
||||
|
||||
def create(self, spec):
|
||||
self.last_spec = spec
|
||||
self.vm.username = spec.username
|
||||
self.vm.password = spec.password
|
||||
self.vm.status = "running"
|
||||
return self.vm
|
||||
|
||||
def stop(self, _vm_id: str):
|
||||
self.vm.status = "stopped"
|
||||
return self.vm
|
||||
|
||||
def destroy(self, _vm_id: str):
|
||||
return self.vm
|
||||
|
||||
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI is not installed")
|
||||
class ApiRouterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
application = fake_application(api_token="test-token")
|
||||
self.lifecycle = FakeLifecycle()
|
||||
application.lifecycle = self.lifecycle
|
||||
application.installer = SimpleNamespace(
|
||||
install=lambda **_kwargs: (
|
||||
Path("/tmp/firecracker"),
|
||||
SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")),
|
||||
)
|
||||
)
|
||||
application.state_store = SimpleNamespace(initialize=lambda: None)
|
||||
self.client = TestClient(create_api(application, host="127.0.0.1"))
|
||||
|
||||
def test_health_is_available_without_credentials(self) -> None:
|
||||
response = self.client.get("/health")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
|
||||
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
|
||||
unauthorized = self.client.get("/vms")
|
||||
authorized = self.client.get("/vms", headers={"X-UVM-Token": "test-token"})
|
||||
|
||||
self.assertEqual(unauthorized.status_code, 401)
|
||||
self.assertEqual(authorized.status_code, 200)
|
||||
self.assertEqual(authorized.json()[0]["id"], "vm-test")
|
||||
self.assertNotIn("password", authorized.json()[0])
|
||||
|
||||
def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None:
|
||||
headers = {"X-UVM-Token": "test-token"}
|
||||
created = self.client.post(
|
||||
"/vms",
|
||||
headers=headers,
|
||||
json={
|
||||
"cpu": 1,
|
||||
"ram": "512",
|
||||
"username": "root",
|
||||
"password": "api-secret",
|
||||
},
|
||||
)
|
||||
detail = self.client.get("/vms/vm-test", headers=headers)
|
||||
stopped = self.client.post("/vms/vm-test/stop", headers=headers)
|
||||
destroyed = self.client.delete("/vms/vm-test", headers=headers)
|
||||
invalid = self.client.post("/vms", headers=headers, json={"forse": True})
|
||||
|
||||
self.assertEqual(created.status_code, 201)
|
||||
self.assertEqual(created.json()["username"], "root")
|
||||
self.assertNotIn("password", created.json())
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "api-secret")
|
||||
self.assertEqual(detail.status_code, 200)
|
||||
self.assertEqual(stopped.json()["status"], "stopped")
|
||||
self.assertEqual(destroyed.json()["status"], "terminated")
|
||||
self.assertEqual(invalid.status_code, 422)
|
||||
|
||||
def test_vm_create_defaults_guest_credentials_to_root(self) -> None:
|
||||
response = self.client.post(
|
||||
"/vms",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"cpu": 1, "ram": "512"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self.lifecycle.last_spec.username, "root")
|
||||
self.assertEqual(self.lifecycle.last_spec.password, "root")
|
||||
|
||||
def test_install_route_uses_the_existing_installer(self) -> None:
|
||||
response = self.client.post(
|
||||
"/install",
|
||||
headers={"X-UVM-Token": "test-token"},
|
||||
json={"force": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.domain import VmRecord
|
||||
from uvm.state import StateStore
|
||||
|
||||
|
||||
def make_vm(vm_id: str = "vm-test") -> VmRecord:
|
||||
return VmRecord(
|
||||
id=vm_id,
|
||||
cpu=1,
|
||||
ram_mib=512,
|
||||
guest_ip="10.42.0.2",
|
||||
gateway="10.42.0.1",
|
||||
tap="uvm-test",
|
||||
mac="02:fc:00:00:00:01",
|
||||
socket="/tmp/firecracker.sock",
|
||||
config="/tmp/config.json",
|
||||
log="/tmp/firecracker.log",
|
||||
disk="/tmp/rootfs.ext4",
|
||||
username="admin",
|
||||
password="stored-secret",
|
||||
)
|
||||
|
||||
|
||||
class StateStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.base = Path(self._temporary_directory.name) / "uvm"
|
||||
self.settings = replace(Settings(), base=self.base)
|
||||
self.store = StateStore(self.settings)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def test_transaction_persists_a_vm_atomically(self) -> None:
|
||||
self.store.initialize()
|
||||
with self.store.transaction() as state:
|
||||
state.vms["vm-test"] = make_vm()
|
||||
state.next_mac_index = 2
|
||||
|
||||
loaded = self.store.load()
|
||||
self.assertEqual(loaded.vms["vm-test"].disk, "/tmp/rootfs.ext4")
|
||||
self.assertEqual(loaded.vms["vm-test"].username, "admin")
|
||||
self.assertEqual(loaded.vms["vm-test"].password, "stored-secret")
|
||||
self.assertNotIn("stored-secret", repr(loaded.vms["vm-test"]))
|
||||
self.assertEqual(loaded.next_mac_index, 2)
|
||||
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
||||
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["username"], "admin")
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["password"], "stored-secret")
|
||||
|
||||
def test_legacy_state_is_loaded_and_migrated_on_next_write(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
legacy = {"vms": {"vm-test": make_vm().to_dict()}}
|
||||
legacy["vms"]["vm-test"].pop("disk")
|
||||
legacy["vms"]["vm-test"].pop("username")
|
||||
legacy["vms"]["vm-test"].pop("password")
|
||||
legacy["vms"]["vm-test"].pop("updated_at")
|
||||
self.settings.state_path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
|
||||
loaded = self.store.load()
|
||||
self.assertEqual(loaded.vms["vm-test"].disk, "")
|
||||
self.assertEqual(loaded.vms["vm-test"].username, "root")
|
||||
self.assertIsNone(loaded.vms["vm-test"].password)
|
||||
self.assertEqual(loaded.next_mac_index, 2)
|
||||
|
||||
with self.store.transaction() as state:
|
||||
state.vms["vm-test"].status = "stopped"
|
||||
|
||||
persisted = json.loads(self.settings.state_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["schema_version"], 1)
|
||||
self.assertEqual(persisted["next_mac_index"], 2)
|
||||
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
||||
|
||||
def test_initialize_protects_registry_credentials(self) -> None:
|
||||
self.base.mkdir(parents=True)
|
||||
self.settings.state_path.write_text('{"vms": {}}', encoding="utf-8")
|
||||
self.settings.state_path.chmod(0o644)
|
||||
|
||||
self.store.initialize()
|
||||
|
||||
self.assertEqual(stat.S_IMODE(self.settings.state_path.stat().st_mode), 0o600)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.errors import CommandError
|
||||
from uvm.system import CommandRunner
|
||||
|
||||
|
||||
class CommandRunnerTests(unittest.TestCase):
|
||||
def test_passes_sensitive_input_over_stdin_without_logging_it(self) -> None:
|
||||
emitted: list[str] = []
|
||||
completed = SimpleNamespace(returncode=0, stdout="result\n", stderr="")
|
||||
|
||||
with patch("uvm.system.subprocess.run", return_value=completed) as run:
|
||||
result = CommandRunner(emit=emitted.append).run(
|
||||
("credential-tool", "--stdin"),
|
||||
capture=True,
|
||||
input_text="secret-value\n",
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.stdout, "result\n")
|
||||
self.assertEqual(run.call_args.kwargs["input"], "secret-value\n")
|
||||
self.assertNotIn("secret-value", emitted[0])
|
||||
|
||||
def test_redacts_sensitive_command_output_from_errors(self) -> None:
|
||||
completed = SimpleNamespace(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="failure mentioning secret-value",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("uvm.system.subprocess.run", return_value=completed),
|
||||
self.assertRaises(CommandError) as raised,
|
||||
):
|
||||
CommandRunner(emit=None).run(
|
||||
("credential-tool", "--stdin"),
|
||||
capture=True,
|
||||
input_text="secret-value\n",
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
self.assertNotIn("secret-value", str(raised.exception))
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from uvm.errors import ValidationError
|
||||
from uvm.validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def test_parse_ram_uses_mib_by_default(self) -> None:
|
||||
self.assertEqual(parse_ram("512"), 512)
|
||||
self.assertEqual(parse_ram("1G"), 1024)
|
||||
self.assertEqual(parse_ram("512MiB"), 512)
|
||||
self.assertEqual(parse_ram("134217728B"), 128)
|
||||
|
||||
def test_parse_ram_rejects_small_and_invalid_values(self) -> None:
|
||||
for value in ("127M", "nonsense", "-1G", "1T"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_ram(value)
|
||||
|
||||
def test_parse_cpu_requires_a_positive_finite_value(self) -> None:
|
||||
self.assertEqual(parse_cpu("0.5"), 0.5)
|
||||
for value in ("0", "-1", "nan", "inf", "cpu"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_cpu(value)
|
||||
|
||||
def test_guest_username_is_validated(self) -> None:
|
||||
self.assertEqual(parse_username("root"), "root")
|
||||
self.assertEqual(parse_username("app-user"), "app-user")
|
||||
for value in ("", "Root", "9user", "user name", "a" * 33):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_username(value)
|
||||
|
||||
def test_guest_password_rejects_empty_control_or_oversized_values(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_password("correct horse battery staple"),
|
||||
"correct horse battery staple",
|
||||
)
|
||||
for value in ("", "line\nbreak", "tab\tvalue", "a" * 129):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
parse_password(value)
|
||||
Reference in New Issue
Block a user