Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const (
ClusterProviderDisabled = "disabled"
)

// KialiOptions is the configuration for the kiali toolset.
type KialiOptions struct {
Url string `toml:"url,omitempty"`
Insecure bool `toml:"insecure,omitempty"`
}

// StaticConfig is the configuration for the server.
// It allows to configure server specific settings and tools to be enabled or disabled.
type StaticConfig struct {
Expand Down Expand Up @@ -68,6 +74,9 @@ 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"`

// KialiOptions is the configuration for the kiali toolset.
KialiOptions KialiOptions `toml:"kiali,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"
)
109 changes: 109 additions & 0 deletions pkg/kiali/kiali.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package kiali

import (
"context"
"crypto/tls"
"fmt"
"io"
"k8s.io/klog/v2"
"net/http"
"net/url"
"strings"
)

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) authorizationHeader() string {
if k == nil || k.manager == nil {
return ""
}
token := strings.TrimSpace(k.manager.BearerToken)
if token == "" {
return ""
}
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.authorizationHeader()
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
}
75 changes: 75 additions & 0 deletions pkg/kiali/kiali_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package kiali

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

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

func TestValidateAndGetURL_JoinsProperly(t *testing.T) {
m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: "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)
}
}

// CurrentAuthorizationHeader behavior is now implicit via executeRequest using Manager.BearerToken

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{KialiOptions: config.KialiOptions{Url: srv.URL}})
m.BearerToken = "token-xyz"
k := m.GetKiali()
out, err := k.executeRequest(context.Background(), "/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)
}
}
25 changes: 25 additions & 0 deletions pkg/kiali/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kiali

import (
"context"

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

type Manager struct {
BearerToken string
KialiURL string
KialiInsecure bool
}

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

func (m *Manager) Derived(_ context.Context) (*Kiali, error) {
return &Kiali{manager: m}, nil
}
53 changes: 53 additions & 0 deletions pkg/kiali/manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package kiali

import (
"context"
"testing"

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

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

func TestDerivedWithoutAuthorizationReturnsOriginalManager(t *testing.T) {
cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "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 TestDerivedPreservesURLAndToken(t *testing.T) {
cfg := &config.StaticConfig{KialiOptions: config.KialiOptions{Url: "https://kiali.example", Insecure: true}}
m := NewManager(cfg)
m.BearerToken = "token-abc"
k, err := m.Derived(context.Background())
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())
}
41 changes: 41 additions & 0 deletions pkg/kiali/mesh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package kiali

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

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

func TestMeshStatus_CallsGraphWithExpectedQuery(t *testing.T) {
var capturedURL *url.URL
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := *r.URL
capturedURL = &u
_, _ = w.Write([]byte("graph"))
}))
defer srv.Close()

m := NewManager(&config.StaticConfig{KialiOptions: config.KialiOptions{Url: srv.URL}})
m.BearerToken = "tkn"
k := m.GetKiali()
out, err := k.MeshStatus(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "graph" {
t.Fatalf("unexpected response: %s", out)
}
if capturedURL == nil {
t.Fatalf("expected request to be captured")
}
if capturedURL.Path != "/api/mesh/graph" {
t.Fatalf("unexpected path: %s", capturedURL.Path)
}
if capturedURL.Query().Get("includeGateways") != "false" || capturedURL.Query().Get("includeWaypoints") != "false" {
t.Fatalf("unexpected query: %s", capturedURL.RawQuery)
}
}
Loading