Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/api/toolsets.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"

"github.com/containers/kubernetes-mcp-server/pkg/kiali"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/output"
"github.com/google/jsonschema-go/jsonschema"
Expand Down Expand Up @@ -65,6 +66,7 @@ func NewToolCallResult(content string, err error) *ToolCallResult {
type ToolHandlerParams struct {
context.Context
*internalk8s.Kubernetes
*kiali.Kiali
ToolCallRequest
ListOutput output.Output
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ type StaticConfig struct {
// This map holds raw TOML primitives that will be parsed by registered provider parsers
ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"`

// KialiServerURL is the URL of the Kiali server.
KialiURL string `toml:"kiali_url,omitempty"`
// KialiInsecure indicates whether the server should use insecure TLS for the Kiali server.
KialiInsecure bool `toml:"kiali_insecure,omitempty"`

// Internal: parsed provider configs (not exposed to TOML package)
parsedClusterProviderConfigs map[string]ProviderConfig

Expand Down
8 changes: 8 additions & 0 deletions pkg/kiali/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kiali

// Kiali API endpoint paths shared across this package.
const (
// MeshGraph is the Kiali API path that returns the mesh graph/status.
MeshGraph = "/api/mesh/graph"
AuthInfo = "/api/auth/info"
)
118 changes: 118 additions & 0 deletions pkg/kiali/kiali.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package kiali

import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"strings"

internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"k8s.io/klog/v2"
)

type Kiali struct {
manager *Manager
}

func (m *Manager) GetKiali() *Kiali {
return &Kiali{manager: m}
}

func (k *Kiali) GetKiali() *Kiali {
return k
}

// validateAndGetURL validates the Kiali client configuration and returns the full URL
// by safely concatenating the base URL with the provided endpoint, avoiding duplicate
// or missing slashes regardless of trailing/leading slashes.
func (k *Kiali) validateAndGetURL(endpoint string) (string, error) {
if k == nil || k.manager == nil || k.manager.KialiURL == "" {
return "", fmt.Errorf("kiali client not initialized")
}
baseStr := strings.TrimSpace(k.manager.KialiURL)
if baseStr == "" {
return "", fmt.Errorf("kiali server URL not configured")
}
baseURL, err := url.Parse(baseStr)
if err != nil {
return "", fmt.Errorf("invalid kiali base URL: %w", err)
}
if endpoint == "" {
return baseURL.String(), nil
}
ref, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("invalid endpoint path: %w", err)
}
return baseURL.ResolveReference(ref).String(), nil
}

func (k *Kiali) createHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: k.manager.KialiInsecure,
},
},
}
}

// CurrentAuthorizationHeader returns the Authorization header value that the
// Kiali client is currently configured to use (Bearer <token>), or empty
// if no bearer token is configured.
func (k *Kiali) CurrentAuthorizationHeader(ctx context.Context) string {
token, _ := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string)
token = strings.TrimSpace(token)

if token == "" {
// Fall back to using the same token that the Kubernetes client is using
if k == nil || k.manager == nil || k.manager.BearerToken == "" {
return ""
}
token = strings.TrimSpace(k.manager.BearerToken)
if token == "" {
return ""
}
}
// Normalize to exactly "Bearer <token>" without double prefix
lower := strings.ToLower(token)
if strings.HasPrefix(lower, "bearer ") {
return "Bearer " + strings.TrimSpace(token[7:])
}
return "Bearer " + token
}

// executeRequest executes an HTTP request and handles common error scenarios.
func (k *Kiali) executeRequest(ctx context.Context, endpoint string) (string, error) {
ApiCallURL, err := k.validateAndGetURL(endpoint)
if err != nil {
return "", err
}

klog.V(0).Infof("Kiali Call URL: %s", ApiCallURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ApiCallURL, nil)
if err != nil {
return "", err
}
authHeader := k.CurrentAuthorizationHeader(ctx)
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
client := k.createHTTPClient()
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(body) > 0 {
return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(body)))
}
return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode)
}
return string(body), nil
}
97 changes: 97 additions & 0 deletions pkg/kiali/kiali_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package kiali

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)

func TestValidateAndGetURL_JoinsProperly(t *testing.T) {
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example/"})
k := m.GetKiali()

full, err := k.validateAndGetURL("/api/path")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if full != "https://kiali.example/api/path" {
t.Fatalf("unexpected url: %s", full)
}

m.KialiURL = "https://kiali.example"
full, err = k.validateAndGetURL("api/path")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if full != "https://kiali.example/api/path" {
t.Fatalf("unexpected url: %s", full)
}

// preserve query
m.KialiURL = "https://kiali.example"
full, err = k.validateAndGetURL("/api/path?x=1&y=2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
u, _ := url.Parse(full)
if u.Path != "/api/path" || u.Query().Get("x") != "1" || u.Query().Get("y") != "2" {
t.Fatalf("unexpected parsed url: %s", full)
}
}

func TestCurrentAuthorizationHeader_FromContext(t *testing.T) {
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"})
k := m.GetKiali()
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "bearer abc")
got := k.CurrentAuthorizationHeader(ctx)
if got != "Bearer abc" {
t.Fatalf("expected normalized bearer header, got '%s'", got)
}
}

func TestCurrentAuthorizationHeader_FromManagerToken(t *testing.T) {
m := NewManager(&config.StaticConfig{KialiURL: "https://kiali.example"})
m.BearerToken = "abc"
k := m.GetKiali()
got := k.CurrentAuthorizationHeader(context.Background())
if got != "Bearer abc" {
t.Fatalf("expected 'Bearer abc', got '%s'", got)
}
}

func TestExecuteRequest_SetsAuthAndCallsServer(t *testing.T) {
// setup test server to assert path and auth header
var seenAuth string
var seenPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenAuth = r.Header.Get("Authorization")
seenPath = r.URL.String()
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()

m := NewManager(&config.StaticConfig{KialiURL: srv.URL})
k := m.GetKiali()
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-xyz")

out, err := k.executeRequest(ctx, "/api/ping?q=1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "ok" {
t.Fatalf("unexpected body: %s", out)
}
if seenAuth != "Bearer token-xyz" {
t.Fatalf("expected auth header to be set, got '%s'", seenAuth)
}
if seenPath != "/api/ping?q=1" {
t.Fatalf("unexpected path: %s", seenPath)
}
}


39 changes: 39 additions & 0 deletions pkg/kiali/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kiali

import (
"context"
"strings"

"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"k8s.io/klog/v2"
)

type Manager struct {
BearerToken string
KialiURL string
KialiInsecure bool
}

func NewManager(config *config.StaticConfig) *Manager {
return &Manager{
BearerToken: "",
KialiURL: config.KialiURL,
KialiInsecure: config.KialiInsecure,
}
}

func (m *Manager) Derived(ctx context.Context) (*Kiali, error) {
authorization, ok := ctx.Value(internalk8s.OAuthAuthorizationHeader).(string)
if !ok || !strings.HasPrefix(authorization, "Bearer ") {
return &Kiali{manager: m}, nil
}
// Authorization header is present; nothing special is needed for the Kiali HTTP client
klog.V(5).Infof("%s header found (Bearer), using provided bearer token", internalk8s.OAuthAuthorizationHeader)

return &Kiali{manager: &Manager{
BearerToken: strings.TrimPrefix(authorization, "Bearer "),
KialiURL: m.KialiURL,
KialiInsecure: m.KialiInsecure,
}}, nil
}
56 changes: 56 additions & 0 deletions pkg/kiali/manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package kiali

import (
"context"
"testing"

"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
)

func TestNewManagerUsesConfigFields(t *testing.T) {
cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true}
m := NewManager(cfg)
if m == nil {
t.Fatalf("expected manager, got nil")
}
if m.KialiURL != cfg.KialiURL {
t.Fatalf("expected KialiURL %s, got %s", cfg.KialiURL, m.KialiURL)
}
if m.KialiInsecure != cfg.KialiInsecure {
t.Fatalf("expected KialiInsecure %v, got %v", cfg.KialiInsecure, m.KialiInsecure)
}
}

func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) {
cfg := &config.StaticConfig{KialiURL: "https://kiali.example"}
m := NewManager(cfg)
k, err := m.Derived(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k == nil || k.manager != m {
t.Fatalf("expected derived Kiali to keep original manager")
}
}

func TestDerivedWithAuthorizationPreservesURLAndToken(t *testing.T) {
cfg := &config.StaticConfig{KialiURL: "https://kiali.example", KialiInsecure: true}
m := NewManager(cfg)
ctx := context.WithValue(context.Background(), internalk8s.OAuthAuthorizationHeader, "Bearer token-abc")
k, err := m.Derived(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if k == nil || k.manager == nil {
t.Fatalf("expected derived Kiali with manager")
}
if k.manager.BearerToken != "token-abc" {
t.Fatalf("expected bearer token 'token-abc', got '%s'", k.manager.BearerToken)
}
if k.manager.KialiURL != m.KialiURL || k.manager.KialiInsecure != m.KialiInsecure {
t.Fatalf("expected Kiali URL/insecure preserved")
}
}


21 changes: 21 additions & 0 deletions pkg/kiali/mesh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kiali

import (
"context"
"net/url"
)

// MeshStatus calls the Kiali mesh graph API to get the status of mesh components.
// This returns information about mesh components like Istio, Kiali, Grafana, Prometheus
// and their interactions, versions, and health status.
func (k *Kiali) MeshStatus(ctx context.Context) (string, error) {
u, err := url.Parse(MeshGraph)
if err != nil {
return "", err
}
q := u.Query()
q.Set("includeGateways", "false")
q.Set("includeWaypoints", "false")
u.RawQuery = q.Encode()
return k.executeRequest(ctx, u.String())
}
Loading