mirror of
https://github.com/Rainyy21/k3s-health-dashboard.git
synced 2026-10-10 23:00:19 -04:00
fix syntax error and it pass the test case
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+140
-8
@@ -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)
|
||||
|
||||
+3
-26
@@ -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()
|
||||
|
||||
+4
-27
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user