Skip to content

Commit f931bcc

Browse files
committed
feat(pods): pods_top retrieves Pod resource consumption (metrics API)
1 parent 8478204 commit f931bcc

File tree

6 files changed

+285
-3
lines changed

6 files changed

+285
-3
lines changed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ require (
1818
k8s.io/cli-runtime v0.33.1
1919
k8s.io/client-go v0.33.1
2020
k8s.io/klog/v2 v2.130.1
21+
k8s.io/kubectl v0.33.0
22+
k8s.io/metrics v0.33.0
2123
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
2224
sigs.k8s.io/controller-runtime v0.21.0
2325
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
@@ -126,7 +128,6 @@ require (
126128
k8s.io/apiserver v0.33.1 // indirect
127129
k8s.io/component-base v0.33.1 // indirect
128130
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
129-
k8s.io/kubectl v0.33.0 // indirect
130131
oras.land/oras-go/v2 v2.5.0 // indirect
131132
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
132133
sigs.k8s.io/kustomize/api v0.19.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
462462
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
463463
k8s.io/kubectl v0.33.0 h1:HiRb1yqibBSCqic4pRZP+viiOBAnIdwYDpzUFejs07g=
464464
k8s.io/kubectl v0.33.0/go.mod h1:gAlGBuS1Jq1fYZ9AjGWbI/5Vk3M/VW2DK4g10Fpyn/0=
465+
k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y=
466+
k8s.io/metrics v0.33.0/go.mod h1:XewckTFXmE2AJiP7PT3EXaY7hi7bler3t2ZLyOdQYzU=
465467
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
466468
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
467469
oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=

pkg/kubernetes/pods.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package kubernetes
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
8+
"k8s.io/metrics/pkg/apis/metrics"
9+
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
10+
metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned"
711

812
"github.com/manusa/kubernetes-mcp-server/pkg/version"
913
v1 "k8s.io/api/core/v1"
@@ -18,6 +22,13 @@ import (
1822
"k8s.io/client-go/tools/remotecommand"
1923
)
2024

25+
type PodsTopOptions struct {
26+
metav1.ListOptions
27+
AllNamespaces bool
28+
Namespace string
29+
Name string
30+
}
31+
2132
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
2233
return k.ResourcesList(ctx, &schema.GroupVersionKind{
2334
Group: "", Version: "v1", Kind: "Pod",
@@ -175,6 +186,38 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
175186
return k.resourcesCreateOrUpdate(ctx, toCreate)
176187
}
177188

189+
func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, error) {
190+
// TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
191+
if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
192+
return nil, errors.New("metrics API is not available")
193+
}
194+
namespace := options.Namespace
195+
if options.AllNamespaces && namespace == "" {
196+
namespace = ""
197+
} else {
198+
namespace = k.NamespaceOrDefault(namespace)
199+
}
200+
metricsClient, err := metricsclientset.NewForConfig(k.cfg)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to create metrics client: %w", err)
203+
}
204+
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
205+
if options.Name != "" {
206+
m, err := metricsClient.MetricsV1beta1().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{})
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err)
209+
}
210+
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
211+
} else {
212+
versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, options.ListOptions)
213+
if err != nil {
214+
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
215+
}
216+
}
217+
convertedMetrics := &metrics.PodMetricsList{}
218+
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
219+
}
220+
178221
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
179222
namespace = k.NamespaceOrDefault(namespace)
180223
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})

pkg/mcp/pods.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package mcp
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
89
"github.com/manusa/kubernetes-mcp-server/pkg/output"
10+
"k8s.io/kubectl/pkg/metricsutil"
911

1012
"github.com/mark3labs/mcp-go/mcp"
1113
"github.com/mark3labs/mcp-go/server"
@@ -53,6 +55,19 @@ func (s *Server) initPods() []server.ServerTool {
5355
mcp.WithIdempotentHintAnnotation(true),
5456
mcp.WithOpenWorldHintAnnotation(true),
5557
), Handler: s.podsDelete},
58+
{Tool: mcp.NewTool("pods_top",
59+
mcp.WithDescription("List the resource consumption (CPU and memory) as recorded by the Kubernetes Metrics Server for the specified Kubernetes Pods in the all namespaces, the provided namespace, or the current namespace"),
60+
mcp.WithBoolean("all_namespaces", mcp.Description("If true, list the resource consumption for all Pods in all namespaces. If false, list the resource consumption for Pods in the provided namespace or the current namespace"), mcp.DefaultBool(true)),
61+
mcp.WithString("namespace", mcp.Description("Namespace to get the Pods resource consumption from (Optional, current namespace if not provided and all_namespaces is false)")),
62+
mcp.WithString("name", mcp.Description("Name of the Pod to get the resource consumption from (Optional, all Pods in the namespace if not provided)")),
63+
mcp.WithString("label_selector", mcp.Description("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 (Optional, only applicable when name is not provided)"), mcp.Pattern("([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]")),
64+
// Tool annotations
65+
mcp.WithTitleAnnotation("Pods: Top"),
66+
mcp.WithReadOnlyHintAnnotation(true),
67+
mcp.WithDestructiveHintAnnotation(false),
68+
mcp.WithIdempotentHintAnnotation(true),
69+
mcp.WithOpenWorldHintAnnotation(true),
70+
), Handler: s.podsTop},
5671
{Tool: mcp.NewTool("pods_exec",
5772
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
5873
mcp.WithString("namespace", mcp.Description("Namespace of the Pod where the command will be executed")),
@@ -125,10 +140,10 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques
125140
if ns == nil {
126141
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
127142
}
128-
labelSelector := ctr.GetArguments()["labelSelector"]
129143
resourceListOptions := kubernetes.ResourceListOptions{
130144
AsTable: s.configuration.ListOutput.AsTable(),
131145
}
146+
labelSelector := ctr.GetArguments()["labelSelector"]
132147
if labelSelector != nil {
133148
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
134149
}
@@ -171,6 +186,33 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.
171186
return NewTextResult(ret, err), nil
172187
}
173188

189+
func (s *Server) podsTop(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
190+
podsTopOptions := kubernetes.PodsTopOptions{AllNamespaces: true}
191+
if v, ok := ctr.GetArguments()["namespace"].(string); ok {
192+
podsTopOptions.Namespace = v
193+
}
194+
if v, ok := ctr.GetArguments()["all_namespaces"].(bool); ok {
195+
podsTopOptions.AllNamespaces = v
196+
}
197+
if v, ok := ctr.GetArguments()["name"].(string); ok {
198+
podsTopOptions.Name = v
199+
}
200+
if v, ok := ctr.GetArguments()["label_selector"].(string); ok {
201+
podsTopOptions.LabelSelector = v
202+
}
203+
ret, err := s.k.Derived(ctx).PodsTop(ctx, podsTopOptions)
204+
if err != nil {
205+
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
206+
}
207+
buf := new(bytes.Buffer)
208+
printer := metricsutil.NewTopCmdPrinter(buf)
209+
err = printer.PrintPodMetrics(ret.Items, true, true, false, "", true)
210+
if err != nil {
211+
return NewTextResult("", fmt.Errorf("failed to get pods top: %v", err)), nil
212+
}
213+
return NewTextResult(buf.String(), nil), nil
214+
}
215+
174216
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
175217
ns := ctr.GetArguments()["namespace"]
176218
if ns == nil {

pkg/mcp/pods_exec_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,5 @@ func TestPodsExec(t *testing.T) {
9797
t.Errorf("expected container name not found %v", podsExecInNamespaceAndContainer.Content[0].(mcp.TextContent).Text)
9898
}
9999
})
100-
101100
})
102101
}

pkg/mcp/pods_top_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package mcp
2+
3+
import (
4+
"github.com/mark3labs/mcp-go/mcp"
5+
"net/http"
6+
"regexp"
7+
"testing"
8+
)
9+
10+
func TestPodsTop(t *testing.T) {
11+
testCase(t, func(c *mcpContext) {
12+
mockServer := NewMockServer()
13+
defer mockServer.Close()
14+
c.withKubeConfig(mockServer.config)
15+
metricsApiAvailable := false
16+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
17+
println("Request received:", req.Method, req.URL.Path) // TODO: REMOVE LINE
18+
w.Header().Set("Content-Type", "application/json")
19+
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
20+
if req.URL.Path == "/api" {
21+
if !metricsApiAvailable {
22+
23+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
24+
} else {
25+
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
26+
}
27+
return
28+
}
29+
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
30+
if req.URL.Path == "/apis" {
31+
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
32+
return
33+
}
34+
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
35+
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
36+
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
37+
return
38+
}
39+
// Pod Metrics from all namespaces
40+
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" {
41+
if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" {
42+
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
43+
`{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi"}}]}` +
44+
`]}`))
45+
} else {
46+
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
47+
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi"}}]},` +
48+
`{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi"}}]}` +
49+
`]}`))
50+
51+
}
52+
return
53+
}
54+
// Pod Metrics from configured namespace
55+
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" {
56+
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
57+
`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi"}}]}` +
58+
`]}`))
59+
return
60+
}
61+
// Pod Metrics from ns-5 namespace
62+
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" {
63+
_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
64+
`{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi"}}]}` +
65+
`]}`))
66+
return
67+
}
68+
// Pod Metrics from ns-5 namespace with pod-ns-5-5 pod name
69+
if metricsApiAvailable && req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" {
70+
_, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` +
71+
`"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` +
72+
`"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi"}}]` +
73+
`}`))
74+
}
75+
}))
76+
podsTopMetricsApiUnavailable, err := c.callTool("pods_top", map[string]interface{}{})
77+
t.Run("pods_top with metrics API not available", func(t *testing.T) {
78+
if err != nil {
79+
t.Fatalf("call tool failed %v", err)
80+
}
81+
if !podsTopMetricsApiUnavailable.IsError {
82+
t.Errorf("call tool should have returned an error")
83+
}
84+
if podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text != "failed to get pods top: metrics API is not available" {
85+
t.Errorf("call tool returned unexpected content: %s", podsTopMetricsApiUnavailable.Content[0].(mcp.TextContent).Text)
86+
}
87+
})
88+
// Enable metrics API addon
89+
metricsApiAvailable = true
90+
d, _ := c.mcpServer.k.ToDiscoveryClient()
91+
d.Invalidate() // Force discovery client to refresh
92+
podsTopDefaults, err := c.callTool("pods_top", map[string]interface{}{})
93+
t.Run("pods_top defaults returns pod metrics from all namespaces", func(t *testing.T) {
94+
if err != nil {
95+
t.Fatalf("call tool failed %v", err)
96+
}
97+
textContent := podsTopDefaults.Content[0].(mcp.TextContent).Text
98+
if podsTopDefaults.IsError {
99+
t.Fatalf("call tool failed %s", textContent)
100+
}
101+
expectedHeaders := regexp.MustCompile("(?m)^\\s*NAMESPACE\\s+POD\\s+NAME\\s+CPU\\(cores\\)\\s+MEMORY\\(bytes\\)\\s*$")
102+
if !expectedHeaders.MatchString(textContent) {
103+
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent)
104+
}
105+
expectedRows := []string{
106+
"default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi",
107+
"default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi",
108+
"ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi",
109+
}
110+
for _, row := range expectedRows {
111+
if !regexp.MustCompile(row).MatchString(textContent) {
112+
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
113+
}
114+
}
115+
expectedTotal := regexp.MustCompile("(?m)^\\s+600m\\s+900Mi\\s*$")
116+
if !expectedTotal.MatchString(textContent) {
117+
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
118+
}
119+
})
120+
podsTopConfiguredNamespace, err := c.callTool("pods_top", map[string]interface{}{
121+
"all_namespaces": false,
122+
})
123+
t.Run("pods_top[allNamespaces=false] returns pod metrics from configured namespace", func(t *testing.T) {
124+
if err != nil {
125+
t.Fatalf("call tool failed %v", err)
126+
}
127+
textContent := podsTopConfiguredNamespace.Content[0].(mcp.TextContent).Text
128+
expectedRows := []string{
129+
"default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi",
130+
"default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi",
131+
}
132+
for _, row := range expectedRows {
133+
if !regexp.MustCompile(row).MatchString(textContent) {
134+
t.Errorf("Expected row '%s' not found in output:\n%s", row, textContent)
135+
}
136+
}
137+
expectedTotal := regexp.MustCompile("(?m)^\\s+40m\\s+60Mi\\s*$")
138+
if !expectedTotal.MatchString(textContent) {
139+
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
140+
}
141+
})
142+
podsTopNamespace, err := c.callTool("pods_top", map[string]interface{}{
143+
"namespace": "ns-5",
144+
})
145+
t.Run("pods_top[namespace=ns-5] returns pod metrics from provided namespace", func(t *testing.T) {
146+
if err != nil {
147+
t.Fatalf("call tool failed %v", err)
148+
}
149+
textContent := podsTopNamespace.Content[0].(mcp.TextContent).Text
150+
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-1\\s+container-1\\s+10m\\s+20Mi")
151+
if !expectedRow.MatchString(textContent) {
152+
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
153+
}
154+
expectedTotal := regexp.MustCompile("(?m)^\\s+10m\\s+20Mi\\s*$")
155+
if !expectedTotal.MatchString(textContent) {
156+
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
157+
}
158+
})
159+
podsTopNamespaceName, err := c.callTool("pods_top", map[string]interface{}{
160+
"namespace": "ns-5",
161+
"name": "pod-ns-5-5",
162+
})
163+
t.Run("pods_top[namespace=ns-5,name=pod-ns-5-5] returns pod metrics from provided namespace and name", func(t *testing.T) {
164+
if err != nil {
165+
t.Fatalf("call tool failed %v", err)
166+
}
167+
textContent := podsTopNamespaceName.Content[0].(mcp.TextContent).Text
168+
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-5\\s+container-1\\s+13m\\s+37Mi")
169+
if !expectedRow.MatchString(textContent) {
170+
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
171+
}
172+
expectedTotal := regexp.MustCompile("(?m)^\\s+13m\\s+37Mi\\s*$")
173+
if !expectedTotal.MatchString(textContent) {
174+
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
175+
}
176+
})
177+
podsTopNamespaceLabelSelector, err := c.callTool("pods_top", map[string]interface{}{
178+
"label_selector": "app=pod-ns-5-42",
179+
})
180+
t.Run("pods_top[label_selector=app=pod-ns-5-42] returns pod metrics from pods matching selector", func(t *testing.T) {
181+
if err != nil {
182+
t.Fatalf("call tool failed %v", err)
183+
}
184+
textContent := podsTopNamespaceLabelSelector.Content[0].(mcp.TextContent).Text
185+
expectedRow := regexp.MustCompile("ns-5\\s+pod-ns-5-42\\s+container-1\\s+42m\\s+42Mi")
186+
if !expectedRow.MatchString(textContent) {
187+
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
188+
}
189+
expectedTotal := regexp.MustCompile("(?m)^\\s+42m\\s+42Mi\\s*$")
190+
if !expectedTotal.MatchString(textContent) {
191+
t.Errorf("Expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
192+
}
193+
})
194+
})
195+
}

0 commit comments

Comments
 (0)