kubectl-mcp-server 1.15.0__py3-none-any.whl → 1.17.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/METADATA +34 -13
- kubectl_mcp_server-1.17.0.dist-info/RECORD +75 -0
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/cli/cli.py +83 -9
- kubectl_mcp_tool/cli/output.py +14 -0
- kubectl_mcp_tool/config/__init__.py +46 -0
- kubectl_mcp_tool/config/loader.py +386 -0
- kubectl_mcp_tool/config/schema.py +184 -0
- kubectl_mcp_tool/crd_detector.py +247 -0
- kubectl_mcp_tool/k8s_config.py +19 -0
- kubectl_mcp_tool/mcp_server.py +246 -8
- kubectl_mcp_tool/observability/__init__.py +59 -0
- kubectl_mcp_tool/observability/metrics.py +223 -0
- kubectl_mcp_tool/observability/stats.py +255 -0
- kubectl_mcp_tool/observability/tracing.py +335 -0
- kubectl_mcp_tool/prompts/__init__.py +43 -0
- kubectl_mcp_tool/prompts/builtin.py +695 -0
- kubectl_mcp_tool/prompts/custom.py +298 -0
- kubectl_mcp_tool/prompts/prompts.py +180 -4
- kubectl_mcp_tool/safety.py +155 -0
- kubectl_mcp_tool/tools/__init__.py +20 -0
- kubectl_mcp_tool/tools/backup.py +881 -0
- kubectl_mcp_tool/tools/capi.py +727 -0
- kubectl_mcp_tool/tools/certs.py +709 -0
- kubectl_mcp_tool/tools/cilium.py +582 -0
- kubectl_mcp_tool/tools/cluster.py +384 -0
- kubectl_mcp_tool/tools/gitops.py +552 -0
- kubectl_mcp_tool/tools/keda.py +464 -0
- kubectl_mcp_tool/tools/kiali.py +652 -0
- kubectl_mcp_tool/tools/kubevirt.py +803 -0
- kubectl_mcp_tool/tools/policy.py +554 -0
- kubectl_mcp_tool/tools/rollouts.py +790 -0
- tests/test_browser.py +2 -2
- tests/test_config.py +386 -0
- tests/test_ecosystem.py +331 -0
- tests/test_mcp_integration.py +251 -0
- tests/test_observability.py +521 -0
- tests/test_prompts.py +716 -0
- tests/test_safety.py +218 -0
- tests/test_tools.py +70 -8
- kubectl_mcp_server-1.15.0.dist-info/RECORD +0 -49
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/top_level.txt +0 -0
|
@@ -7,6 +7,7 @@ All tools support multi-cluster operations via the optional 'context' parameter.
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import subprocess
|
|
11
12
|
from typing import Any, Dict, List, Optional
|
|
12
13
|
|
|
@@ -30,6 +31,31 @@ def _get_kubectl_context_args(context: str = "") -> List[str]:
|
|
|
30
31
|
return []
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
# DNS-1123 subdomain regex for node name validation
|
|
35
|
+
_DNS_1123_PATTERN = re.compile(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_node_name(name: str) -> tuple:
|
|
39
|
+
"""Validate that a node name follows DNS-1123 subdomain rules.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
name: Node name to validate
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tuple of (is_valid: bool, error_message: Optional[str])
|
|
46
|
+
"""
|
|
47
|
+
if not name:
|
|
48
|
+
return False, "Node name cannot be empty"
|
|
49
|
+
if len(name) > 253:
|
|
50
|
+
return False, f"Node name too long: {len(name)} chars (max 253)"
|
|
51
|
+
if not _DNS_1123_PATTERN.match(name):
|
|
52
|
+
return False, (
|
|
53
|
+
f"Invalid node name '{name}': must be a valid DNS-1123 subdomain "
|
|
54
|
+
"(lowercase alphanumeric, '-' or '.', must start/end with alphanumeric)"
|
|
55
|
+
)
|
|
56
|
+
return True, None
|
|
57
|
+
|
|
58
|
+
|
|
33
59
|
def register_cluster_tools(server, non_destructive: bool):
|
|
34
60
|
"""Register cluster and context management tools."""
|
|
35
61
|
|
|
@@ -587,3 +613,361 @@ def register_cluster_tools(server, non_destructive: bool):
|
|
|
587
613
|
except Exception as e:
|
|
588
614
|
logger.error(f"Error getting nodes summary: {e}")
|
|
589
615
|
return {"success": False, "error": str(e)}
|
|
616
|
+
|
|
617
|
+
# ========== Node Kubelet Tools ==========
|
|
618
|
+
|
|
619
|
+
@server.tool(
|
|
620
|
+
annotations=ToolAnnotations(
|
|
621
|
+
title="Get Node Logs",
|
|
622
|
+
readOnlyHint=True,
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
def node_logs_tool(
|
|
626
|
+
name: str,
|
|
627
|
+
query: str = "kubelet",
|
|
628
|
+
tail_lines: int = 100,
|
|
629
|
+
context: str = ""
|
|
630
|
+
) -> Dict[str, Any]:
|
|
631
|
+
"""Get logs from a Kubernetes node via kubelet API proxy.
|
|
632
|
+
|
|
633
|
+
This tool retrieves logs from a node's kubelet service or system log files.
|
|
634
|
+
Common service names: kubelet, kube-proxy, containerd, docker.
|
|
635
|
+
For file paths, use the full path like /var/log/syslog or /var/log/messages.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
name: Node name
|
|
639
|
+
query: Service name (kubelet, kube-proxy) or log file path (/var/log/syslog)
|
|
640
|
+
tail_lines: Number of lines from end (0 = all lines)
|
|
641
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
# Validate node name
|
|
645
|
+
is_valid, error_msg = _validate_node_name(name)
|
|
646
|
+
if not is_valid:
|
|
647
|
+
return {"success": False, "error": error_msg}
|
|
648
|
+
|
|
649
|
+
ctx_args = _get_kubectl_context_args(context)
|
|
650
|
+
|
|
651
|
+
# Build the proxy URL path
|
|
652
|
+
log_path = query.lstrip("/") if query.startswith("/var/log") else query
|
|
653
|
+
|
|
654
|
+
# Use kubectl proxy to access kubelet logs
|
|
655
|
+
cmd = ["kubectl"] + ctx_args + [
|
|
656
|
+
"get", "--raw",
|
|
657
|
+
f"/api/v1/nodes/{name}/proxy/logs/{log_path}"
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
661
|
+
|
|
662
|
+
if result.returncode != 0:
|
|
663
|
+
error_msg = result.stderr.strip()
|
|
664
|
+
if "not found" in error_msg.lower():
|
|
665
|
+
return {
|
|
666
|
+
"success": False,
|
|
667
|
+
"error": f"Node '{name}' not found or log path '{query}' is invalid",
|
|
668
|
+
"hint": "Try: kubelet, kube-proxy, or /var/log/syslog"
|
|
669
|
+
}
|
|
670
|
+
return {"success": False, "error": error_msg}
|
|
671
|
+
|
|
672
|
+
# Handle empty or None output
|
|
673
|
+
logs = result.stdout or ""
|
|
674
|
+
if not logs.strip():
|
|
675
|
+
lines = []
|
|
676
|
+
total_lines = 0
|
|
677
|
+
else:
|
|
678
|
+
lines = logs.splitlines()
|
|
679
|
+
total_lines = len(lines)
|
|
680
|
+
|
|
681
|
+
# Apply tail_lines if specified
|
|
682
|
+
if tail_lines > 0 and len(lines) > tail_lines:
|
|
683
|
+
lines = lines[-tail_lines:]
|
|
684
|
+
truncated = True
|
|
685
|
+
else:
|
|
686
|
+
truncated = False
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
"success": True,
|
|
690
|
+
"context": context or "current",
|
|
691
|
+
"node": name,
|
|
692
|
+
"query": query,
|
|
693
|
+
"tailLines": tail_lines,
|
|
694
|
+
"truncated": truncated,
|
|
695
|
+
"totalLines": total_lines,
|
|
696
|
+
"returnedLines": len(lines),
|
|
697
|
+
"logs": "\n".join(lines)
|
|
698
|
+
}
|
|
699
|
+
except subprocess.TimeoutExpired:
|
|
700
|
+
return {"success": False, "error": "Log retrieval timed out after 60 seconds"}
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logger.error(f"Error getting node logs: {e}")
|
|
703
|
+
return {"success": False, "error": str(e)}
|
|
704
|
+
|
|
705
|
+
@server.tool(
|
|
706
|
+
annotations=ToolAnnotations(
|
|
707
|
+
title="Get Node Stats Summary",
|
|
708
|
+
readOnlyHint=True,
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
def node_stats_summary_tool(
|
|
712
|
+
name: str,
|
|
713
|
+
context: str = ""
|
|
714
|
+
) -> Dict[str, Any]:
|
|
715
|
+
"""Get resource usage statistics from node via kubelet Summary API.
|
|
716
|
+
|
|
717
|
+
Returns CPU, memory, filesystem, and network usage at node, pod, and
|
|
718
|
+
container levels. On systems with cgroup v2 and kernel 4.20+, may include
|
|
719
|
+
PSI (Pressure Stall Information) metrics.
|
|
720
|
+
|
|
721
|
+
This provides more detailed metrics than 'kubectl top nodes' including:
|
|
722
|
+
- Node-level: CPU, memory, filesystem, network, runtime stats
|
|
723
|
+
- Pod-level: CPU, memory, network, volume stats for each pod
|
|
724
|
+
- Container-level: CPU, memory, rootfs, logs usage for each container
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
name: Node name
|
|
728
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
729
|
+
"""
|
|
730
|
+
try:
|
|
731
|
+
# Validate node name
|
|
732
|
+
is_valid, error_msg = _validate_node_name(name)
|
|
733
|
+
if not is_valid:
|
|
734
|
+
return {"success": False, "error": error_msg}
|
|
735
|
+
|
|
736
|
+
ctx_args = _get_kubectl_context_args(context)
|
|
737
|
+
|
|
738
|
+
cmd = ["kubectl"] + ctx_args + [
|
|
739
|
+
"get", "--raw",
|
|
740
|
+
f"/api/v1/nodes/{name}/proxy/stats/summary"
|
|
741
|
+
]
|
|
742
|
+
|
|
743
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
744
|
+
|
|
745
|
+
if result.returncode != 0:
|
|
746
|
+
error_msg = result.stderr.strip()
|
|
747
|
+
if "not found" in error_msg.lower():
|
|
748
|
+
return {"success": False, "error": f"Node '{name}' not found"}
|
|
749
|
+
return {"success": False, "error": error_msg}
|
|
750
|
+
|
|
751
|
+
stats = json.loads(result.stdout)
|
|
752
|
+
node_stats = stats.get("node", {})
|
|
753
|
+
pods_stats = stats.get("pods", [])
|
|
754
|
+
|
|
755
|
+
formatted_node = {
|
|
756
|
+
"nodeName": node_stats.get("nodeName"),
|
|
757
|
+
"startTime": node_stats.get("startTime"),
|
|
758
|
+
"cpu": _format_cpu_stats(node_stats.get("cpu", {})),
|
|
759
|
+
"memory": _format_memory_stats(node_stats.get("memory", {})),
|
|
760
|
+
"network": _format_network_stats(node_stats.get("network", {})),
|
|
761
|
+
"fs": _format_fs_stats(node_stats.get("fs", {})),
|
|
762
|
+
"runtime": _format_runtime_stats(node_stats.get("runtime", {})),
|
|
763
|
+
"rlimit": node_stats.get("rlimit", {}),
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
# Truncation limits
|
|
767
|
+
pod_limit = 50
|
|
768
|
+
container_limit = 5
|
|
769
|
+
|
|
770
|
+
formatted_pods = []
|
|
771
|
+
for pod in pods_stats[:pod_limit]:
|
|
772
|
+
containers = pod.get("containers", [])
|
|
773
|
+
pod_summary = {
|
|
774
|
+
"podRef": pod.get("podRef", {}),
|
|
775
|
+
"startTime": pod.get("startTime"),
|
|
776
|
+
"cpu": _format_cpu_stats(pod.get("cpu", {})),
|
|
777
|
+
"memory": _format_memory_stats(pod.get("memory", {})),
|
|
778
|
+
"network": _format_network_stats(pod.get("network", {})),
|
|
779
|
+
"containers": [],
|
|
780
|
+
"containersTruncated": len(containers) > container_limit
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
for container in containers[:container_limit]:
|
|
784
|
+
pod_summary["containers"].append({
|
|
785
|
+
"name": container.get("name"),
|
|
786
|
+
"cpu": _format_cpu_stats(container.get("cpu", {})),
|
|
787
|
+
"memory": _format_memory_stats(container.get("memory", {})),
|
|
788
|
+
"rootfs": _format_fs_stats(container.get("rootfs", {})),
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
formatted_pods.append(pod_summary)
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
"success": True,
|
|
795
|
+
"context": context or "current",
|
|
796
|
+
"nodeName": name,
|
|
797
|
+
"nodeStats": formatted_node,
|
|
798
|
+
"podCount": len(pods_stats),
|
|
799
|
+
"pods": formatted_pods,
|
|
800
|
+
"podLimit": pod_limit,
|
|
801
|
+
"containerLimit": container_limit,
|
|
802
|
+
"podsTruncated": len(pods_stats) > pod_limit,
|
|
803
|
+
"rawStatsAvailable": True
|
|
804
|
+
}
|
|
805
|
+
except json.JSONDecodeError as e:
|
|
806
|
+
return {"success": False, "error": f"Failed to parse stats: {e}"}
|
|
807
|
+
except subprocess.TimeoutExpired:
|
|
808
|
+
return {"success": False, "error": "Stats retrieval timed out after 120 seconds"}
|
|
809
|
+
except Exception as e:
|
|
810
|
+
logger.error(f"Error getting node stats summary: {e}")
|
|
811
|
+
return {"success": False, "error": str(e)}
|
|
812
|
+
|
|
813
|
+
@server.tool(
|
|
814
|
+
annotations=ToolAnnotations(
|
|
815
|
+
title="Get Node Top",
|
|
816
|
+
readOnlyHint=True,
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
def node_top_tool(
|
|
820
|
+
name: str = "",
|
|
821
|
+
label_selector: str = "",
|
|
822
|
+
context: str = ""
|
|
823
|
+
) -> Dict[str, Any]:
|
|
824
|
+
"""Get resource consumption (CPU, memory) for nodes from Metrics Server.
|
|
825
|
+
|
|
826
|
+
Similar to 'kubectl top nodes' command. Requires metrics-server to be
|
|
827
|
+
installed in the cluster.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
name: Specific node name (optional, all nodes if empty)
|
|
831
|
+
label_selector: Label selector to filter nodes (e.g., 'node-role.kubernetes.io/control-plane')
|
|
832
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
833
|
+
"""
|
|
834
|
+
try:
|
|
835
|
+
ctx_args = _get_kubectl_context_args(context)
|
|
836
|
+
cmd = ["kubectl"] + ctx_args + ["top", "nodes", "--no-headers"]
|
|
837
|
+
|
|
838
|
+
if label_selector:
|
|
839
|
+
cmd.extend(["-l", label_selector])
|
|
840
|
+
|
|
841
|
+
if name:
|
|
842
|
+
cmd.append(name)
|
|
843
|
+
|
|
844
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
845
|
+
|
|
846
|
+
if result.returncode != 0:
|
|
847
|
+
error_msg = result.stderr.strip()
|
|
848
|
+
if "metrics" in error_msg.lower() or "not available" in error_msg.lower():
|
|
849
|
+
return {
|
|
850
|
+
"success": False,
|
|
851
|
+
"error": "Metrics server not available or not ready",
|
|
852
|
+
"hint": "Install metrics-server: kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml"
|
|
853
|
+
}
|
|
854
|
+
if "not found" in error_msg.lower() and name:
|
|
855
|
+
return {"success": False, "error": f"Node '{name}' not found"}
|
|
856
|
+
return {"success": False, "error": error_msg}
|
|
857
|
+
|
|
858
|
+
metrics = []
|
|
859
|
+
for line in result.stdout.strip().split("\n"):
|
|
860
|
+
if not line.strip():
|
|
861
|
+
continue
|
|
862
|
+
parts = line.split()
|
|
863
|
+
if len(parts) >= 5:
|
|
864
|
+
node_metric = {
|
|
865
|
+
"node": parts[0],
|
|
866
|
+
"cpuCores": parts[1],
|
|
867
|
+
"cpuPercent": parts[2].rstrip("%"),
|
|
868
|
+
"memoryBytes": parts[3],
|
|
869
|
+
"memoryPercent": parts[4].rstrip("%"),
|
|
870
|
+
}
|
|
871
|
+
metrics.append(node_metric)
|
|
872
|
+
|
|
873
|
+
# Use separate counters for valid samples to avoid skewing average
|
|
874
|
+
total_cpu_percent = 0.0
|
|
875
|
+
total_memory_percent = 0.0
|
|
876
|
+
cpu_samples = 0
|
|
877
|
+
memory_samples = 0
|
|
878
|
+
for m in metrics:
|
|
879
|
+
try:
|
|
880
|
+
total_cpu_percent += float(m["cpuPercent"])
|
|
881
|
+
cpu_samples += 1
|
|
882
|
+
except (ValueError, TypeError):
|
|
883
|
+
pass
|
|
884
|
+
try:
|
|
885
|
+
total_memory_percent += float(m["memoryPercent"])
|
|
886
|
+
memory_samples += 1
|
|
887
|
+
except (ValueError, TypeError):
|
|
888
|
+
pass
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
"success": True,
|
|
892
|
+
"context": context or "current",
|
|
893
|
+
"filter": {
|
|
894
|
+
"name": name or None,
|
|
895
|
+
"labelSelector": label_selector or None
|
|
896
|
+
},
|
|
897
|
+
"nodeCount": len(metrics),
|
|
898
|
+
"nodes": metrics,
|
|
899
|
+
"clusterAverage": {
|
|
900
|
+
"cpuPercent": round(total_cpu_percent / cpu_samples, 1) if cpu_samples else None,
|
|
901
|
+
"memoryPercent": round(total_memory_percent / memory_samples, 1) if memory_samples else None
|
|
902
|
+
} if cpu_samples > 1 or memory_samples > 1 else None
|
|
903
|
+
}
|
|
904
|
+
except subprocess.TimeoutExpired:
|
|
905
|
+
return {"success": False, "error": "Metrics retrieval timed out after 60 seconds"}
|
|
906
|
+
except Exception as e:
|
|
907
|
+
logger.error(f"Error getting node top metrics: {e}")
|
|
908
|
+
return {"success": False, "error": str(e)}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
# Helper functions for formatting kubelet stats
|
|
912
|
+
|
|
913
|
+
def _format_cpu_stats(cpu: Dict) -> Dict[str, Any]:
|
|
914
|
+
"""Format CPU statistics from kubelet stats."""
|
|
915
|
+
if not cpu:
|
|
916
|
+
return {}
|
|
917
|
+
return {
|
|
918
|
+
"time": cpu.get("time"),
|
|
919
|
+
"usageNanoCores": cpu.get("usageNanoCores"),
|
|
920
|
+
"usageCoreNanoSeconds": cpu.get("usageCoreNanoSeconds"),
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _format_memory_stats(memory: Dict) -> Dict[str, Any]:
|
|
925
|
+
"""Format memory statistics from kubelet stats."""
|
|
926
|
+
if not memory:
|
|
927
|
+
return {}
|
|
928
|
+
return {
|
|
929
|
+
"time": memory.get("time"),
|
|
930
|
+
"availableBytes": memory.get("availableBytes"),
|
|
931
|
+
"usageBytes": memory.get("usageBytes"),
|
|
932
|
+
"workingSetBytes": memory.get("workingSetBytes"),
|
|
933
|
+
"rssBytes": memory.get("rssBytes"),
|
|
934
|
+
"pageFaults": memory.get("pageFaults"),
|
|
935
|
+
"majorPageFaults": memory.get("majorPageFaults"),
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _format_network_stats(network: Dict) -> Dict[str, Any]:
|
|
940
|
+
"""Format network statistics from kubelet stats."""
|
|
941
|
+
if not network:
|
|
942
|
+
return {}
|
|
943
|
+
return {
|
|
944
|
+
"time": network.get("time"),
|
|
945
|
+
"rxBytes": network.get("rxBytes"),
|
|
946
|
+
"rxErrors": network.get("rxErrors"),
|
|
947
|
+
"txBytes": network.get("txBytes"),
|
|
948
|
+
"txErrors": network.get("txErrors"),
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _format_fs_stats(fs: Dict) -> Dict[str, Any]:
|
|
953
|
+
"""Format filesystem statistics from kubelet stats."""
|
|
954
|
+
if not fs:
|
|
955
|
+
return {}
|
|
956
|
+
return {
|
|
957
|
+
"time": fs.get("time"),
|
|
958
|
+
"availableBytes": fs.get("availableBytes"),
|
|
959
|
+
"capacityBytes": fs.get("capacityBytes"),
|
|
960
|
+
"usedBytes": fs.get("usedBytes"),
|
|
961
|
+
"inodesFree": fs.get("inodesFree"),
|
|
962
|
+
"inodes": fs.get("inodes"),
|
|
963
|
+
"inodesUsed": fs.get("inodesUsed"),
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _format_runtime_stats(runtime: Dict) -> Dict[str, Any]:
|
|
968
|
+
"""Format runtime (container runtime) statistics from kubelet stats."""
|
|
969
|
+
if not runtime:
|
|
970
|
+
return {}
|
|
971
|
+
return {
|
|
972
|
+
"imageFs": _format_fs_stats(runtime.get("imageFs", {})),
|
|
973
|
+
}
|