Skip to content

Commit d3c39c9

Browse files
committed
Add kiali toolset
Signed-off-by: Alberto Gutierrez <[email protected]>
1 parent ba18f13 commit d3c39c9

File tree

18 files changed

+546
-2
lines changed

18 files changed

+546
-2
lines changed

pkg/api/toolsets.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66

7+
"github.com/containers/kubernetes-mcp-server/pkg/kiali"
78
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
89
"github.com/containers/kubernetes-mcp-server/pkg/output"
910
"github.com/google/jsonschema-go/jsonschema"
@@ -65,6 +66,7 @@ func NewToolCallResult(content string, err error) *ToolCallResult {
6566
type ToolHandlerParams struct {
6667
context.Context
6768
*internalk8s.Kubernetes
69+
*kiali.Kiali
6870
ToolCallRequest
6971
ListOutput output.Output
7072
}

pkg/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ type StaticConfig struct {
6868
// This map holds raw TOML primitives that will be parsed by registered provider parsers
6969
ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"`
7070

71+
// KialiServerURL is the URL of the Kiali server.
72+
KialiURL string `toml:"kiali_url,omitempty"`
73+
// KialiInsecure indicates whether the server should use insecure TLS for the Kiali server.
74+
KialiInsecure bool `toml:"kiali_insecure,omitempty"`
75+
7176
// Internal: parsed provider configs (not exposed to TOML package)
7277
parsedClusterProviderConfigs map[string]ProviderConfig
7378

pkg/kiali/endpoints.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package kiali
2+
3+
// Kiali API endpoint paths shared across this package.
4+
const (
5+
// MeshGraph is the Kiali API path that returns the mesh graph/status.
6+
MeshGraph = "/api/mesh/graph"
7+
AuthInfo = "/api/auth/info"
8+
)

pkg/kiali/kiali.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package kiali
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
12+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
13+
"k8s.io/klog/v2"
14+
)
15+
16+
type Kiali struct {
17+
manager *Manager
18+
}
19+
20+
func (m *Manager) GetKiali() *Kiali {
21+
return &Kiali{manager: m}
22+
}
23+
24+
func (k *Kiali) GetKiali() *Kiali {
25+
return k
26+
}
27+
28+
// validateAndGetURL validates the Kiali client configuration and returns the full URL
29+
// by safely concatenating the base URL with the provided endpoint, avoiding duplicate
30+
// or missing slashes regardless of trailing/leading slashes.
31+
func (k *Kiali) validateAndGetURL(endpoint string) (string, error) {
32+
if k == nil || k.manager == nil || k.manager.KialiURL == "" {
33+
return "", fmt.Errorf("kiali client not initialized")
34+
}
35+
baseStr := strings.TrimSpace(k.manager.KialiURL)
36+
if baseStr == "" {
37+
return "", fmt.Errorf("kiali server URL not configured")
38+
}
39+
baseURL, err := url.Parse(baseStr)
40+
if err != nil {
41+
return "", fmt.Errorf("invalid kiali base URL: %w", err)
42+
}
43+
if endpoint == "" {
44+
return baseURL.String(), nil
45+
}
46+
ref, err := url.Parse(endpoint)
47+
if err != nil {
48+
return "", fmt.Errorf("invalid endpoint path: %w", err)
49+
}
50+
return baseURL.ResolveReference(ref).String(), nil
51+
}
52+
53+
func (k *Kiali) createHTTPClient() *http.Client {
54+
return &http.Client{
55+
Transport: &http.Transport{
56+
TLSClientConfig: &tls.Config{
57+
InsecureSkipVerify: k.manager.KialiInsecure,
58+
},
59+
},
60+
}
61+
}
62+
63+
// CurrentAuthorizationHeader returns the Authorization header value that the
64+
// Kiali client is currently configured to use (Bearer <token>), or empty
65+
// if no bearer token is configured.
66+
func (k *Kiali) CurrentAuthorizationHeader(ctx context.Context) string {
67+
token, _ := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string)
68+
token = strings.TrimSpace(token)
69+
70+
if token == "" {
71+
// Fall back to using the same token that the Kubernetes client is using
72+
if k == nil || k.manager == nil || k.manager.BearerToken == "" {
73+
return ""
74+
}
75+
token = strings.TrimSpace(k.manager.BearerToken)
76+
if token == "" {
77+
return ""
78+
}
79+
}
80+
// Normalize to exactly "Bearer <token>" without double prefix
81+
lower := strings.ToLower(token)
82+
if strings.HasPrefix(lower, "bearer ") {
83+
return "Bearer " + strings.TrimSpace(token[7:])
84+
}
85+
return "Bearer " + token
86+
}
87+
88+
// executeRequest executes an HTTP request and handles common error scenarios.
89+
func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, error) {
90+
ApiCallURL, err := k.validateAndGetURL(endpoint)
91+
if err != nil {
92+
return "", err
93+
}
94+
95+
klog.V(0).Infof("Kiali Call URL: %s", ApiCallURL)
96+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ApiCallURL, nil)
97+
if err != nil {
98+
return "", err
99+
}
100+
authHeader := k.CurrentAuthorizationHeader(ctx)
101+
if authHeader != "" {
102+
req.Header.Set("Authorization", authHeader)
103+
}
104+
client := k.createHTTPClient()
105+
resp, err := client.Do(req)
106+
if err != nil {
107+
return "", err
108+
}
109+
defer func() { _ = resp.Body.Close() }()
110+
body, _ := io.ReadAll(resp.Body)
111+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
112+
if len(body) > 0 {
113+
return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(body)))
114+
}
115+
return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode)
116+
}
117+
return string(body), nil
118+
}

pkg/kiali/kiali_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package kiali
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/containers/kubernetes-mcp-server/pkg/config"
11+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
12+
)
13+
14+
func TestValidateAndGetURL_JoinsProperly(t *testing.T) {
15+
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example/"})
16+
k := m.GetKiali()
17+
18+
full, err := k.validateAndGetURL("/api/path")
19+
if err != nil {
20+
t.Fatalf("unexpected error: %v", err)
21+
}
22+
if full != "https://kiali.example/api/path" {
23+
t.Fatalf("unexpected url: %s", full)
24+
}
25+
26+
m.KialiURL = "https://kiali.example"
27+
full, err = k.validateAndGetURL("api/path")
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if full != "https://kiali.example/api/path" {
32+
t.Fatalf("unexpected url: %s", full)
33+
}
34+
35+
// preserve query
36+
m.KialiURL = "https://kiali.example"
37+
full, err = k.validateAndGetURL("/api/path?x=1&y=2")
38+
if err != nil {
39+
t.Fatalf("unexpected error: %v", err)
40+
}
41+
u, _ := url.Parse(full)
42+
if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" {
43+
t.Fatalf("unexpected parsed url: %s", full)
44+
}
45+
}
46+
47+
func TestCurrentAuthorizationHeader_FromContext(t *testing.T) {
48+
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"})
49+
k := m.GetKiali()
50+
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "bearer abc")
51+
got := k.CurrentAuthorizationHeader(ctx)
52+
if got != "Bearer abc" {
53+
t.Fatalf("expected normalized bearer header, got '%s'", got)
54+
}
55+
}
56+
57+
func TestCurrentAuthorizationHeader_FromManagerToken(t *testing.T) {
58+
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"})
59+
m.BearerToken = "abc"
60+
k := m.GetKiali()
61+
got := k.CurrentAuthorizationHeader(context.Background())
62+
if got != "Bearer abc" {
63+
t.Fatalf("expected 'Bearer abc', got '%s'", got)
64+
}
65+
}
66+
67+
func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) {
68+
// setup test server to assert path and auth header
69+
var seenAuth string
70+
var seenPath string
71+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+
seenAuth = r.Header.Get("Authorization")
73+
seenPath = r.URL.String()
74+
_, _ = w.Write([]byte("ok"))
75+
}))
76+
defer srv.Close()
77+
78+
m := NewManager(&config.StaticConfig{KialiURL: srv.URL})
79+
k := m.GetKiali()
80+
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-xyz")
81+
82+
out, err := k.executeRequest(ctx, "/api/ping?q=1")
83+
if err != nil {
84+
t.Fatalf("unexpected error: %v", err)
85+
}
86+
if out != "ok" {
87+
t.Fatalf("unexpected body: %s", out)
88+
}
89+
if seenAuth != "Bearer token-xyz" {
90+
t.Fatalf("expected auth header to be set, got '%s'", seenAuth)
91+
}
92+
if seenPath != "/api/ping?q=1" {
93+
t.Fatalf("unexpected path: %s", seenPath)
94+
}
95+
}
96+
97+

pkg/kiali/manager.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package kiali
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/containers/kubernetes-mcp-server/pkg/config"
8+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
9+
"k8s.io/klog/v2"
10+
)
11+
12+
type Manager struct {
13+
BearerToken string
14+
KialiURL string
15+
KialiInsecure bool
16+
}
17+
18+
func NewManager(config *config.StaticConfig) *Manager {
19+
return &Manager{
20+
BearerToken: "",
21+
KialiURL: config.KialiURL,
22+
KialiInsecure: config.KialiInsecure,
23+
}
24+
}
25+
26+
func (m *Manager) Derived(ctx context.Context) (*Kiali, error) {
27+
authorization, ok := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string)
28+
if !ok || !strings.HasPrefix(authorization, "Bearer ") {
29+
return &Kiali{manager: m}, nil
30+
}
31+
// Authorization header is present; nothing special is needed for the Kiali HTTP client
32+
klog.V(5).Infof("%s header found (Bearer), using provided bearer token", internalk8s.OAuthAuthorizationHeader)
33+
34+
return &Kiali{manager: &Manager{
35+
BearerToken: strings.TrimPrefix(authorization, "Bearer "),
36+
KialiURL: m.KialiURL,
37+
KialiInsecure: m.KialiInsecure,
38+
}}, nil
39+
}

pkg/kiali/manager_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package kiali
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/containers/kubernetes-mcp-server/pkg/config"
8+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
9+
)
10+
11+
func TestNewManagerUsesConfigFields(t *testing.T) {
12+
cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true}
13+
m := NewManager(cfg)
14+
if m == nil {
15+
t.Fatalf("expected manager, got nil")
16+
}
17+
if m.KialiURL != cfg.KialiURL {
18+
t.Fatalf("expected KialiURL %s, got %s", cfg.KialiURL, m.KialiURL)
19+
}
20+
if m.KialiInsecure != cfg.KialiInsecure {
21+
t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiInsecure, m.KialiInsecure)
22+
}
23+
}
24+
25+
func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) {
26+
cfg := &config.StaticConfig{KialiURL: "https://kiali.example"}
27+
m := NewManager(cfg)
28+
k, err := m.Derived(context.Background())
29+
if err != nil {
30+
t.Fatalf("unexpected error: %v", err)
31+
}
32+
if k == nil || k.manager != m {
33+
t.Fatalf("expected derived Kiali to keep original manager")
34+
}
35+
}
36+
37+
func TestDerivedWithAuthorizationPreservesURLAndToken(t *testing.T) {
38+
cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true}
39+
m := NewManager(cfg)
40+
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-abc")
41+
k, err := m.Derived(ctx)
42+
if err != nil {
43+
t.Fatalf("unexpected error: %v", err)
44+
}
45+
if k == nil || k.manager == nil {
46+
t.Fatalf("expected derived Kiali with manager")
47+
}
48+
if k.manager.BearerToken != "token-abc" {
49+
t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken)
50+
}
51+
if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure {
52+
t.Fatalf("expected Kiali URL/insecure preserved")
53+
}
54+
}
55+
56+

pkg/kiali/mesh.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package kiali
2+
3+
import (
4+
"context"
5+
"net/url"
6+
)
7+
8+
// MeshStatus calls the Kiali mesh graph API to get the status of mesh components.
9+
// This returns information about mesh components like Istio, Kiali, Grafana, Prometheus
10+
// and their interactions, versions, and health status.
11+
func (k *Kiali) MeshStatus(ctx context.Context) (string, error) {
12+
u, err := url.Parse(MeshGraph)
13+
if err != nil {
14+
return "", err
15+
}
16+
q := u.Query()
17+
q.Set("includeGateways", "false")
18+
q.Set("includeWaypoints", "false")
19+
u.RawQuery = q.Encode()
20+
return k.executeRequest(ctx, u.String())
21+
}

0 commit comments

Comments
 (0)