Skip to content

Commit d1a2916

Browse files
committed
review(nodes): nodes_top retrieves Node resource consumption (metrics API)
Signed-off-by: Marc Nuri <[email protected]>
1 parent 7e3f71f commit d1a2916

File tree

4 files changed

+275
-280
lines changed

4 files changed

+275
-280
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ In case multi-cluster support is enabled (default) and you have access to multip
252252
- **nodes_stats_summary** - Get detailed resource usage statistics from a Kubernetes node via the kubelet's Summary API. Provides comprehensive metrics including CPU, memory, filesystem, and network usage at the node, pod, and container levels. On systems with cgroup v2 and kernel 4.20+, also includes PSI (Pressure Stall Information) metrics that show resource pressure for CPU, memory, and I/O. See https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ for details on PSI metrics
253253
- `name` (`string`) **(required)** - Name of the node to get stats from
254254

255+
- **nodes_top** - List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Nodes or all nodes in the cluster
256+
- `label_selector` (`string`) - Kubernetes label selector (e.g. 'node-role.kubernetes.io/worker=') to filter nodes by label (Optional, only applicable when name is not provided)
257+
- `name` (`string`) - Name of the Node to get the resource consumption from (Optional, all Nodes if not provided)
258+
255259
- **pods_list** - List all the Kubernetes pods in the current cluster from all namespaces
256260
- `labelSelector` (`string`) - Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label
257261

pkg/kubernetes/accesscontrol_clientset.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,29 @@ func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*r
6363
AbsPath(url...), nil
6464
}
6565

66+
func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) {
67+
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"}
68+
if !isAllowed(a.staticConfig, gvk) {
69+
return nil, isNotAllowedError(gvk)
70+
}
71+
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
72+
var err error
73+
if name != "" {
74+
m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{})
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err)
77+
}
78+
versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m}
79+
} else {
80+
versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to list node metrics: %w", err)
83+
}
84+
}
85+
convertedMetrics := &metrics.NodeMetricsList{}
86+
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil)
87+
}
88+
6689
func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) {
6790
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
6891
if !isAllowed(a.staticConfig, gvk) {
@@ -137,29 +160,6 @@ func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, n
137160
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
138161
}
139162

140-
func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) {
141-
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"}
142-
if !isAllowed(a.staticConfig, gvk) {
143-
return nil, isNotAllowedError(gvk)
144-
}
145-
versionedMetrics := &metricsv1beta1api.NodeMetricsList{}
146-
var err error
147-
if name != "" {
148-
m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{})
149-
if err != nil {
150-
return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err)
151-
}
152-
versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m}
153-
} else {
154-
versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions)
155-
if err != nil {
156-
return nil, fmt.Errorf("failed to list node metrics: %w", err)
157-
}
158-
}
159-
convertedMetrics := &metrics.NodeMetricsList{}
160-
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil)
161-
}
162-
163163
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
164164
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
165165
if !isAllowed(a.staticConfig, gvk) {

pkg/mcp/nodes_test.go

Lines changed: 0 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -331,263 +331,6 @@ func (s *NodesSuite) TestNodesStatsSummaryDenied() {
331331
})
332332
}
333333

334-
func (s *NodesSuite) TestNodesTop() {
335-
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
336-
w.Header().Set("Content-Type", "application/json")
337-
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
338-
if req.URL.Path == "/api" {
339-
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
340-
return
341-
}
342-
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
343-
if req.URL.Path == "/apis" {
344-
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"metrics.k8s.io","versions":[{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}],"preferredVersion":{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}}]}`))
345-
return
346-
}
347-
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
348-
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
349-
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"nodes","singularName":"","namespaced":false,"kind":"NodeMetrics","verbs":["get","list"]}]}`))
350-
return
351-
}
352-
// List Nodes
353-
if req.URL.Path == "/api/v1/nodes" {
354-
_, _ = w.Write([]byte(`{
355-
"apiVersion": "v1",
356-
"kind": "NodeList",
357-
"items": [
358-
{
359-
"metadata": {
360-
"name": "node-1",
361-
"labels": {
362-
"node-role.kubernetes.io/worker": ""
363-
}
364-
},
365-
"status": {
366-
"allocatable": {
367-
"cpu": "4",
368-
"memory": "16Gi"
369-
},
370-
"nodeInfo": {
371-
"swap": {
372-
"capacity": 0
373-
}
374-
}
375-
}
376-
},
377-
{
378-
"metadata": {
379-
"name": "node-2",
380-
"labels": {
381-
"node-role.kubernetes.io/worker": ""
382-
}
383-
},
384-
"status": {
385-
"allocatable": {
386-
"cpu": "4",
387-
"memory": "16Gi"
388-
},
389-
"nodeInfo": {
390-
"swap": {
391-
"capacity": 0
392-
}
393-
}
394-
}
395-
}
396-
]
397-
}`))
398-
return
399-
}
400-
// Get NodeMetrics
401-
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" {
402-
_, _ = w.Write([]byte(`{
403-
"apiVersion": "metrics.k8s.io/v1beta1",
404-
"kind": "NodeMetricsList",
405-
"items": [
406-
{
407-
"metadata": {
408-
"name": "node-1"
409-
},
410-
"timestamp": "2025-10-29T09:00:00Z",
411-
"window": "30s",
412-
"usage": {
413-
"cpu": "500m",
414-
"memory": "2Gi"
415-
}
416-
},
417-
{
418-
"metadata": {
419-
"name": "node-2"
420-
},
421-
"timestamp": "2025-10-29T09:00:00Z",
422-
"window": "30s",
423-
"usage": {
424-
"cpu": "1000m",
425-
"memory": "4Gi"
426-
}
427-
}
428-
]
429-
}`))
430-
return
431-
}
432-
// Get specific NodeMetrics
433-
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes/node-1" {
434-
_, _ = w.Write([]byte(`{
435-
"apiVersion": "metrics.k8s.io/v1beta1",
436-
"kind": "NodeMetrics",
437-
"metadata": {
438-
"name": "node-1"
439-
},
440-
"timestamp": "2025-10-29T09:00:00Z",
441-
"window": "30s",
442-
"usage": {
443-
"cpu": "500m",
444-
"memory": "2Gi"
445-
}
446-
}`))
447-
return
448-
}
449-
w.WriteHeader(http.StatusNotFound)
450-
}))
451-
s.InitMcpClient()
452-
453-
s.Run("nodes_top() - all nodes", func() {
454-
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
455-
s.Require().NotNil(toolResult, "toolResult should not be nil")
456-
s.Run("no error", func() {
457-
s.Falsef(toolResult.IsError, "call tool should succeed")
458-
s.Nilf(err, "call tool should not return error object")
459-
})
460-
s.Run("returns metrics for all nodes", func() {
461-
content := toolResult.Content[0].(mcp.TextContent).Text
462-
s.Contains(content, "node-1", "expected metrics to contain node-1")
463-
s.Contains(content, "node-2", "expected metrics to contain node-2")
464-
s.Contains(content, "CPU(cores)", "expected header with CPU column")
465-
s.Contains(content, "MEMORY(bytes)", "expected header with MEMORY column")
466-
})
467-
})
468-
469-
s.Run("nodes_top(name=node-1) - specific node", func() {
470-
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
471-
"name": "node-1",
472-
})
473-
s.Require().NotNil(toolResult, "toolResult should not be nil")
474-
s.Run("no error", func() {
475-
s.Falsef(toolResult.IsError, "call tool should succeed")
476-
s.Nilf(err, "call tool should not return error object")
477-
})
478-
s.Run("returns metrics for specific node", func() {
479-
content := toolResult.Content[0].(mcp.TextContent).Text
480-
s.Contains(content, "node-1", "expected metrics to contain node-1")
481-
s.Contains(content, "500m", "expected CPU usage of 500m")
482-
s.Contains(content, "2048Mi", "expected memory usage of 2048Mi")
483-
})
484-
})
485-
486-
s.Run("nodes_top(label_selector=node-role.kubernetes.io/worker=)", func() {
487-
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{
488-
"label_selector": "node-role.kubernetes.io/worker=",
489-
})
490-
s.Require().NotNil(toolResult, "toolResult should not be nil")
491-
s.Run("no error", func() {
492-
s.Falsef(toolResult.IsError, "call tool should succeed")
493-
s.Nilf(err, "call tool should not return error object")
494-
})
495-
s.Run("returns metrics for filtered nodes", func() {
496-
content := toolResult.Content[0].(mcp.TextContent).Text
497-
s.Contains(content, "node-1", "expected metrics to contain node-1")
498-
s.Contains(content, "node-2", "expected metrics to contain node-2")
499-
})
500-
})
501-
}
502-
503-
func (s *NodesSuite) TestNodesTopMetricsUnavailable() {
504-
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
505-
// List Nodes
506-
if req.URL.Path == "/api/v1/nodes" {
507-
w.Header().Set("Content-Type", "application/json")
508-
w.WriteHeader(http.StatusOK)
509-
_, _ = w.Write([]byte(`{
510-
"apiVersion": "v1",
511-
"kind": "NodeList",
512-
"items": [
513-
{
514-
"metadata": {
515-
"name": "node-1"
516-
},
517-
"status": {
518-
"allocatable": {
519-
"cpu": "4",
520-
"memory": "16Gi"
521-
}
522-
}
523-
}
524-
]
525-
}`))
526-
return
527-
}
528-
// Metrics server not available
529-
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" {
530-
w.WriteHeader(http.StatusNotFound)
531-
return
532-
}
533-
w.WriteHeader(http.StatusNotFound)
534-
}))
535-
s.InitMcpClient()
536-
537-
s.Run("nodes_top() - metrics unavailable", func() {
538-
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
539-
s.Require().NotNil(toolResult, "toolResult should not be nil")
540-
s.Run("has error", func() {
541-
s.Truef(toolResult.IsError, "call tool should fail when metrics unavailable")
542-
s.Nilf(err, "call tool should not return error object")
543-
})
544-
s.Run("describes metrics unavailable", func() {
545-
content := toolResult.Content[0].(mcp.TextContent).Text
546-
s.Contains(content, "failed to get nodes top", "expected error message about failing to get nodes top")
547-
})
548-
})
549-
}
550-
551-
func (s *NodesSuite) TestNodesTopDenied() {
552-
s.Require().NoError(toml.Unmarshal([]byte(`
553-
denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
554-
`), s.Cfg), "Expected to parse denied resources config")
555-
s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
556-
w.Header().Set("Content-Type", "application/json")
557-
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
558-
if req.URL.Path == "/api" {
559-
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
560-
return
561-
}
562-
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
563-
if req.URL.Path == "/apis" {
564-
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"metrics.k8s.io","versions":[{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}],"preferredVersion":{"groupVersion":"metrics.k8s.io/v1beta1","version":"v1beta1"}}]}`))
565-
return
566-
}
567-
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
568-
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
569-
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"nodes","singularName":"","namespaced":false,"kind":"NodeMetrics","verbs":["get","list"]}]}`))
570-
return
571-
}
572-
w.WriteHeader(http.StatusNotFound)
573-
}))
574-
s.InitMcpClient()
575-
576-
s.Run("nodes_top (denied)", func() {
577-
toolResult, err := s.CallTool("nodes_top", map[string]interface{}{})
578-
s.Require().NotNil(toolResult, "toolResult should not be nil")
579-
s.Run("has error", func() {
580-
s.Truef(toolResult.IsError, "call tool should fail")
581-
s.Nilf(err, "call tool should not return error object")
582-
})
583-
s.Run("describes denial", func() {
584-
expectedMessage := "failed to get nodes top: resource not allowed: metrics.k8s.io/v1beta1, Kind=NodeMetrics"
585-
s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
586-
"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
587-
})
588-
})
589-
}
590-
591334
func TestNodes(t *testing.T) {
592335
suite.Run(t, new(NodesSuite))
593336
}

0 commit comments

Comments
 (0)