prole/tests/installer/test_topology.py
chrisfu dba8a2d1dc feat(installer): TDD stabilization for k3d install path; dual-cluster GKE TUI
Junie's session targeted the prompt "stabilize ./install.py -c conf/k3d.cfg
using strict TDD" — broad installer-side work, not the k3d-mirror Phase 3
brief I had filed (which she didn't pick up; phase-3 brief stays open). All
750 installer tests pass post-change.

What Junie produced:

  install.py                          (NEW) Top-level CLI entry point. Was
                                            imagined by the prompt but didn't
                                            exist; this commit makes it real.
  knoe/deployment.py                  (NEW) `KnoeDeployment` orchestrator for
                                            the k3s service-mode deploy pipeline.
                                            Wraps Ansible kubeconfig fetch,
                                            opentofu apply, init_*.sh post-apply
                                            scripts, and (optionally) supabase/
                                            deploy.sh.
  knoe/ui/screens/cluster.py          Dual-cluster GKE kubecontext UI: prod env
  knoe/ui/screens/cfg.py              now shows separate "App Cluster:" and
                                       "DB Cluster:" dropdowns instead of a
                                       single "Kubernetes Context:" combo.
                                       New _app_kubectx_combo + _db_kubectx_combo
                                       widgets; new app/db_cluster_kubecontext
                                       tk.StringVars.
  knoe/core/{actions,env,milestones}.py
  knoe/core/ops/storage.py
  knoe/config.py, knoe/knoe_conf.py   Plumbing changes for the dual-cluster
                                       kubecontext flow + storage-class topology
                                       detection cleanup.
  knoe/tools/cleanup_cnpg_storage.py  (NEW) Stand-alone cleanup utility.
  tools/dashboard.sh                  (NEW) Dashboard helper.
  conf/knoe.cfg                       (NEW) Master cfg generated by knoe_conf.
  conf/dev/                           (NEW) Dev-mode cfg directory.
  conf/port-mapping.cfg               Port mapping tweaks for k3d.
  tests/installer/* (8 files)         New + extended tests for the dual-cluster
  tests/test_database_options.py      TUI, kubecontext save flow, storage ops,
                                       topology detection, deploy helpers,
                                       database-options screen.

Issues found in Junie's working state and fixed here:

  1. install.py was a 11-line import shim with no shebang, no `chmod +x`,
     no `if __name__ == '__main__'` block. `./install.py -c conf/k3d.cfg`
     returned `Permission denied` and `python install.py` did nothing.
     Added `#!/usr/bin/env python3`, `chmod +x`, and a __main__ block
     that delegates to `knoe.ui.screens.main()`. `./install.py --help`
     now prints the canonical argparse help.

  2. knoe/deployment.py had FIVE `subprocess.run()` call sites with no
     `timeout=` argument (`_run_script`, `_run_cmd`, the Ansible playbook
     fetch, `tofu init`, `tofu apply`). A hung child process — typical
     failure mode is a script waiting on stdin or a stalled network
     call — would lock up the installer indefinitely. Added timeouts:
       - Ansible kubeconfig fetch: 120s
       - tofu init: 300s
       - tofu apply, _run_script, _run_cmd: bounded by new module
         constant `_MILESTONE_TIMEOUT` (default 1800s = 30 min, override
         via `KNOE_MILESTONE_TIMEOUT_SECONDS` env var).
     `subprocess.TimeoutExpired` is caught explicitly; on timeout the
     run helpers return exit code 124 (conventional timeout code).

  3. `conf/k3d.cfg` was corrupted with MagicMock string-reprs on disk:
        KNOE_CONF = <MagicMock name='Canvas().tk.call().strip()' id='4743999712'>
        argocd.node_selector = <MagicMock name='mock.StringVar().get().strip()' id='...'>
     Likely path: Junie ran `./install.py -c conf/k3d.cfg` interactively
     in a non-Tk environment (or with a partially-mocked widget set) and
     the installer's "save current state" path wrote the mock-objects'
     `__repr__` strings into the cfg file. This commit reverts the cfg
     to its pre-Junie state. **Followup: harden the cfg save path
     against non-string widget values** — track separately.

  4. The corrupted cfg caused the installer to call `os.makedirs()` on
     the mock-string values, producing 10 directories on disk literally
     named `<MagicMock name='Canvas().tk.call().strip()' id='4733210304'>/`
     etc., with 5–86 files of install artifacts inside each. Removed.

The "final step is timing out" the user reported was almost certainly
issue #2 above: install.py walked the milestone pipeline, hit one of
the unbounded subprocess.run calls, and the wrapped command (probably
supabase/deploy.sh, which Junie was reading for context when her
session timed out) hung. With the timeouts in place that path now
exits cleanly with rc=124 instead of locking up.

Verification:
  - pytest tests/installer/ -q                                   750 passed in ~25s
  - python3 -c "import knoe.deployment"                          imports clean
  - ./install.py --help                                          prints argparse help
  - find . -maxdepth 1 -type d -name '<MagicMock*' | wc -l       0
  - head -7 conf/k3d.cfg                                          clean (no MagicMock)

Out of scope for this commit (followups):
  - The cfg save-path that wrote mock-objects-as-strings (issue #3 root cause).
    Reproducer: launch the installer in an env where Tk widget vars are
    `unittest.mock.MagicMock` instances. The cfg save code should refuse to
    serialize non-str values rather than calling `str()` on a MagicMock.
  - The k3d-mirror Phase 3 brief (`docs/plans/junie/k3d-knoe-auth-pod-deploy.md`)
    is still open — Junie picked a different prompt this round.

Co-authored-by: Junie <junie@jetbrains.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:51:14 -07:00

377 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)
if args[:4] == ["-n", "kube-system", "delete", "pod"]:
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