Refactor VM lifecycle management and enhance validation

- Introduced a new method `installed_kernel` in `ImageStore` to handle kernel verification and checksum validation.
- Updated `Installer` to include additional packages: `nftables` and `conntrack`.
- Refined `LifecycleService` to improve error handling and state management during VM creation, starting, stopping, and deletion.
- Enhanced port management with new methods for setting and syncing ports, including validation for port rules.
- Improved error handling in `StateStore` to raise specific `NotFoundError` and `ConflictError` exceptions.
- Updated network management to ensure proper TAP device handling and firewall integration.
- Enhanced API responses in `vms.py` to include policy status for better client-side handling.
- Added validation for VM names and ports in `validation.py` to enforce stricter rules and prevent conflicts.
This commit is contained in:
2026-09-09 17:27:50 +05:30
parent 4ce14c3378
commit d8a25209b9
31 changed files with 9576 additions and 1331 deletions
+886
View File
@@ -0,0 +1,886 @@
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import replace
from ipaddress import IPv4Address
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, create_autospec, patch
from tests.test_cli import make_vm
from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError
from uvm.lifecycle import LifecycleService, ListedVm, OperationResult
from uvm.system import CommandRunner
class TtyInput(io.StringIO):
def isatty(self) -> bool:
return True
class CliTestCase(unittest.TestCase):
def setUp(self) -> None:
self.vm = replace(
make_vm(), name="web", ports=(PortRule(22), PortRule(80, "HTTP")),
status="running", disk_ready=True,
)
self.lifecycle = create_autospec(LifecycleService, instance=True, spec_set=True)
self.listed = ListedVm(vm=self.vm, observed_status="running", policy_status="enforced")
self.lifecycle.list_vms.return_value = [self.listed]
self.lifecycle.inspect.return_value = self.listed
self.lifecycle.find_for_ssh.return_value = self.vm
self.lifecycle.resolve_targets.return_value = [self.vm]
self.lifecycle.set_ports.return_value = self.vm
self.lifecycle.run_many.side_effect = lambda action, ids, **_kwargs: [
OperationResult(id=vm_id, action=action, outcome="succeeded", error=None)
for vm_id in ids
]
self.lifecycle.create.side_effect = lambda spec: replace(
self.vm, cpu=spec.cpu, ram_mib=spec.ram_mib, username=spec.username,
password=spec.password, name=spec.name, ports=spec.ports,
)
self.application = SimpleNamespace(
settings=Settings(), lifecycle=self.lifecycle,
state_store=Mock(spec_set=("operation_lock",)),
)
self.application.state_store.operation_lock.side_effect = AssertionError(
"CLI must not take an operation lock"
)
def invoke(self, arguments, *, stdin=None):
stdout, stderr = io.StringIO(), io.StringIO()
with (
patch("uvm.cli.build_application", return_value=self.application) as build,
patch("uvm.cli.sys.stdin", stdin if stdin is not None else io.StringIO()),
redirect_stdout(stdout), redirect_stderr(stderr),
):
try:
result = main(arguments)
except SystemExit as error:
result = error.code
self.build_call = build.call_args
return result, stdout.getvalue(), stderr.getvalue()
class CliParsingTests(CliTestCase):
def test_guest_ip_alias_keeps_the_original_destination(self) -> None:
for option in ("--host-ip", "--guest-ip"):
with self.subTest(option=option):
args = build_parser(self.application).parse_args(["create", option, "10.42.0.8"])
self.assertEqual(args.host_ip, "10.42.0.8")
self.assertEqual(args.password, "root")
self.assertFalse(args.password_stdin)
def test_bulk_selectors_are_exclusive_and_required(self) -> None:
for command in (("start",), ("stop",), ("restart",), ("delete",), ("destroy",), ("ports", "sync")):
with self.subTest(command=command):
parser = build_parser(self.application)
named = parser.parse_args([*command, "web", "10.42.0.3", "--yes", "--json"])
self.assertEqual(named.vms, ["web", "10.42.0.3"])
self.assertFalse(named.all_vms)
all_vms = parser.parse_args([*command, "--all", "--dry-run"])
self.assertTrue(all_vms.all_vms)
self.assertEqual(all_vms.vms, [])
for selector in ([], ["web", "--all"], ["--all", "web"]):
code, _, _ = self.invoke([*command, *selector])
self.assertEqual(code, 2)
self.lifecycle.run_many.assert_not_called()
def test_ports_subcommand_selector_semantics(self) -> None:
parser = build_parser(self.application)
close = parser.parse_args(["ports", "close", "web", "--all"])
self.assertEqual(close.vm, "web")
self.assertTrue(close.all_ports)
listing = parser.parse_args(["ports", "list", "--all"])
self.assertTrue(listing.all_vms)
self.assertIsNone(listing.vm)
for arguments in (
["ports"], ["ports", "list"], ["ports", "list", "web", "--all"],
["ports", "open", "web"], ["ports", "open", "web", "--all"],
["ports", "close", "--all"], ["ports", "close", "web"],
["ports", "close", "web", "--ports", "80", "--all"],
["ports", "set", "web"], ["ports", "set", "web", "--all"],
["ports", "set", "web", "--ports", "80", "--none"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.set_ports.assert_not_called()
def test_mutually_exclusive_output_and_password_options(self) -> None:
for arguments in (
["list", "--json", "-q"],
["create", "--password", "root", "--password-stdin"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.create.assert_not_called()
def test_no_bare_identifier_guessing_or_public_port_flags(self) -> None:
for arguments in (
["web"], ["vm-test"], ["10.42.0.2"],
["create", "--public-port", "8080"],
["create", "--ports", "127.0.0.1:8080:80"],
["create", "--ports", "8080:80"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
self.lifecycle.create.assert_not_called()
def test_explicit_invalid_ports_fail_before_any_lifecycle_calls(self) -> None:
self.vm.ports = ()
for command in (["create"], ["ports", "open", "web"],
["ports", "close", "web"], ["ports", "set", "web"]):
for invalid in ("", "80/http", "443/HTTPS", "22/TCP", "80:UDP", "80:",
"80:HTTP:TCP", "22,", "80:HTTP,65536:TCP"):
with self.subTest(command=command, invalid=invalid):
code, stdout, _ = self.invoke([
*command, "--ports", "8080:TCP", "--ports", invalid, "--json",
])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.assertEqual(self.lifecycle.mock_calls, [])
def test_port_help_documents_only_colon_syntax(self) -> None:
for command in (["create"], ["ports", "open"], ["ports", "close"], ["ports", "set"]):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([*command, "--help"])
self.assertEqual(code, 0, stderr)
self.assertIn("PORT[:TYPE]", stdout)
self.assertIn("80:HTTP", stdout)
self.assertNotIn("80/HTTP", stdout)
def test_timeout_help_describes_process_signals_not_graceful_guest_shutdown(self) -> None:
for command in ("stop", "restart", "delete", "destroy"):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([command, "--help"])
self.assertEqual(code, 0, stderr)
self.assertIn("SIGTERM", stdout)
self.assertIn("SIGKILL", stdout)
self.assertIn("does not coordinate guest shutdown", " ".join(stdout.split()))
self.assertNotIn("graceful", stdout.lower())
def test_server_and_ssh_ports_have_independent_defaults(self) -> None:
parser = build_parser(self.application)
self.assertEqual(parser.parse_args(["--serve"]).port, 8000)
self.assertEqual(parser.parse_args(["ssh", "web"]).port, 22)
self.assertEqual(parser.parse_args(["--port", "8123", "ssh", "web"]).port, 22)
for value in ("0", "65536", "tcp", "1.5"):
for command in (["--serve", "--port", value], ["ssh", "web", "--port", value]):
with self.subTest(command=command):
self.assertEqual(self.invoke(command)[0], 2)
def test_server_mode_is_preserved_and_cannot_run_a_command(self) -> None:
with patch("uvm.cli._run_server", return_value=0) as server:
code, _, _ = self.invoke(["--serve", "--host", "127.0.0.1", "--port", "8123"])
self.assertEqual(code, 0)
self.assertEqual(server.call_args.args[1].port, 8123)
self.assertEqual(self.invoke(["--serve", "list"])[0], 2)
self.assertEqual(self.invoke([])[0], 2)
class CliCreateCommandTests(CliTestCase):
def test_create_combines_default_ports_and_stdin_credentials(self) -> None:
code, stdout, stderr = self.invoke([
"create", "--name", "web", "--cpu", "0.5", "--ram", "1G",
"--guest-ip", "10.42.0.8", "--username", "admin", "--password-stdin",
"--ports", "80:http,443:https", "--ports", "8080:tcp", "--json",
], stdin=io.StringIO(" stdin-secret \r\n"))
self.assertEqual(code, 0, stderr)
spec = self.lifecycle.create.call_args.args[0]
self.assertEqual(spec.name, "web")
self.assertEqual(spec.cpu, 0.5)
self.assertEqual(spec.ram_mib, 1024)
self.assertEqual(spec.guest_ip, IPv4Address("10.42.0.8"))
self.assertEqual(spec.username, "admin")
self.assertEqual(spec.password, " stdin-secret ")
self.assertEqual(spec.ports, (
PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"), PortRule(8080),
))
self.assertEqual(json.loads(stdout)["ports"], [rule.to_dict() for rule in spec.ports])
self.assertNotIn("stdin-secret", stdout + stderr)
self.assertNotIn("password", json.loads(stdout))
self.assertIn("fractional", stderr)
self.lifecycle.run_many.assert_not_called()
def test_create_defaults_to_ssh_but_can_create_an_explicit_empty_policy(self) -> None:
for options, expected in (
([], (PortRule(22),)),
(["--ports", "22,80"], (PortRule(22), PortRule(80))),
(["--no-default-ports"], ()),
(["--no-default-ports", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(self.lifecycle.create.call_args.args[0].ports, expected)
self.assertNotIn("WARNING", stdout)
self.assertIn("default password", stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "enforced")
def test_create_does_not_claim_enforcement_for_a_legacy_returned_record(self) -> None:
self.lifecycle.create.side_effect = None
self.lifecycle.create.return_value = replace(self.vm, ports=None)
code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["policy_status"], "unmanaged")
self.assertIsNone(json.loads(stdout)["ports"])
self.assertNotIn("stored-secret", stdout + stderr)
def test_invalid_create_inputs_do_not_call_the_service(self) -> None:
for options in (
["--name", "../bad"], ["--cpu", "nan"], ["--ram", "12"],
["--guest-ip", "invalid"], ["--username", "Invalid"],
["--ports", "80,70000"], ["--ports", "80:http", "--ports", "53:udp"],
["--ports", ""],
):
with self.subTest(options=options):
code, stdout, _ = self.invoke(["create", *options, "--json"])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.lifecycle.create.assert_not_called()
def test_invalid_stdin_password_is_not_echoed(self) -> None:
for password in ("", "\n", "secret\tcontrol\n", "long-secret-" * 20):
with self.subTest(password=password):
code, stdout, stderr = self.invoke(
["create", "--password-stdin", "--json"], stdin=io.StringIO(password)
)
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.assertNotIn("secret", stderr)
self.lifecycle.create.assert_not_called()
def test_json_uses_a_whitelist_not_persisted_vm_serialization(self) -> None:
with patch.object(VmRecord, "to_dict", side_effect=AssertionError("unsafe serialization")):
for arguments in (
["create", "--password", "stored-secret", "--json"],
["list", "--json"], ["inspect", "web", "--json"],
["ports", "list", "--all", "--json"],
["ports", "open", "web", "--ports", "443", "--json"],
["delete", "--all", "--dry-run", "--json"],
["ports", "set", "web", "--none", "--dry-run", "--json"],
):
with self.subTest(arguments=arguments):
code, stdout, stderr = self.invoke(arguments)
self.assertEqual(code, 0, stderr)
json.loads(stdout)
self.assertNotIn("password", stdout)
self.assertNotIn("stored-secret", stdout + stderr)
def test_application_and_subprocess_progress_use_stderr_for_json(self) -> None:
self.invoke(["list", "--json"])
runner = CommandRunner(emit=self.build_call.kwargs["emit"])
create_vm = self.lifecycle.create.side_effect
def create(spec):
runner.run(["cli-output-probe"])
return create_vm(spec)
def subprocess_run(_command, **kwargs):
self.assertIs(kwargs["stdout"], sys.stderr)
print("child process progress", file=kwargs["stdout"])
return SimpleNamespace(returncode=0, stdout="", stderr="")
self.lifecycle.create.side_effect = create
with patch("uvm.system.subprocess.run", side_effect=subprocess_run) as run:
code, stdout, stderr = self.invoke(["create", "--password", "stored-secret", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["id"], self.vm.id)
self.assertIn("+ cli-output-probe", stderr)
self.assertIn("child process progress", stderr)
self.assertNotIn("stored-secret", stdout + stderr)
run.assert_called_once()
class CliListingTests(CliTestCase):
def test_status_filters_use_observed_status_and_quiet_prints_only_ids(self) -> None:
self.lifecycle.list_vms.return_value = [
ListedVm(vm=self.vm, observed_status="dead", policy_status="missing"),
ListedVm(
vm=replace(self.vm, id="vm-two", status="stopped"),
observed_status="stopped", policy_status="inactive",
),
ListedVm(
vm=replace(self.vm, id="vm-three"), observed_status="running", policy_status="enforced",
),
]
code, stdout, stderr = self.invoke(["list", "--status", "dead", "--status", "stopped", "-q"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, "vm-test\nvm-two\n")
code, stdout, _ = self.invoke(["list", "--status", "dead", "--json"])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout)[0]["observed_status"], "dead")
self.assertEqual(json.loads(stdout)[0]["status"], "running")
self.assertEqual(json.loads(stdout)[0]["policy_status"], "missing")
def test_empty_list_formats(self) -> None:
self.lifecycle.list_vms.return_value = []
for options, expected in (([], "No VMs.\n"), (["-q"], ""), (["--json"], "[]\n")):
with self.subTest(options=options):
code, stdout, _ = self.invoke(["list", *options])
self.assertEqual(code, 0)
self.assertEqual(stdout, expected)
def test_inspect_delegates_identifier_and_exposes_observed_state(self) -> None:
self.lifecycle.inspect.return_value = ListedVm(
vm=self.vm, observed_status="dead", policy_status="drift",
)
for options in ([], ["--json"]):
code, stdout, stderr = self.invoke(["inspect", "10.42.0.2", *options])
self.assertEqual(code, 0, stderr)
self.assertIn("dead", stdout)
self.assertIn("drift", stdout)
self.assertNotIn("stored-secret", stdout)
self.lifecycle.inspect.assert_called_with("10.42.0.2")
class CliBulkTests(CliTestCase):
def test_named_lifecycle_actions_use_resolved_ids_and_configured_timeouts(self) -> None:
self.application.settings = replace(Settings(), terminate_timeout_s=7.5)
self.lifecycle.resolve_targets.return_value = [self.vm, replace(self.vm, id="vm-two")]
for action in ("start", "stop", "restart", "delete", "destroy"):
with self.subTest(action=action):
code, _, stderr = self.invoke([action, "web", "10.42.0.3", "-y"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resolve_targets.assert_called_with(["web", "10.42.0.3"], all_vms=False)
self.lifecycle.run_many.assert_called_with(
"delete" if action == "destroy" else action,
("vm-test", "vm-two"), timeout=None if action == "start" else 7.5,
)
def test_explicit_timeouts_are_validated_before_target_resolution(self) -> None:
for action in ("stop", "restart", "delete", "destroy"):
for timeout in ("-1", "nan", "inf", "bad"):
with self.subTest(action=action, timeout=timeout):
code, _, _ = self.invoke([action, "web", "--timeout", timeout, "-y"])
self.assertEqual(code, 2)
self.lifecycle.resolve_targets.assert_not_called()
self.lifecycle.run_many.assert_not_called()
self.assertEqual(self.invoke(["stop", "web", "--timeout", "2.25"])[0], 0)
self.lifecycle.run_many.assert_called_once_with("stop", ("vm-test",), timeout=2.25)
def test_every_delete_and_all_selector_requires_tty_or_yes(self) -> None:
for arguments in (
["delete", "web"], ["destroy", "web"],
["start", "--all"], ["stop", "--all"], ["restart", "--all"],
["delete", "--all"], ["ports", "sync", "--all"],
):
with self.subTest(arguments=arguments):
code, stdout, stderr = self.invoke([*arguments, "--json"], stdin=io.StringIO("yes\n"))
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("--yes", stderr)
self.lifecycle.run_many.assert_not_called()
self.assertEqual(self.invoke(["start", "--all", "-y"])[0], 0)
def test_snapshot_is_taken_before_confirmation_and_never_expanded(self) -> None:
events = []
selected = [self.vm]
def resolve(identifiers, *, all_vms):
self.assertEqual(identifiers, [])
self.assertTrue(all_vms)
events.append("resolve")
return selected
def approve():
events.append("prompt")
selected.append(replace(self.vm, id="vm-new"))
self.vm.id = "vm-renamed-during-prompt"
return "yes\n"
self.lifecycle.resolve_targets.side_effect = resolve
stdin = Mock(isatty=Mock(return_value=True), readline=Mock(side_effect=approve))
code, stdout, stderr = self.invoke(["delete", "--all", "--json"], stdin=stdin)
self.assertEqual(code, 0, stderr)
self.assertEqual(events, ["resolve", "prompt"])
self.lifecycle.resolve_targets.assert_called_once()
self.lifecycle.run_many.assert_called_once_with("delete", ("vm-test",), timeout=5.0)
self.assertIn("vm-test", stderr)
self.assertEqual([result["id"] for result in json.loads(stdout)], ["vm-test"])
def test_declining_confirmation_is_a_noop_and_keeps_stdout_json(self) -> None:
for answer in ("n\n", "\n", "", "not yes\n"):
with self.subTest(answer=answer):
code, stdout, stderr = self.invoke(["delete", "web", "--json"], stdin=TtyInput(answer))
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout), [])
self.assertIn("Cancelled", stderr)
self.lifecycle.run_many.assert_not_called()
def test_dry_run_neither_mutates_nor_prompts(self) -> None:
for action in ("start", "stop", "restart", "delete", "destroy"):
with self.subTest(action=action):
stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt")))
code, stdout, stderr = self.invoke([action, "--all", "--dry-run", "--json"], stdin=stdin)
self.assertEqual(code, 0, stderr)
preview = json.loads(stdout)
self.assertTrue(preview["dry_run"])
self.assertEqual(preview["action"], "delete" if action == "destroy" else action)
self.assertEqual([vm["id"] for vm in preview["targets"]], [self.vm.id])
self.lifecycle.run_many.assert_not_called()
def test_empty_target_set_is_success_without_confirmation_or_mutation(self) -> None:
self.lifecycle.resolve_targets.return_value = []
for command in (["delete"], ["start"], ["ports", "sync"]):
with self.subTest(command=command):
code, stdout, stderr = self.invoke([*command, "--all", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout), [])
self.lifecycle.run_many.assert_not_called()
def test_failed_target_resolution_aborts_the_entire_bulk_request(self) -> None:
self.lifecycle.resolve_targets.side_effect = UvmError("VM not found: missing")
code, stdout, stderr = self.invoke(["stop", "web", "missing", "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("missing", stderr)
self.lifecycle.run_many.assert_not_called()
def test_result_outcomes_determine_exit_code_and_preserve_each_result(self) -> None:
for outcomes, expected in (
(("succeeded", "skipped"), 0),
(("succeeded", "failed"), 1),
(("failed", "interrupted", "skipped"), 130),
):
with self.subTest(outcomes=outcomes):
self.lifecycle.run_many.side_effect = None
self.lifecycle.run_many.return_value = [
OperationResult(id=f"vm-{index}", action="stop", outcome=outcome,
error="failure detail" if outcome == "failed" else None)
for index, outcome in enumerate(outcomes)
]
code, stdout, _ = self.invoke(["stop", "web", "--json"])
self.assertEqual(code, expected)
data = json.loads(stdout)
self.assertEqual([result["outcome"] for result in data], list(outcomes))
self.assertTrue(all(set(result) == {"id", "action", "outcome", "error"} for result in data))
def test_keyboard_interrupt_returns_130_without_traceback(self) -> None:
self.lifecycle.run_many.side_effect = KeyboardInterrupt
code, stdout, stderr = self.invoke(["stop", "web"])
self.assertEqual(code, 130)
self.assertEqual(stdout, "")
self.assertIn("interrupted", stderr)
class CliPortsTests(CliTestCase):
def test_list_distinguishes_unmanaged_empty_and_enforced_policies(self) -> None:
self.lifecycle.list_vms.return_value = [
self.listed,
ListedVm(
vm=replace(self.vm, id="vm-legacy", ports=None),
observed_status="running", policy_status="unmanaged",
),
ListedVm(
vm=replace(self.vm, id="vm-empty", ports=()),
observed_status="running", policy_status="enforced",
),
]
code, stdout, stderr = self.invoke(["ports", "list", "--all", "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertEqual(data[0]["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}])
self.assertIsNone(data[1]["ports"])
self.assertEqual(data[1]["policy_status"], "unmanaged")
self.assertEqual(data[2]["ports"], [])
code, stdout, _ = self.invoke(["ports", "list", "web"])
self.assertEqual(code, 0)
self.assertIn("PRIVATE-IP", stdout)
self.assertIn("22:TCP,80:HTTP", stdout)
self.assertNotIn("22/TCP", stdout)
self.lifecycle.inspect.assert_called_with("web")
def test_open_and_close_call_service_modes_using_resolved_id(self) -> None:
for mode, values, expected in (
("open", "443:https,8080:tcp", (PortRule(443, "HTTPS"), PortRule(8080))),
("close", "80:tcp", (PortRule(80),)),
):
with self.subTest(mode=mode):
code, _, stderr = self.invoke(["ports", mode, "web", "--ports", values, "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode=mode)
def test_mutation_json_reports_policy_from_the_successful_returned_state(self) -> None:
self.lifecycle.inspect.return_value = replace(self.listed, policy_status="drift")
for status, policy_status in (("running", "enforced"), ("stopped", "inactive")):
self.lifecycle.set_ports.return_value = replace(self.vm, status=status, operation=None)
for options in (
["open", "web", "--ports", "443:HTTPS"],
["close", "web", "--ports", "80:TCP"],
["set", "web", "--ports", "443:HTTPS", "--yes"],
):
with self.subTest(status=status, options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertEqual(data["status"], status)
self.assertEqual(data["policy_status"], policy_status)
self.assertIsNone(data["operation"])
def test_invalid_port_in_later_argument_aborts_before_service_calls(self) -> None:
for mode in ("open", "close", "set"):
with self.subTest(mode=mode):
code, stdout, _ = self.invoke([
"ports", mode, "web", "--ports", "80:http", "--ports", "53:udp", "--json",
])
self.assertEqual(code, 2)
self.assertEqual(stdout, "")
self.lifecycle.inspect.assert_not_called()
self.lifecycle.set_ports.assert_not_called()
def test_set_close_all_and_removing_ssh_require_confirmation(self) -> None:
for options in (
["set", "web", "--ports", "22,80"], ["set", "web", "--none"],
["close", "web", "--all"], ["close", "web", "--ports", "22"],
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("--yes", stderr)
self.lifecycle.set_ports.assert_not_called()
def test_set_and_close_all_replace_policy_without_touching_other_vms(self) -> None:
for options, expected in (
(["close", "web", "--all"], ()),
(["set", "web", "--none"], ()),
(["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, _, stderr = self.invoke(["ports", *options, "--yes"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_with(self.vm.id, expected, mode="set")
self.lifecycle.resolve_targets.assert_not_called()
self.lifecycle.run_many.assert_not_called()
def test_empty_managed_port_target_is_a_noop_without_confirmation(self) -> None:
for status, policy_status in (("running", "enforced"), ("stopped", "inactive")):
vm = replace(self.vm, ports=(), status=status, operation=None)
self.lifecycle.inspect.return_value = ListedVm(vm, status, policy_status)
for options in (
["close", "web", "--all"], ["close", "web", "--ports", "22:TCP"],
["set", "web", "--none"],
):
for output in ([], ["--json"]):
with self.subTest(status=status, options=options, output=output):
stdin = Mock(isatty=Mock(side_effect=AssertionError("must not prompt")))
code, stdout, stderr = self.invoke(["ports", *options, *output], stdin=stdin)
self.assertEqual(code, 0, stderr)
if output:
self.assertEqual(json.loads(stdout)["ports"], [])
self.assertEqual(json.loads(stdout)["policy_status"], policy_status)
else:
self.assertIn("no port changes", stdout)
self.assertEqual(stderr, "")
self.lifecycle.set_ports.assert_not_called()
self.lifecycle.run_many.assert_not_called()
def test_empty_policy_requires_service_when_unverified_or_pending(self) -> None:
for status, observed, policy, operation in (
("running", "running", "missing", None),
("running", "running", "drift", None),
("running", "running", "failed", None),
("running", "running", "pending", None),
("running", "running", "unknown", None),
("running", "running", "unmanaged", None),
("running", "running", "enforced", "ports-sync"),
("stopped", "stopped", "inactive", "ports-sync"),
("failed", "failed", "enforced", None),
("running", "dead", "enforced", None),
("stopped", "running", "inactive", None),
("stopped", "stopped", "missing", None),
):
vm = replace(self.vm, ports=(), status=status, operation=operation)
self.lifecycle.inspect.return_value = ListedVm(vm, observed, policy)
updated_status = "stopped" if status == observed == "stopped" else "running"
self.lifecycle.set_ports.return_value = replace(vm, status=updated_status, operation=None)
for options, requested, mode in (
(["close", "web", "--all"], (), "set"),
(["close", "web", "--ports", "22:TCP"], (PortRule(22),), "close"),
(["set", "web", "--none"], (), "set"),
):
with self.subTest(state=(status, observed, policy, operation), options=options):
self.lifecycle.set_ports.reset_mock()
code, stdout, stderr = self.invoke(["ports", *options, "--yes", "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_once_with(vm.id, requested, mode=mode)
self.assertEqual(json.loads(stdout)["policy_status"],
"inactive" if updated_status == "stopped" else "enforced")
self.assertIsNone(json.loads(stdout)["operation"])
def test_empty_policy_reconciliation_failure_is_not_reported_as_a_noop(self) -> None:
vm = replace(self.vm, ports=(), status="failed", operation="ports-sync")
self.lifecycle.inspect.return_value = ListedVm(vm, "failed", "drift")
self.lifecycle.set_ports.side_effect = UvmError("policy reconciliation failed")
code, stdout, stderr = self.invoke(["ports", "close", "web", "--all", "--yes", "--json"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("policy reconciliation failed", stderr)
self.lifecycle.set_ports.assert_called_once_with(vm.id, (), mode="set")
def test_confirmation_uses_stable_id_and_decline_does_not_mutate(self) -> None:
code, stdout, stderr = self.invoke(
["ports", "close", "web", "--ports", "22", "--json"], stdin=TtyInput("n\n")
)
self.assertEqual(code, 0)
self.assertIn(self.vm.id, stderr)
self.assertEqual(json.loads(stdout), [])
self.lifecycle.set_ports.assert_not_called()
code, _, _ = self.invoke(
["ports", "close", "web", "--ports", "22"], stdin=TtyInput("YES\n")
)
self.assertEqual(code, 0)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="close")
def test_port_dry_runs_render_effective_changes_without_mutation(self) -> None:
for options, expected in (
(["open", "web", "--ports", "443:https"],
(PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS"))),
(["close", "web", "--ports", "80:tcp"], (PortRule(22),)),
(["close", "web", "--ports", "22"], (PortRule(80, "HTTP"),)),
(["close", "web", "--all"], ()),
(["set", "web", "--none"], ()),
(["set", "web", "--ports", "443:https"], (PortRule(443, "HTTPS"),)),
):
with self.subTest(options=options):
code, stdout, stderr = self.invoke(["ports", *options, "--dry-run", "--json"])
self.assertEqual(code, 0, stderr)
data = json.loads(stdout)
self.assertTrue(data["dry_run"])
self.assertEqual(data["changes"]["ports"], {
"before": [rule.to_dict() for rule in self.vm.ports],
"after": [rule.to_dict() for rule in expected],
})
self.lifecycle.set_ports.assert_not_called()
def test_open_preview_preserves_labels_of_existing_ports(self) -> None:
code, stdout, stderr = self.invoke([
"ports", "open", "web", "--ports", "22:HTTPS,80:TCP", "--ports", "443:HTTPS",
"--dry-run", "--json",
])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["changes"]["ports"], {
"before": [PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict()],
"after": [
PortRule(22).to_dict(), PortRule(80, "HTTP").to_dict(), PortRule(443, "HTTPS").to_dict(),
],
})
self.assertEqual(self.vm.ports, (PortRule(22), PortRule(80, "HTTP")))
self.lifecycle.set_ports.assert_not_called()
def test_unmanaged_open_and_close_are_not_silently_treated_as_empty(self) -> None:
self.vm.ports = None
for options in (
["open", "web", "--ports", "80"], ["close", "web", "--ports", "80"],
["close", "web", "--all", "--yes"],
):
for preview in ([], ["--dry-run"]):
with self.subTest(options=options, preview=preview):
code, _, stderr = self.invoke(["ports", *options, *preview])
self.assertEqual(code, 1)
self.assertIn("unmanaged", stderr)
self.lifecycle.set_ports.assert_not_called()
def test_legacy_migration_stopped_requirement_is_owned_by_service(self) -> None:
self.vm.ports = None
self.lifecycle.set_ports.side_effect = UvmError("stop the VM before adopting a port policy")
code, _, stderr = self.invoke(["ports", "set", "web", "--ports", "22", "--yes"])
self.assertEqual(code, 1)
self.assertIn("stop", stderr)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (PortRule(22),), mode="set")
def test_setting_an_empty_legacy_policy_is_not_a_noop(self) -> None:
self.vm.ports = None
code, _, stderr = self.invoke(["ports", "set", "web", "--none"])
self.assertEqual(code, 1)
self.assertIn("--yes", stderr)
self.lifecycle.set_ports.assert_not_called()
self.lifecycle.set_ports.return_value = replace(self.vm, ports=(), status="stopped")
code, _, stderr = self.invoke(["ports", "set", "web", "--none", "--yes"])
self.assertEqual(code, 0, stderr)
self.lifecycle.set_ports.assert_called_once_with(self.vm.id, (), mode="set")
def test_sync_uses_bulk_action_and_supports_preview(self) -> None:
code, _, stderr = self.invoke(["ports", "sync", "web", "--json"])
self.assertEqual(code, 0, stderr)
self.lifecycle.resolve_targets.assert_called_with(["web"], all_vms=False)
self.lifecycle.run_many.assert_called_once_with("ports-sync", (self.vm.id,), timeout=None)
self.lifecycle.run_many.reset_mock()
code, stdout, stderr = self.invoke(["ports", "sync", "--all", "--dry-run", "--json"])
self.assertEqual(code, 0, stderr)
self.assertEqual(json.loads(stdout)["action"], "ports-sync")
self.lifecycle.run_many.assert_not_called()
self.lifecycle.sync_ports.assert_not_called()
class CliSshCommandTests(CliTestCase):
def test_options_after_vm_are_not_swallowed_by_remote_command(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke([
"ssh", "web", "--key", "/tmp/key with spaces", "--port", "2222", "--user", "admin",
"--", "printf", "%s", "--user", "--port", "literal argument",
])
self.assertEqual(code, 0, stderr)
self.lifecycle.find_for_ssh.assert_called_once_with("web")
executable, arguments = execute.call_args.args
self.assertEqual(executable, "ssh")
self.assertEqual(arguments[:5], ["ssh", "-p", "2222", "-i", "/tmp/key with spaces"])
self.assertIn("HostKeyAlias=uvm-vm-test", arguments)
self.assertEqual(arguments[-6:], [
"admin@10.42.0.2", "printf", "%s", "--user", "--port", "literal argument",
])
def test_options_before_vm_and_remote_separator_are_supported(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke([
"ssh", "--port", "2222", "--insecure-host-key", "web", "--", "uname", "-a",
])
self.assertEqual(code, 0, stderr)
arguments = execute.call_args.args[1]
self.assertIn("StrictHostKeyChecking=no", arguments)
self.assertNotIn("HostKeyAlias=uvm-vm-test", arguments)
self.assertEqual(arguments[-3:], ["admin@10.42.0.2", "uname", "-a"])
def test_remote_commands_require_literal_separator_and_unknown_options_fail(self) -> None:
with patch("uvm.cli.os.execvp") as execute:
for arguments in (
["ssh", "web", "uname", "-a"], ["ssh", "web", "--unknown"],
["ssh", "web", "--bad-option", "--", "uname"],
["ssh", "web", "--user", "-bad"],
):
with self.subTest(arguments=arguments):
self.assertEqual(self.invoke(arguments)[0], 2)
execute.assert_not_called()
def test_ssh_help_and_option_values_after_separator_belong_to_remote_command(self) -> None:
for remote in (["--help"], ["--port", "not-a-port", "--user", "Not-A-Local-User"], ["--", "-x"]):
with self.subTest(remote=remote), patch("uvm.cli.os.execvp") as execute:
code, _, stderr = self.invoke(["ssh", "web", "--port", "2222", "--", *remote])
self.assertEqual(code, 0, stderr)
arguments = execute.call_args.args[1]
self.assertEqual(arguments[:3], ["ssh", "-p", "2222"])
self.assertEqual(arguments[-len(remote):], remote)
with patch("uvm.cli.os.execvp") as execute:
self.assertEqual(self.invoke(["ssh", "web", "--help"])[0], 0)
execute.assert_not_called()
def test_missing_ssh_executable_is_reported_without_traceback(self) -> None:
with patch("uvm.cli.os.execvp", side_effect=FileNotFoundError("ssh not found")):
code, stdout, stderr = self.invoke(["ssh", "web"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("ssh not found", stderr)
class CliLogTests(CliTestCase):
def test_tail_handles_empty_partial_and_fewer_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
self.lifecycle.log_path.return_value = path
for content, count, expected in (
(b"", 100, ""), (b"one\ntwo\nthree\n", 2, "two\nthree\n"),
(b"one\ntwo\nthree", 2, "two\nthree"), (b"one\n", 100, "one\n"),
(b"one\ntwo\n", 0, ""), (b"\n\n", 1, "\n"),
(b"bad\xff\n", 1, "bad\ufffd\n"),
):
with self.subTest(content=content, count=count):
path.write_bytes(content)
code, stdout, stderr = self.invoke(["logs", "web", "--tail", str(count)])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, expected)
self.lifecycle.log_path.assert_called_with("web")
def test_default_tail_is_100_and_reads_are_bounded_near_eof(self) -> None:
content = b"not in tail\n" * 200000 + b"last line\n" * 100
stream = io.BytesIO(content)
bounded = Mock(wraps=stream)
path = Mock()
path.open.return_value.__enter__ = Mock(return_value=bounded)
path.open.return_value.__exit__ = Mock(return_value=False)
self.lifecycle.log_path.return_value = path
code, stdout, stderr = self.invoke(["logs", "web"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, "last line\n" * 100)
reads = [call.args[0] for call in bounded.read.call_args_list]
self.assertTrue(all(0 < size <= 65536 for size in reads))
self.assertLess(sum(reads), 131072)
path.open.assert_called_once_with("rb")
def test_multibyte_and_very_long_lines_are_streamed_correctly(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
content = "skip\n" + "x" * 65535 + "\u00e9" + "z" * 70000 + "\n"
path.write_text(content, encoding="utf-8")
self.lifecycle.log_path.return_value = path
code, stdout, stderr = self.invoke(["logs", "web", "--tail", "1"])
self.assertEqual(code, 0, stderr)
self.assertEqual(stdout, content.removeprefix("skip\n"))
def test_follow_uses_safe_path_without_holding_an_operation_lock(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "safe.log"
path.write_text("old\n", encoding="utf-8")
self.lifecycle.log_path.return_value = path
events = []
def append_then_interrupt(_seconds):
if events:
raise KeyboardInterrupt
with path.open("a", encoding="utf-8") as stream:
stream.write("new\n")
events.append("appended")
with patch("uvm.cli.time.sleep", side_effect=append_then_interrupt):
code, stdout, stderr = self.invoke(["logs", "web", "--tail", "0", "-f"])
self.assertEqual(code, 130)
self.assertEqual(stdout, "new\n")
self.assertIn("interrupted", stderr)
self.lifecycle.log_path.assert_called_once_with("web")
self.application.state_store.operation_lock.assert_not_called()
def test_missing_or_unsafe_log_path_fails_without_opening_record_path(self) -> None:
self.lifecycle.log_path.side_effect = UvmError("unsafe VM log path")
with patch("uvm.cli.Path.open") as open_path:
code, stdout, stderr = self.invoke(["logs", "web"])
self.assertEqual(code, 1)
self.assertEqual(stdout, "")
self.assertIn("unsafe", stderr)
open_path.assert_not_called()
self.lifecycle.log_path.side_effect = None
with tempfile.TemporaryDirectory() as directory:
self.lifecycle.log_path.return_value = Path(directory) / "missing.log"
self.assertEqual(self.invoke(["logs", "web"])[0], 1)
def test_invalid_tail_is_an_argument_error(self) -> None:
for value in ("-1", "1.5", "all"):
with self.subTest(value=value):
self.assertEqual(self.invoke(["logs", "web", "--tail", value])[0], 2)
self.lifecycle.log_path.assert_not_called()
class CliDoctorTests(CliTestCase):
def test_doctor_reports_all_checks_and_fails_if_any_check_fails(self) -> None:
for healthy in (True, False):
with self.subTest(healthy=healthy):
self.lifecycle.diagnostics.return_value = [
{"name": "kvm", "ok": True, "message": "available", "password": "not-output"},
{"name": "firewall", "ok": healthy, "message": "checked"},
]
for options in ([], ["--json"]):
code, stdout, stderr = self.invoke(["doctor", *options])
self.assertEqual(code, 0 if healthy else 1, stderr)
self.assertIn("firewall", stdout)
self.assertNotIn("password", stdout)
self.assertNotIn("not-output", stdout)
if options:
self.assertEqual(len(json.loads(stdout)), 2)