- 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.
887 lines
45 KiB
Python
887 lines
45 KiB
Python
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)
|