Skip to content

Commit c7a0c61

Browse files
sd2kclaude
andauthored
feat: add generic Kubernetes-style API client (#690)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d58513d commit c7a0c61

4 files changed

Lines changed: 949 additions & 0 deletions

File tree

k8s_client.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package mcpgrafana
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
)
12+
13+
// KubernetesClient is a lightweight, generic HTTP client for Grafana's
14+
// Kubernetes-style APIs (/apis/...). It uses unstructured data
15+
// (map[string]interface{}) so callers are not tied to specific Go types.
16+
//
17+
// Authentication is read from the GrafanaConfig in the request context,
18+
// following the same priority as the rest of mcp-grafana:
19+
//
20+
// 1. AccessToken + IDToken (on-behalf-of)
21+
// 2. APIKey (bearer token)
22+
// 3. BasicAuth
23+
type KubernetesClient struct {
24+
// BaseURL is the root URL of the Grafana instance (e.g. "http://localhost:3000").
25+
BaseURL string
26+
27+
// HTTPClient is the underlying HTTP client used for requests.
28+
// If nil, http.DefaultClient is used.
29+
HTTPClient *http.Client
30+
}
31+
32+
// NewKubernetesClient creates a KubernetesClient from the GrafanaConfig in ctx.
33+
// It reuses BuildTransport so TLS, extra headers, OrgID, and user-agent are
34+
// handled the same way as for the legacy OpenAPI client.
35+
func NewKubernetesClient(ctx context.Context) (*KubernetesClient, error) {
36+
cfg := GrafanaConfigFromContext(ctx)
37+
38+
baseURL := cfg.URL
39+
if baseURL == "" {
40+
baseURL = defaultGrafanaURL
41+
}
42+
baseURL = strings.TrimRight(baseURL, "/")
43+
44+
transport, err := BuildTransport(&cfg, nil)
45+
if err != nil {
46+
return nil, fmt.Errorf("build transport: %w", err)
47+
}
48+
transport = NewOrgIDRoundTripper(transport, cfg.OrgID)
49+
transport = NewUserAgentTransport(transport)
50+
51+
timeout := cfg.Timeout
52+
if timeout == 0 {
53+
timeout = DefaultGrafanaClientTimeout
54+
}
55+
56+
return &KubernetesClient{
57+
BaseURL: baseURL,
58+
HTTPClient: &http.Client{
59+
Transport: transport,
60+
Timeout: timeout,
61+
},
62+
}, nil
63+
}
64+
65+
// ResourceList is the response shape for a Kubernetes-style list request.
66+
type ResourceList struct {
67+
Kind string `json:"kind"`
68+
APIVersion string `json:"apiVersion"`
69+
Items []map[string]interface{} `json:"items"`
70+
Metadata map[string]interface{} `json:"metadata,omitempty"`
71+
}
72+
73+
// ListOptions controls the behaviour of a List call.
74+
type ListOptions struct {
75+
// LabelSelector filters results by label (e.g. "app=foo").
76+
LabelSelector string
77+
// Limit caps the number of items returned.
78+
Limit int
79+
// Continue is a pagination token from a previous list response.
80+
Continue string
81+
}
82+
83+
// Discover calls GET /apis and returns a ResourceRegistry describing
84+
// available API groups and their versions.
85+
func (c *KubernetesClient) Discover(ctx context.Context) (*ResourceRegistry, error) {
86+
body, err := c.doRequest(ctx, http.MethodGet, "/apis", nil)
87+
if err != nil {
88+
return nil, fmt.Errorf("discover /apis: %w", err)
89+
}
90+
91+
var groupList APIGroupList
92+
if err := json.Unmarshal(body, &groupList); err != nil {
93+
return nil, fmt.Errorf("decode /apis response: %w", err)
94+
}
95+
96+
return NewResourceRegistry(&groupList), nil
97+
}
98+
99+
// validatePathSegment checks that a user-supplied path segment (namespace or
100+
// resource name) does not contain path separators, which could lead to path
101+
// traversal.
102+
func validatePathSegment(kind, value string) error {
103+
if strings.Contains(value, "/") || strings.Contains(value, "\\") {
104+
return fmt.Errorf("%s %q must not contain path separators", kind, value)
105+
}
106+
return nil
107+
}
108+
109+
// Get fetches a single resource by name.
110+
// Returns the full Kubernetes-style object as unstructured data.
111+
func (c *KubernetesClient) Get(ctx context.Context, desc ResourceDescriptor, namespace, name string) (map[string]interface{}, error) {
112+
if err := validatePathSegment("namespace", namespace); err != nil {
113+
return nil, err
114+
}
115+
if err := validatePathSegment("name", name); err != nil {
116+
return nil, err
117+
}
118+
119+
path := desc.BasePath(namespace) + "/" + name
120+
121+
body, err := c.doRequest(ctx, http.MethodGet, path, nil)
122+
if err != nil {
123+
return nil, err
124+
}
125+
126+
var result map[string]interface{}
127+
if err := json.Unmarshal(body, &result); err != nil {
128+
return nil, fmt.Errorf("decode response: %w", err)
129+
}
130+
return result, nil
131+
}
132+
133+
// List fetches a collection of resources.
134+
func (c *KubernetesClient) List(ctx context.Context, desc ResourceDescriptor, namespace string, opts *ListOptions) (*ResourceList, error) {
135+
if err := validatePathSegment("namespace", namespace); err != nil {
136+
return nil, err
137+
}
138+
139+
path := desc.BasePath(namespace)
140+
141+
// Build query string from options using url.Values for proper encoding.
142+
if opts != nil {
143+
params := url.Values{}
144+
if opts.LabelSelector != "" {
145+
params.Set("labelSelector", opts.LabelSelector)
146+
}
147+
if opts.Limit > 0 {
148+
params.Set("limit", fmt.Sprintf("%d", opts.Limit))
149+
}
150+
if opts.Continue != "" {
151+
params.Set("continue", opts.Continue)
152+
}
153+
if encoded := params.Encode(); encoded != "" {
154+
path += "?" + encoded
155+
}
156+
}
157+
158+
body, err := c.doRequest(ctx, http.MethodGet, path, nil)
159+
if err != nil {
160+
return nil, err
161+
}
162+
163+
var list ResourceList
164+
if err := json.Unmarshal(body, &list); err != nil {
165+
return nil, fmt.Errorf("decode list response: %w", err)
166+
}
167+
return &list, nil
168+
}
169+
170+
// KubernetesAPIError is returned when the server responds with a non-2xx status.
171+
type KubernetesAPIError struct {
172+
StatusCode int
173+
Status string
174+
Body string
175+
}
176+
177+
func (e *KubernetesAPIError) Error() string {
178+
return fmt.Sprintf("kubernetes API error: %s (HTTP %d): %s", e.Status, e.StatusCode, e.Body)
179+
}
180+
181+
// doRequest executes an authenticated HTTP request and returns the response body.
182+
// It injects auth headers from the GrafanaConfig in ctx.
183+
func (c *KubernetesClient) doRequest(ctx context.Context, method, path string, reqBody io.Reader) ([]byte, error) {
184+
url := c.BaseURL + path
185+
186+
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
187+
if err != nil {
188+
return nil, fmt.Errorf("create request: %w", err)
189+
}
190+
req.Header.Set("Accept", "application/json")
191+
if reqBody != nil {
192+
req.Header.Set("Content-Type", "application/json")
193+
}
194+
195+
// Inject authentication from context.
196+
cfg := GrafanaConfigFromContext(ctx)
197+
applyAuth(req, &cfg)
198+
199+
httpClient := c.HTTPClient
200+
if httpClient == nil {
201+
httpClient = http.DefaultClient
202+
}
203+
204+
resp, err := httpClient.Do(req)
205+
if err != nil {
206+
return nil, fmt.Errorf("http request: %w", err)
207+
}
208+
defer func() { _ = resp.Body.Close() }()
209+
210+
// Read the body (with a reasonable limit to avoid OOM).
211+
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB
212+
if err != nil {
213+
return nil, fmt.Errorf("read response body: %w", err)
214+
}
215+
216+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
217+
return nil, &KubernetesAPIError{
218+
StatusCode: resp.StatusCode,
219+
Status: resp.Status,
220+
Body: string(body),
221+
}
222+
}
223+
224+
return body, nil
225+
}
226+
227+
// applyAuth sets authentication headers on the request based on GrafanaConfig.
228+
// Priority: on-behalf-of (AccessToken+IDToken) > APIKey > BasicAuth.
229+
func applyAuth(req *http.Request, cfg *GrafanaConfig) {
230+
switch {
231+
case cfg.AccessToken != "" && cfg.IDToken != "":
232+
req.Header.Set("X-Access-Token", cfg.AccessToken)
233+
req.Header.Set("X-Grafana-Id", cfg.IDToken)
234+
case cfg.APIKey != "":
235+
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
236+
case cfg.BasicAuth != nil:
237+
password, _ := cfg.BasicAuth.Password()
238+
req.SetBasicAuth(cfg.BasicAuth.Username(), password)
239+
}
240+
}

0 commit comments

Comments
 (0)