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, storage_mib=30 * 1024, ) 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, storage_mib=spec.storage_mib, ) self.lifecycle.resize.return_value = self.vm 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 CliResizeCommandTests(CliTestCase): def test_resize_forwards_resources_and_timeout_without_changing_identity(self) -> None: self.lifecycle.resize.return_value = replace(self.vm, cpu=2, ram_mib=4096, storage_mib=50 * 1024) code, stdout, stderr = self.invoke([ "resize", "web", "--cpu", "2", "--ram", "4G", "--storage", "50G", "--timeout", "0.5", "--json", ]) self.assertEqual(code, 0, stderr) self.lifecycle.resize.assert_called_once_with("web", cpu=2, ram_mib=4096, storage_mib=51200, timeout=0.5) result = json.loads(stdout) self.assertEqual(result["storage_mib"], 51200) self.assertEqual(result["guest_ip"], self.vm.guest_ip) self.assertEqual(result["username"], self.vm.username) self.assertNotIn("password", result) self.assertNotIn(self.vm.password, stdout + stderr) def test_partial_resize_preserves_omitted_fields(self) -> None: code, _stdout, stderr = self.invoke(["resize", "web", "--storage", "60G"]) self.assertEqual(code, 0, stderr) self.lifecycle.resize.assert_called_once_with("web", cpu=None, ram_mib=None, storage_mib=61440, timeout=None) def test_invalid_resize_requests_are_rejected_before_lifecycle_calls(self) -> None: for options in ([], ["--storage", "29G"], ["--ram", "127M"], ["--cpu", "nan"], ["--cpu", "0"], ["--cpu", "2", "--timeout", "-1"], ["--username", "new-user"]): with self.subTest(options=options): self.assertEqual(self.invoke(["resize", "web", *options])[0], 2) self.lifecycle.resize.assert_not_called() 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", "--storage", "50G", "--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.storage_mib, 50 * 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.assertEqual(self.lifecycle.create.call_args.args[0].storage_mib, 30 * 1024) 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)