fix syntax error and it pass the test case

This commit is contained in:
Rainy21
2026-04-28 14:59:12 -04:00
parent 71a9858ddd
commit b59bc0749f
12 changed files with 608 additions and 61 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+137 -5
View File
@@ -1,15 +1,147 @@
import os import os
import kubernetes import client, config from datetime import datetime, timezone
from kubernetes import client, config
def get_core_client() -> client.CoreV1Api: _CONFIG_LOADED = False
def _ensure_config():
global _CONFIG_LOADED
if _CONFIG_LOADED:
return
try:
if os.getenv("IN_CLUSTER"): if os.getenv("IN_CLUSTER"):
config.load_incluster_config() config.load_incluster_config()
else: else:
config.load_kube_config( # runs locally # Load local kubeconfig
config_file=os.getenv("KUBECONFIG", "~/.kube/config") 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() 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: def get_custom_client() -> client.CustomObjectsApi:
_ensure_config()
return client.CustomObjectsApi() 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)
+3 -26
View File
@@ -1,31 +1,8 @@
from fastapi import APIRouter 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 = APIRouter(prefix="/nodes", tags=["nodes"])
@router.get("/") @router.get("/")
def list_node(): def list_nodes():
v1 = get_core_client() return fetch_nodes()
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
+3 -26
View File
@@ -1,31 +1,8 @@
from fastapi import APIRouter, Query 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("/") @router.get("/")
def list_pods(namespace: str = Query("default")): def list_pods(namespace: str = Query("all")):
v1 = get_core_client() return fetch_pods(namespace=namespace)
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)
+213
View File
@@ -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())
+248
View File
@@ -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"