diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d6a85c4 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/config.cpython-313.pyc b/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..e839327 Binary files /dev/null and b/app/__pycache__/config.cpython-313.pyc differ diff --git a/app/__pycache__/k8s_client.cpython-313.pyc b/app/__pycache__/k8s_client.cpython-313.pyc new file mode 100644 index 0000000..4e534d2 Binary files /dev/null and b/app/__pycache__/k8s_client.cpython-313.pyc differ diff --git a/app/k8s_client.py b/app/k8s_client.py index 7122952..d9b0ec6 100644 --- a/app/k8s_client.py +++ b/app/k8s_client.py @@ -1,15 +1,147 @@ import os -import kubernetes import client, config +from datetime import datetime, timezone +from kubernetes import client, config -def get_core_client() -> client.CoreV1Api: - if os.getenv("IN_CLUSTER"): - config.load_incluster_config() - else: - config.load_kube_config( # runs locally - config_file=os.getenv("KUBECONFIG", "~/.kube/config") - ) +_CONFIG_LOADED = False + +def _ensure_config(): + global _CONFIG_LOADED + if _CONFIG_LOADED: + return + try: + if os.getenv("IN_CLUSTER"): + config.load_incluster_config() + else: + # Load local kubeconfig + config_path = os.getenv("KUBECONFIG", os.path.expanduser("~/.kube/config")) + config.load_kube_config(config_file=config_path) + except Exception: + # If we can't load config (e.g. in tests where we mock), we don't want to crash here + # but let the client creation fail later or be mocked. + pass + _CONFIG_LOADED = True + +def get_core(): + """ + Returns an initialized Kubernetes CoreV1Api client. + Matches the name expected by test fixtures. + """ + _ensure_config() return client.CoreV1Api() +def get_core_client() -> client.CoreV1Api: + """Legacy alias for get_core() if needed.""" + return get_core() + def get_custom_client() -> client.CustomObjectsApi: + _ensure_config() return client.CustomObjectsApi() +def fetch_nodes(): + v1 = get_core() + nodes = v1.list_node().items + result = [] + for n in nodes: + conditions = {c.type: c.status for c in n.status.conditions} if n.status.conditions else {} + + # Role logic + labels = n.metadata.labels or {} + roles = [] + if "node-role.kubernetes.io/control-plane" in labels: + roles.append("control-plane") + if "node-role.kubernetes.io/master" in labels: + roles.append("master") + + role_str = ", ".join(roles) if roles else "worker" + + result.append({ + "name": n.metadata.name, + "ready": conditions.get("Ready") == "True", + "roles": role_str, + "age": _format_age(n.metadata.creation_timestamp), + "cpu_cap": _parse_millicores(n.status.capacity.get("cpu", "0")), + "mem_cap_mi": _parse_mebibytes(n.status.capacity.get("memory", "0")), + "mem_alloc_mi": _parse_mebibytes(n.status.allocatable.get("memory", "0")), + "os": n.status.node_info.os_image, + "kubelet_version": n.status.node_info.kubelet_version, + "mem_pressure": conditions.get("MemoryPressure", "False"), + "disk_pressure": conditions.get("DiskPressure", "False"), + "pid_pressure": conditions.get("PIDPressure", "False"), + }) + return result + +def fetch_pods(namespace="all"): + v1 = get_core() + if namespace == "all": + pods = v1.list_pod_for_all_namespaces().items + else: + pods = v1.list_namespaced_pod(namespace=namespace).items + + result = [] + for p in pods: + result.append({ + "namespace": p.metadata.namespace, + "name": p.metadata.name, + "ready": _is_pod_ready(p), + "phase": p.status.phase or "Unknown", + "restarts": _get_pod_restarts(p), + "node": p.spec.node_name or "—", + "age": _format_age(p.metadata.creation_timestamp), + }) + return result + +def _is_pod_ready(pod): + if not pod.status.conditions: + return False + for c in pod.status.conditions: + if c.type == "Ready": + return c.status == "True" + return False + +def _get_pod_restarts(pod): + if not pod.status.container_statuses: + return 0 + return sum(c.restart_count for c in pod.status.container_statuses) + +def _format_age(ts): + if not ts: + return "—" + now = datetime.now(timezone.utc) + delta = now - ts + if delta.days > 0: + return f"{delta.days}d" + hours = delta.seconds // 3600 + if hours > 0: + return f"{hours}h" + minutes = delta.seconds // 60 + return f"{minutes}m" + +def _parse_millicores(val): + if not val: + return 0.0 + if isinstance(val, (int, float)): + return float(val) + if val.endswith("m"): + return float(val[:-1]) / 1000.0 + return float(val) + +def _parse_mebibytes(val): + if not val: + return 0.0 + if isinstance(val, (int, float)): + return float(val) + + # Simple parser for Ki, Mi, Gi, Ti + suffix_map = { + "Ki": 1/1024, + "Mi": 1, + "Gi": 1024, + "Ti": 1024*1024, + } + + for suffix, multiplier in suffix_map.items(): + if val.endswith(suffix): + return float(val[:-len(suffix)]) * multiplier + + # Bytes to MiB if no suffix + return float(val) / (1024 * 1024) diff --git a/app/routers/nodes.py b/app/routers/nodes.py index 88593b1..c4ab77c 100644 --- a/app/routers/nodes.py +++ b/app/routers/nodes.py @@ -1,31 +1,8 @@ from fastapi import APIRouter -from app.k8s_client import get_core_client +from app.k8s_client import fetch_nodes router = APIRouter(prefix="/nodes", tags=["nodes"]) - @router.get("/") -def list_node(): - v1 = get_core_client() - nodes = v1.list_node().items - result = [] - for n in nodes: - conditions = {c.type: c.status for c in n.status.conditions} - result.append( - { - "name": n.metadata.name, - "ready": conditions.get("Ready") == "True", - "cpu_capacity": n.status.capacity.get("cpu"), - "memory_capacity": n.status.capacity.get("memory"), - "labels": n.metadata.labels, - "age_days": _age(n.metadata.creation_timestamp), - } - ) - return result - - -def _age(ts) -> int: - from datetime import datetime, timezone - - now = datetime.now(timezone.utc) - return (now - ts).days +def list_nodes(): + return fetch_nodes() diff --git a/app/routers/pods.py b/app/routers/pods.py index 948ca7c..a8b0ac2 100644 --- a/app/routers/pods.py +++ b/app/routers/pods.py @@ -1,31 +1,8 @@ from fastapi import APIRouter, Query -from k8s_client import get_core_client +from app.k8s_client import fetch_pods -router = APIRouter(prefix="/pods", tags=["pods"]) +router = APIRouter(prefix="/pods", tags=["pods"]) @router.get("/") -def list_pods(namespace: str = Query("default")): - v1 = get_core_client() - pods = v1.list_namespaced_pod(namespace=namespace).items - return [ - { - "name": p.metadata.name, - "namespace": p.metadata.namespace, - "phase": p.status.phase, # Pending/Running/Succeeded/Failed - "ready": _is_ready(p), - "restarts": _restarts(p), - "node": p.spec.node_name, - } - for p in pods - ] - - -def _is_ready(pod) -> bool : - if not pod.status.conditions: - return False - for any(c.type == "Ready" and c.status == "True" for c in pod.status.conditions) - -def _restarts(pod) -> int: - if not pod.status.container_statuses: - return 0 - return sum(c.restart_count for c in pod.status.container_statuses) +def list_pods(namespace: str = Query("all")): + return fetch_pods(namespace=namespace) diff --git a/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..b5e7b87 Binary files /dev/null and b/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..090940e Binary files /dev/null and b/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_pods.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_pods.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..ca4ebaa Binary files /dev/null and b/tests/__pycache__/test_pods.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_pods.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_pods.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..ca4ebaa Binary files /dev/null and b/tests/__pycache__/test_pods.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/test_nodes.py b/tests/test_nodes.py index e69de29..dba0a1b 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -0,0 +1,213 @@ +""" +tests/test_nodes.py + +Unit tests for fetch_nodes(). +No real cluster needed — all Kubernetes API calls are mocked. + +Run: + pytest tests/test_nodes.py -v +""" + +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +from app.k8s_client import fetch_nodes, _parse_millicores, _parse_mebibytes + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +def _make_condition(type_: str, status: str): + c = MagicMock() + c.type = type_ + c.status = status + return c + + +def _make_node( + name="node-1", + ready_status="True", + cpu="4", + memory="8058752Ki", + alloc_memory="7542528Ki", + os_image="Ubuntu 22.04.3 LTS", + kubelet_version="v1.28.5+k3s1", + labels=None, + created_days_ago=10, + mem_pressure="False", + disk_pressure="False", + pid_pressure="False", +): + node = MagicMock() + node.metadata.name = name + node.metadata.labels = labels if labels is not None else {"node-role.kubernetes.io/control-plane": "true"} + node.metadata.creation_timestamp = datetime.now(timezone.utc) - timedelta( + days=created_days_ago + ) + + node.status.conditions = [ + _make_condition("Ready", ready_status), + _make_condition("MemoryPressure", mem_pressure), + _make_condition("DiskPressure", disk_pressure), + _make_condition("PIDPressure", pid_pressure), + ] + node.status.capacity = {"cpu": cpu, "memory": memory} + node.status.allocatable = {"cpu": cpu, "memory": alloc_memory} + node.status.node_info.os_image = os_image + node.status.node_info.kubelet_version = kubelet_version + return node + + +@pytest.fixture +def mock_core(monkeypatch): + """Patch get_core() so no real kubeconfig is needed.""" + mock = MagicMock() + monkeypatch.setattr("app.k8s_client.get_core", lambda: mock) + return mock + + +# ── Parser unit tests (no mock needed) ──────────────────────────────────────── + + +class TestParsers: + def test_millicores_with_suffix(self): + assert _parse_millicores("250m") == pytest.approx(0.25) + + def test_millicores_without_suffix(self): + assert _parse_millicores("4") == pytest.approx(4.0) + + def test_mebibytes_mi(self): + assert _parse_mebibytes("512Mi") == pytest.approx(512.0) + + def test_mebibytes_gi(self): + assert _parse_mebibytes("1Gi") == pytest.approx(1024.0) + + def test_mebibytes_ki(self): + assert _parse_mebibytes("1024Ki") == pytest.approx(1.0) + + def test_mebibytes_raw_bytes(self): + assert _parse_mebibytes("1048576") == pytest.approx(1.0) + + +# ── fetch_nodes() tests ──────────────────────────────────────────────────────── + + +class TestFetchNodes: + def test_returns_list_of_dicts(self, mock_core): + mock_core.list_node.return_value.items = [_make_node()] + result = fetch_nodes() + assert isinstance(result, list) + assert isinstance(result[0], dict) + + def test_ready_node(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(ready_status="True")] + node = fetch_nodes()[0] + assert node["ready"] is True + + def test_not_ready_node(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(ready_status="False")] + node = fetch_nodes()[0] + assert node["ready"] is False + + def test_ready_is_bool_not_string(self, mock_core): + """Regression: status comes back as string 'True' — must be cast to bool.""" + mock_core.list_node.return_value.items = [_make_node(ready_status="True")] + node = fetch_nodes()[0] + assert node["ready"] is True + assert not isinstance(node["ready"], str) + + def test_node_name(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(name="k3s-worker-1")] + assert fetch_nodes()[0]["name"] == "k3s-worker-1" + + def test_control_plane_role(self, mock_core): + mock_core.list_node.return_value.items = [ + _make_node(labels={"node-role.kubernetes.io/control-plane": "true"}) + ] + assert "control-plane" in fetch_nodes()[0]["roles"] + + def test_worker_role_fallback(self, mock_core): + """Nodes with no role label should fall back to 'worker'.""" + mock_core.list_node.return_value.items = [_make_node(labels={})] + assert fetch_nodes()[0]["roles"] == "worker" + + def test_memory_parsed_to_float(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(memory="8058752Ki")] + node = fetch_nodes()[0] + assert isinstance(node["mem_cap_mi"], float) + assert node["mem_cap_mi"] > 0 + + def test_cpu_parsed_to_float(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(cpu="4")] + assert fetch_nodes()[0]["cpu_cap"] == pytest.approx(4.0) + + def test_age_days(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(created_days_ago=5)] + assert fetch_nodes()[0]["age"] == "5d" + + def test_age_hours(self, mock_core): + node = _make_node() + node.metadata.creation_timestamp = datetime.now(timezone.utc) - timedelta( + hours=3 + ) + mock_core.list_node.return_value.items = [node] + assert fetch_nodes()[0]["age"] == "3h" + + def test_pressure_fields_present(self, mock_core): + mock_core.list_node.return_value.items = [_make_node()] + node = fetch_nodes()[0] + assert "mem_pressure" in node + assert "disk_pressure" in node + assert "pid_pressure" in node + + def test_memory_pressure_true(self, mock_core): + mock_core.list_node.return_value.items = [_make_node(mem_pressure="True")] + assert fetch_nodes()[0]["mem_pressure"] == "True" + + def test_multiple_nodes(self, mock_core): + mock_core.list_node.return_value.items = [ + _make_node(name="node-1"), + _make_node(name="node-2"), + ] + result = fetch_nodes() + assert len(result) == 2 + assert {r["name"] for r in result} == {"node-1", "node-2"} + + def test_empty_cluster(self, mock_core): + mock_core.list_node.return_value.items = [] + assert fetch_nodes() == [] + + def test_null_conditions_handled(self, mock_core): + """Nodes mid-join can have conditions=None — must not raise.""" + node = _make_node() + node.status.conditions = None + mock_core.list_node.return_value.items = [node] + result = fetch_nodes() + assert result[0]["ready"] is False + + def test_null_labels_handled(self, mock_core): + node = _make_node() + node.metadata.labels = None + mock_core.list_node.return_value.items = [node] + assert fetch_nodes()[0]["roles"] == "worker" + + def test_all_keys_present(self, mock_core): + """Every dict must have the same stable set of keys.""" + mock_core.list_node.return_value.items = [_make_node()] + node = fetch_nodes()[0] + expected_keys = { + "name", + "ready", + "roles", + "age", + "cpu_cap", + "mem_cap_mi", + "mem_alloc_mi", + "os", + "kubelet_version", + "mem_pressure", + "disk_pressure", + "pid_pressure", + } + assert expected_keys == set(node.keys()) diff --git a/tests/test_pods.py b/tests/test_pods.py index e69de29..69e7323 100644 --- a/tests/test_pods.py +++ b/tests/test_pods.py @@ -0,0 +1,248 @@ +""" +tests/test_pods.py + +Unit tests for fetch_pods(). +No real cluster needed — all Kubernetes API calls are mocked. + +Run: + pytest tests/test_pods.py -v +""" + +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock + +import pytest + +from app.k8s_client import fetch_pods + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +def _make_container_status(restart_count=0, ready=True): + cs = MagicMock() + cs.restart_count = restart_count + cs.ready = ready + return cs + + +def _make_pod( + name="pod-1", + namespace="default", + phase="Running", + ready_status="True", + restarts=0, + node_name="k3s-worker-1", + created_minutes_ago=60, + container_statuses=None, +): + pod = MagicMock() + pod.metadata.name = name + pod.metadata.namespace = namespace + pod.metadata.creation_timestamp = datetime.now(timezone.utc) - timedelta( + minutes=created_minutes_ago + ) + pod.status.phase = phase + pod.status.conditions = [ + _cond("Ready", ready_status), + _cond("ContainersReady", ready_status), + _cond("PodScheduled", "True"), + _cond("Initialized", "True"), + ] + pod.status.container_statuses = container_statuses or [ + _make_container_status(restart_count=restarts) + ] + pod.spec.node_name = node_name + return pod + + +def _cond(type_: str, status: str): + c = MagicMock() + c.type = type_ + c.status = status + return c + + +@pytest.fixture +def mock_core(monkeypatch): + mock = MagicMock() + monkeypatch.setattr("app.k8s_client.get_core", lambda: mock) + return mock + + +# ── fetch_pods() — return shape ──────────────────────────────────────────────── + + +class TestFetchPodsShape: + def test_returns_list_of_dicts(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [_make_pod()] + result = fetch_pods("all") + assert isinstance(result, list) + assert isinstance(result[0], dict) + + def test_all_keys_present(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [_make_pod()] + pod = fetch_pods("all")[0] + assert set(pod.keys()) == { + "namespace", + "name", + "ready", + "phase", + "restarts", + "node", + "age", + } + + def test_empty_result(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [] + assert fetch_pods("all") == [] + + +# ── fetch_pods() — namespace routing ────────────────────────────────────────── + + +class TestNamespaceRouting: + def test_all_namespace_uses_cluster_wide_call(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [] + fetch_pods("all") + mock_core.list_pod_for_all_namespaces.assert_called_once() + mock_core.list_namespaced_pod.assert_not_called() + + def test_specific_namespace_uses_namespaced_call(self, mock_core): + mock_core.list_namespaced_pod.return_value.items = [] + fetch_pods("kube-system") + mock_core.list_namespaced_pod.assert_called_once_with(namespace="kube-system") + mock_core.list_pod_for_all_namespaces.assert_not_called() + + def test_namespace_preserved_in_result(self, mock_core): + mock_core.list_namespaced_pod.return_value.items = [ + _make_pod(namespace="kube-system") + ] + pod = fetch_pods("kube-system")[0] + assert pod["namespace"] == "kube-system" + + +# ── fetch_pods() — readiness ─────────────────────────────────────────────────── + + +class TestPodReadiness: + def test_ready_pod(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(ready_status="True") + ] + assert fetch_pods("all")[0]["ready"] is True + + def test_not_ready_pod(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(ready_status="False") + ] + assert fetch_pods("all")[0]["ready"] is False + + def test_ready_is_bool_not_string(self, mock_core): + """Regression: condition.status is the string 'True', must be cast to bool.""" + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(ready_status="True") + ] + assert fetch_pods("all")[0]["ready"] is True + assert not isinstance(fetch_pods("all")[0]["ready"], str) + + def test_running_but_not_ready(self, mock_core): + """phase=Running does NOT mean ready. This is the key gotcha.""" + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(phase="Running", ready_status="False") + ] + pod = fetch_pods("all")[0] + assert pod["phase"] == "Running" + assert pod["ready"] is False # not the same thing + + +# ── fetch_pods() — phase ─────────────────────────────────────────────────────── + + +class TestPodPhase: + @pytest.mark.parametrize("phase", ["Running", "Pending", "Failed", "Succeeded"]) + def test_phase_preserved(self, mock_core, phase): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(phase=phase) + ] + assert fetch_pods("all")[0]["phase"] == phase + + def test_none_phase_becomes_unknown(self, mock_core): + pod = _make_pod() + pod.status.phase = None + mock_core.list_pod_for_all_namespaces.return_value.items = [pod] + assert fetch_pods("all")[0]["phase"] == "Unknown" + + +# ── fetch_pods() — restarts ──────────────────────────────────────────────────── + + +class TestPodRestarts: + def test_zero_restarts(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(restarts=0) + ] + assert fetch_pods("all")[0]["restarts"] == 0 + + def test_nonzero_restarts(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(restarts=7) + ] + assert fetch_pods("all")[0]["restarts"] == 7 + + def test_restarts_summed_across_containers(self, mock_core): + """Multi-container pods sum restarts from all containers.""" + pod = _make_pod( + container_statuses=[ + _make_container_status(restart_count=3), + _make_container_status(restart_count=5), + ] + ) + mock_core.list_pod_for_all_namespaces.return_value.items = [pod] + assert fetch_pods("all")[0]["restarts"] == 8 + + def test_null_container_statuses_handled(self, mock_core): + """Pending pods have no container_statuses yet — must not raise.""" + pod = _make_pod() + pod.status.container_statuses = None + mock_core.list_pod_for_all_namespaces.return_value.items = [pod] + assert fetch_pods("all")[0]["restarts"] == 0 + + +# ── fetch_pods() — edge cases ────────────────────────────────────────────────── + + +class TestPodEdgeCases: + def test_null_conditions_handled(self, mock_core): + pod = _make_pod() + pod.status.conditions = None + mock_core.list_pod_for_all_namespaces.return_value.items = [pod] + result = fetch_pods("all") + assert result[0]["ready"] is False + + def test_null_node_name(self, mock_core): + """Pending pods are not yet scheduled to a node.""" + pod = _make_pod() + pod.spec.node_name = None + mock_core.list_pod_for_all_namespaces.return_value.items = [pod] + assert fetch_pods("all")[0]["node"] == "—" + + def test_multiple_pods(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(name="pod-a", namespace="default"), + _make_pod(name="pod-b", namespace="kube-system"), + ] + result = fetch_pods("all") + assert len(result) == 2 + assert {r["name"] for r in result} == {"pod-a", "pod-b"} + + def test_age_minutes(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(created_minutes_ago=25) + ] + assert fetch_pods("all")[0]["age"] == "25m" + + def test_age_hours(self, mock_core): + mock_core.list_pod_for_all_namespaces.return_value.items = [ + _make_pod(created_minutes_ago=180) + ] + assert fetch_pods("all")[0]["age"] == "3h"