Files
k7/tests/unit/test_cli_utils.py
T
G 13fafe0997 Release 0.4.0
Per-key node pins, k7d-fc pause/resume/exec, and HA-soak fixes. Playbook
pins k7d 0.7.0. GitHub .deb, Launchpad PPA, and PyPI k7-sdk are 0.4.0.
2026-09-19 23:22:14 +02:00

346 lines
11 KiB
Python

"""Unit tests for pure CLI utility functions."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import typer
from k7.cli.k7 import (
_build_default_inventory,
_core_run,
_kubectl_cmd,
_normalize_backend,
_parse_backends,
_read_api_endpoint,
)
# --- _normalize_backend ---
class TestNormalizeBackend:
def test_alias_kfd(self):
assert _normalize_backend("kfd") == "kata-firecracker-devmapper"
def test_alias_kql(self):
assert _normalize_backend("kql") == "kata-qemu-longhorn"
def test_deprecated_alias_fd_warns(self, capsys):
assert _normalize_backend("fd") == "kata-firecracker-devmapper"
err = capsys.readouterr().err
assert "deprecated" in err.lower()
assert "kfd" in err
def test_deprecated_alias_ql_warns(self, capsys):
assert _normalize_backend("ql") == "kata-qemu-longhorn"
err = capsys.readouterr().err
assert "deprecated" in err.lower()
assert "kql" in err
def test_deprecated_full_name_warns(self, capsys):
assert _normalize_backend("firecracker-devmapper") == "kata-firecracker-devmapper"
err = capsys.readouterr().err
assert "deprecated" in err.lower()
def test_full_name_firecracker(self):
assert _normalize_backend("kata-firecracker-devmapper") == "kata-firecracker-devmapper"
def test_full_name_qemu(self):
assert _normalize_backend("kata-qemu-longhorn") == "kata-qemu-longhorn"
def test_none_returns_none(self):
assert _normalize_backend(None) is None
def test_case_insensitive(self):
assert _normalize_backend("KFD") == "kata-firecracker-devmapper"
assert _normalize_backend("Kata-Qemu-Longhorn") == "kata-qemu-longhorn"
def test_whitespace_stripped(self):
assert _normalize_backend(" kfd ") == "kata-firecracker-devmapper"
def test_empty_string_raises(self):
with pytest.raises(typer.BadParameter, match="cannot be empty"):
_normalize_backend("")
def test_whitespace_only_raises(self):
with pytest.raises(typer.BadParameter, match="cannot be empty"):
_normalize_backend(" ")
def test_unknown_backend_raises(self):
with pytest.raises(typer.BadParameter, match="Unsupported backend"):
_normalize_backend("vmware")
def test_k7d_accepted(self):
assert _normalize_backend("k7d") == "k7d"
def test_k7_alias_maps_to_k7d(self):
assert _normalize_backend("k7") == "k7d"
def test_k7d_fc_accepted(self):
assert _normalize_backend("k7d-fc") == "k7d-fc"
def test_k7_fc_alias_maps_to_k7d_fc(self):
assert _normalize_backend("k7-fc") == "k7d-fc"
# --- _kubectl_cmd ---
class TestKubectlCmd:
def test_uses_k3s_when_available(self):
with patch("k7.cli.k7.shutil.which", return_value="/usr/local/bin/k3s"):
assert _kubectl_cmd() == ["k3s", "kubectl"]
def test_falls_back_to_kubectl(self):
with patch("k7.cli.k7.shutil.which", return_value=None):
assert _kubectl_cmd() == ["kubectl"]
# --- _read_api_endpoint ---
_SINGLE_STACK = json.dumps(
[
{"address": "10.0.0.1", "type": "InternalIP"},
{"address": "k7-node", "type": "Hostname"},
]
)
_DUAL_STACK = json.dumps(
[
{"address": "192.0.2.10", "type": "InternalIP"},
{"address": "2001:db8::2", "type": "InternalIP"},
{"address": "k7-node-01", "type": "Hostname"},
]
)
class TestReadApiEndpoint:
@pytest.fixture(autouse=True)
def _ignore_on_node_endpoint_file(self, tmp_path: Path):
with patch("k7.cli.k7._ETC_K7_API_ENDPOINT", tmp_path / "missing"):
yield
def test_returns_endpoint_on_success(self):
port_proc = MagicMock(returncode=0, stdout="31007")
node_proc = MagicMock(returncode=0, stdout=_SINGLE_STACK)
with patch("k7.cli.k7.subprocess.run", side_effect=[port_proc, node_proc]):
result = _read_api_endpoint(["kubectl"])
assert result == "http://10.0.0.1:31007"
def test_picks_ipv4_on_dual_stack(self):
port_proc = MagicMock(returncode=0, stdout="31007")
node_proc = MagicMock(returncode=0, stdout=_DUAL_STACK)
with patch("k7.cli.k7.subprocess.run", side_effect=[port_proc, node_proc]):
result = _read_api_endpoint(["kubectl"])
assert result == "http://192.0.2.10:31007"
def test_returns_none_when_svc_missing(self):
port_proc = MagicMock(returncode=1, stdout="")
with patch("k7.cli.k7.subprocess.run", return_value=port_proc):
assert _read_api_endpoint(["kubectl"]) is None
def test_falls_back_to_localhost_when_no_node_ip(self):
port_proc = MagicMock(returncode=0, stdout="31007")
node_proc = MagicMock(returncode=0, stdout="")
with patch("k7.cli.k7.subprocess.run", side_effect=[port_proc, node_proc]):
result = _read_api_endpoint(["kubectl"])
assert result == "http://localhost:31007"
def test_https_when_service_targets_8443(self):
port_proc = MagicMock(returncode=0, stdout="31007,8443")
node_proc = MagicMock(returncode=0, stdout=_SINGLE_STACK)
with patch("k7.cli.k7.subprocess.run", side_effect=[port_proc, node_proc]):
result = _read_api_endpoint(["kubectl"])
assert result == "https://10.0.0.1:31007"
def test_falls_back_to_configured_url_when_kubectl_missing(self):
with (
patch("k7.cli.k7.subprocess.run", side_effect=FileNotFoundError("kubectl")),
patch("k7.cli.k7._resolve_api_url", return_value="https://10.0.0.1:31007"),
):
assert _read_api_endpoint(["kubectl"]) == "https://10.0.0.1:31007"
# --- _build_default_inventory ---
class TestBuildDefaultInventory:
def test_localhost_default_server(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=["kata-firecracker-devmapper"],
disk=None,
longhorn_extra_disk=None,
)
assert "[k7_servers]" in inv
assert "localhost ansible_connection=local" in inv
assert "k7_backends=kata-firecracker-devmapper" in inv
assert "[k7_agents]" in inv
assert "[k7_cluster:children]" in inv
def test_localhost_none_backend(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=[],
disk=None,
longhorn_extra_disk=None,
)
assert "k7_backends=none" in inv
def test_localhost_both_kata_backends(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=["kata-firecracker-devmapper", "kata-qemu-longhorn"],
disk=None,
longhorn_extra_disk=None,
)
assert "k7_backends=kata-firecracker-devmapper,kata-qemu-longhorn" in inv
def test_disk_propagated_for_devmapper(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=["kata-firecracker-devmapper"],
disk="/dev/nvme1n1",
longhorn_extra_disk=None,
)
assert "k7_devmapper_disk=/dev/nvme1n1" in inv
def test_disk_ignored_for_qemu_longhorn(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=["kata-qemu-longhorn"],
disk="/dev/nvme1n1",
longhorn_extra_disk="/mnt/lh",
)
assert "k7_devmapper_disk" not in inv
assert "longhorn_extra_disk=/mnt/lh" in inv
def test_both_backends_propagate_their_disks(self):
inv = _build_default_inventory(
hosts=None,
role="server",
backends=["kata-firecracker-devmapper", "kata-qemu-longhorn"],
disk="/dev/nvme1n1",
longhorn_extra_disk="/mnt/lh",
)
assert "k7_backends=kata-firecracker-devmapper,kata-qemu-longhorn" in inv
assert "k7_devmapper_disk=/dev/nvme1n1" in inv
assert "longhorn_extra_disk=/mnt/lh" in inv
def test_remote_hosts_as_server(self):
inv = _build_default_inventory(
hosts=["1.2.3.4", "5.6.7.8"],
role="server",
backends=["kata-firecracker-devmapper"],
disk=None,
longhorn_extra_disk=None,
)
assert "1.2.3.4 ansible_user=root" in inv
assert "5.6.7.8 ansible_user=root" in inv
def test_remote_hosts_as_agent(self):
inv = _build_default_inventory(
hosts=["agent1"],
role="agent",
backends=["kata-qemu-longhorn"],
disk=None,
longhorn_extra_disk=None,
)
# agent1 must land in [k7_agents], not [k7_servers]
servers_section, agents_section = inv.split("[k7_agents]", 1)
assert "agent1" not in servers_section
assert "agent1" in agents_section
# --- _parse_backends ---
class TestParseBackends:
def test_default_pair(self):
assert _parse_backends("kata-firecracker-devmapper,kata-qemu-longhorn") == [
"kata-firecracker-devmapper",
"kata-qemu-longhorn",
]
def test_aliases(self):
assert _parse_backends("kfd,kql") == ["kata-firecracker-devmapper", "kata-qemu-longhorn"]
def test_k7d_fc_alias(self):
assert _parse_backends("k7d,k7-fc") == ["k7d", "k7d-fc"]
def test_deprecated_aliases(self):
assert _parse_backends("fd,ql") == ["kata-firecracker-devmapper", "kata-qemu-longhorn"]
def test_single_value(self):
assert _parse_backends("kql") == ["kata-qemu-longhorn"]
def test_dedupes_and_preserves_order(self):
assert _parse_backends("kql, kfd, kata-qemu-longhorn") == [
"kata-qemu-longhorn",
"kata-firecracker-devmapper",
]
def test_strips_whitespace_and_empty(self):
assert _parse_backends(" kfd , , kql ") == [
"kata-firecracker-devmapper",
"kata-qemu-longhorn",
]
def test_rejects_unknown(self):
with pytest.raises(typer.BadParameter, match="Unsupported backend"):
_parse_backends("kfd,vmware")
def test_rejects_empty(self):
with pytest.raises(typer.BadParameter, match="cannot be empty"):
_parse_backends("")
with pytest.raises(typer.BadParameter, match="cannot be empty"):
_parse_backends(" , , ")
def test_none_is_empty_set(self):
assert _parse_backends("none") == []
assert _parse_backends("None") == []
def test_none_cannot_mix(self):
with pytest.raises(typer.BadParameter, match="cannot be combined"):
_parse_backends("none,kql")
with pytest.raises(typer.BadParameter, match="cannot be combined"):
_parse_backends("kfd,none")
class TestCoreRun:
def test_closes_real_k7core(self):
from k7.core.core import K7Core
core = K7Core()
closed: list[bool] = []
async def aclose():
closed.append(True)
async def work():
return "ok"
core.aclose = aclose # type: ignore[method-assign]
assert _core_run(core, work()) == "ok"
assert closed == [True]
def test_skips_aclose_on_mocks(self):
fake = MagicMock()
async def work():
return 7
assert _core_run(fake, work()) == 7