prole/tests/installer/test_topology.py
chrisfu 22420408f9 Align installer namespace/topology handling and add regression tests
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-29 23:45:56 -07:00

375 lines
14 KiB
Python

from __future__ import annotations
import json
import subprocess
from knoe.core import topology as topology_mod
from knoe.core import storage_probe as storage_probe_mod
def _completed(args: list[str], rc: int = 0, out: str = "", err: str = "") -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(args=args, returncode=rc, stdout=out, stderr=err)
def test_extract_inventory_from_marked_log_payload() -> None:
payload = {
"nodeName": "myrddin",
"cpuModel": "Intel",
"filesystems": [{"mountpoint": "/synology/d001", "availableBytes": 123}],
}
text = (
"hello\n"
f"{topology_mod.INVENTORY_BEGIN_MARKER}\n"
f"{json.dumps(payload)}\n"
f"{topology_mod.INVENTORY_END_MARKER}\n"
)
parsed = topology_mod._extract_inventory_from_log(text)
assert parsed == payload
def test_merge_node_monitoring_eligible_with_synology_d004() -> None:
k8s_node = {
"name": "merlin",
"roles": ("worker",),
"labels": {
"kubernetes.io/arch": "amd64",
"kubernetes.io/os": "linux",
},
"taints": (),
"ready": True,
"internal_ip": "10.0.0.11",
"architecture": "amd64",
"operating_system": "linux",
"allocatable_cpu": "4",
"allocatable_memory_bytes": 16 * 1024 * 1024 * 1024,
"allocatable_ephemeral_storage_bytes": 100 * 1024 * 1024 * 1024,
}
inventory = {
"filesystems": [
{
"device": "/dev/sdb",
"mountpoint": "/synology/d004",
"fstype": "ext4",
"sizeBytes": 512 * 1024 * 1024 * 1024,
"usedBytes": 128 * 1024 * 1024 * 1024,
"availableBytes": 384 * 1024 * 1024 * 1024,
"transport": "sata",
"rotational": True,
"storageClass": "synology",
}
]
}
merged = topology_mod._merge_node(
k8s_node,
inventory=inventory,
required_monitoring_mountpoints=("/synology/d001",),
monitoring_min_free_bytes=200 * 1024 * 1024 * 1024,
)
assert merged.capabilities.matching_mountpoints == ("/synology/d004",)
assert merged.capabilities.monitoring_eligible is True
def test_discover_cluster_topology_partial_results_and_xml(tmp_path, monkeypatch) -> None:
nodes_payload = {
"items": [
{
"metadata": {
"name": "myrddin",
"labels": {
"kubernetes.io/arch": "amd64",
"kubernetes.io/os": "linux",
"node-role.kubernetes.io/control-plane": "",
},
},
"spec": {},
"status": {
"conditions": [{"type": "Ready", "status": "True"}],
"addresses": [{"type": "InternalIP", "address": "10.0.0.10"}],
"allocatable": {
"cpu": "4",
"memory": "16Gi",
"ephemeral-storage": "100Gi",
},
},
},
{
"metadata": {
"name": "merlin",
"labels": {
"kubernetes.io/arch": "amd64",
"kubernetes.io/os": "linux",
},
},
"spec": {},
"status": {
"conditions": [{"type": "Ready", "status": "True"}],
"addresses": [{"type": "InternalIP", "address": "10.0.0.11"}],
"allocatable": {
"cpu": "4",
"memory": "16Gi",
"ephemeral-storage": "100Gi",
},
},
},
]
}
pods_payload = {
"items": [
{"metadata": {"name": "collector-myrddin"}, "spec": {"nodeName": "myrddin"}},
{"metadata": {"name": "collector-merlin"}, "spec": {"nodeName": "merlin"}},
]
}
def fake_kubectl_json(_base_cmd, args, **_kwargs):
if args[:2] == ["get", "nodes"]:
return nodes_payload
if "pods" in args:
return pods_payload
raise AssertionError(f"unexpected kubectl json args: {args}")
def fake_run_kubectl(_base_cmd, args, **_kwargs):
if args[:4] == ["-n", "kube-system", "apply", "-f"]:
return _completed(args)
if args[:4] == ["-n", "kube-system", "logs", "collector-myrddin"]:
body = {
"nodeName": "myrddin",
"cpuModel": "Intel i7",
"physicalCores": 6,
"logicalCores": 12,
"memoryBytes": 17179869184,
"filesystems": [
{
"device": "/dev/nvme0n1p1",
"mountpoint": "/synology/d001",
"fstype": "ext4",
"sizeBytes": 1000,
"usedBytes": 100,
"availableBytes": 900,
"transport": "nvme",
"rotational": False,
"storageClass": "nvme",
}
],
}
body_json = json.dumps(body, separators=(",", ":"), sort_keys=True)
text = (
f"{topology_mod.INVENTORY_BEGIN_MARKER}\n"
f"{body_json}\n"
f"{topology_mod.INVENTORY_END_MARKER}\n"
)
return _completed(args, out=text)
if args[:4] == ["-n", "kube-system", "logs", "collector-merlin"]:
return _completed(args, out="collector still warming up\n")
if args[:4] == ["-n", "kube-system", "delete", "daemonset"]:
return _completed(args)
raise AssertionError(f"unexpected kubectl run args: {args}")
clock = {"now": 0.0}
def fake_monotonic():
clock["now"] += 1.0
return clock["now"]
monkeypatch.setattr(topology_mod, "_kubectl_json", fake_kubectl_json)
monkeypatch.setattr(topology_mod, "_run_kubectl", fake_run_kubectl)
monkeypatch.setattr(topology_mod.time, "monotonic", fake_monotonic)
monkeypatch.setattr(topology_mod.time, "sleep", lambda _s: None)
result = topology_mod.discover_cluster_topology(
kubectl_base_cmd=["kubectl"],
topology_root=tmp_path / "topology",
config=topology_mod.TopologyDiscoveryConfig(
timeout_seconds=1,
poll_interval_seconds=0.01,
monitoring_min_free_bytes=100,
storage_probe_enabled=False,
),
)
assert result.topology.collection_status == "partial"
assert result.reported_nodes == ("myrddin",)
assert result.missing_nodes == ("merlin",)
myrddin = next(node for node in result.topology.nodes if node.name == "myrddin")
merlin = next(node for node in result.topology.nodes if node.name == "merlin")
assert myrddin.capabilities.monitoring_eligible is True
assert merlin.capabilities.monitoring_eligible is None
assert "topology_unavailable" in merlin.capabilities.reasons
assert (tmp_path / "topology" / "cluster.xml").is_file()
assert (tmp_path / "topology" / "nodes" / "myrddin.xml").is_file()
assert (tmp_path / "topology" / "nodes" / "merlin.xml").is_file()
def test_discover_cluster_topology_failed_collector_keeps_k8s_rows(tmp_path, monkeypatch) -> None:
nodes_payload = {
"items": [
{
"metadata": {
"name": "pi",
"labels": {"kubernetes.io/arch": "arm64", "kubernetes.io/os": "linux"},
},
"spec": {},
"status": {
"conditions": [{"type": "Ready", "status": "True"}],
"addresses": [{"type": "InternalIP", "address": "10.0.0.12"}],
"allocatable": {"cpu": "2", "memory": "4Gi", "ephemeral-storage": "20Gi"},
},
}
]
}
def fake_kubectl_json(_base_cmd, args, **_kwargs):
if args[:2] == ["get", "nodes"]:
return nodes_payload
raise AssertionError(f"unexpected kubectl json args: {args}")
def fake_run_kubectl(_base_cmd, args, **_kwargs):
if args[:4] == ["-n", "kube-system", "apply", "-f"]:
return _completed(args, rc=1, err="image pull failed")
return _completed(args)
monkeypatch.setattr(topology_mod, "_kubectl_json", fake_kubectl_json)
monkeypatch.setattr(topology_mod, "_run_kubectl", fake_run_kubectl)
result = topology_mod.discover_cluster_topology(
kubectl_base_cmd=["kubectl"],
topology_root=tmp_path / "topology",
config=topology_mod.TopologyDiscoveryConfig(storage_probe_enabled=False),
)
assert result.topology.collection_status == "failed"
assert result.topology.discovered_nodes == 1
node = result.topology.nodes[0]
assert node.name == "pi"
assert node.capabilities.monitoring_eligible is None
assert "topology_unavailable" in node.capabilities.reasons
assert (tmp_path / "topology" / "cluster.xml").is_file()
def test_discover_cluster_topology_includes_storage_items(tmp_path, monkeypatch) -> None:
nodes_payload = {
"items": [
{
"metadata": {
"name": "arthur",
"labels": {"kubernetes.io/arch": "amd64", "kubernetes.io/os": "linux"},
},
"spec": {},
"status": {
"conditions": [{"type": "Ready", "status": "True"}],
"addresses": [{"type": "InternalIP", "address": "10.0.0.20"}],
"allocatable": {"cpu": "4", "memory": "8Gi", "ephemeral-storage": "50Gi"},
},
}
]
}
def fake_kubectl_json(_base_cmd, args, **_kwargs):
if args[:2] == ["get", "nodes"]:
return nodes_payload
raise AssertionError(f"unexpected kubectl json args: {args}")
def fake_run_kubectl(_base_cmd, args, **_kwargs):
if args[:4] == ["-n", "kube-system", "apply", "-f"]:
return _completed(args, rc=1, err="collector disabled in test")
return _completed(args)
def fake_probe(node, **_kwargs):
item = storage_probe_mod.StorageProbeResult(
target_id="abc123",
node_name=node.name,
label="Synology d004",
mountpoint="/synology/d004",
source_type="synology_mount",
storage_class="synology",
succeeded=True,
skipped=False,
error=None,
metrics=storage_probe_mod.StorageProbeMetrics(
randread_iops=620.0,
randwrite_iops=240.0,
read_bw_bytes=2_539_520,
write_bw_bytes=983_040,
read_lat_ms_p95=18.0,
write_lat_ms_p95=24.7,
test_runtime_sec=8.1,
),
io_class="io_slow_durable",
timing_multiplier=3.0,
observed_at="2026-03-26T00:00:00+00:00",
)
return storage_probe_mod.NodeStorageInventory(node_name=node.name, items=[item])
monkeypatch.setattr(topology_mod, "_kubectl_json", fake_kubectl_json)
monkeypatch.setattr(topology_mod, "_run_kubectl", fake_run_kubectl)
monkeypatch.setattr(topology_mod, "probe_node_storage", fake_probe)
result = topology_mod.discover_cluster_topology(
kubectl_base_cmd=["kubectl"],
topology_root=tmp_path / "topology",
)
assert result.topology.discovered_nodes == 1
node = result.topology.nodes[0]
assert len(node.storage_items) == 1
assert node.storage_items[0].label == "Synology d004"
assert node.storage_items[0].io_class == "io_slow_durable"
def test_collector_fs_stats_permission_denied_does_not_abort_collection() -> None:
script = topology_mod._collector_python_script()
namespace: dict[str, object] = {}
exec(script, namespace)
class _PathWithDeniedExists:
def __init__(self, value: str) -> None:
self.value = value
def __truediv__(self, other: str):
base = self.value.rstrip("/")
child = str(other).lstrip("/")
return _PathWithDeniedExists(f"{base}/{child}" if base else f"/{child}")
def exists(self) -> bool:
raise PermissionError("denied")
def __str__(self) -> str:
return self.value
class _Stat:
f_frsize = 4096
f_blocks = 1000
f_bavail = 200
f_bfree = 300
def fake_statvfs(path: str):
if "run/user/1000/doc" in path:
raise PermissionError("denied")
return _Stat()
namespace["Path"] = _PathWithDeniedExists
namespace["_mount_rows"] = lambda: [
("/dev/sda1", "/run/user/1000/doc", "ext4"),
("/dev/sdb1", "/data", "ext4"),
]
namespace["_read_rotational"] = lambda _block_name: None
namespace["_read_transport"] = lambda *_args: "unknown"
namespace["_infer_storage_class"] = lambda *_args: "unknown"
namespace["os"].statvfs = fake_statvfs
filesystems = namespace["_collect_filesystems"]()
assert len(filesystems) == 2
first = filesystems[0]
second = filesystems[1]
assert first["mountpoint"] == "/run/user/1000/doc"
assert first["sizeBytes"] is None
assert first["usedBytes"] is None
assert first["availableBytes"] is None
assert second["mountpoint"] == "/data"
assert second["sizeBytes"] == 4096 * 1000
assert second["availableBytes"] == 4096 * 200