mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
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>
377 lines
14 KiB
Python
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
|