diff --git a/pkg/config/config.go b/pkg/config/config.go index 81bec2b7..4a18a82b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 { @@ -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 diff --git a/pkg/kiali/endpoints.go b/pkg/kiali/endpoints.go new file mode 100644 index 00000000..bf4d3407 --- /dev/null +++ b/pkg/kiali/endpoints.go @@ -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" +) diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go new file mode 100644 index 00000000..aa86c91e --- /dev/null +++ b/pkg/kiali/kiali.go @@ -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 ), 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 +} diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go new file mode 100644 index 00000000..9c70fc1d --- /dev/null +++ b/pkg/kiali/kiali_test.go @@ -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) + } +} diff --git a/pkg/kiali/manager.go b/pkg/kiali/manager.go new file mode 100644 index 00000000..ee1442f1 --- /dev/null +++ b/pkg/kiali/manager.go @@ -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 +} diff --git a/pkg/kiali/manager_test.go b/pkg/kiali/manager_test.go new file mode 100644 index 00000000..69a93531 --- /dev/null +++ b/pkg/kiali/manager_test.go @@ -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") + } +} diff --git a/pkg/kiali/mesh.go b/pkg/kiali/mesh.go new file mode 100644 index 00000000..b443dcb9 --- /dev/null +++ b/pkg/kiali/mesh.go @@ -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()) +} diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go new file mode 100644 index 00000000..03a4fab7 --- /dev/null +++ b/pkg/kiali/mesh_test.go @@ -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) + } +} diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index db1782ab..9157a685 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "slices" "strconv" "strings" @@ -73,8 +74,15 @@ const ( flagServerUrl = "server-url" flagCertificateAuthority = "certificate-authority" flagDisableMultiCluster = "disable-multi-cluster" + flagKialiUrl = "kiali-url" + flagKialiInsecure = "kiali-insecure" ) +type KialiOptions struct { + Url string + Insecure bool +} + type MCPServerOptions struct { Version bool LogLevel int @@ -94,6 +102,7 @@ type MCPServerOptions struct { CertificateAuthority string ServerURL string DisableMultiCluster bool + KialiOptions KialiOptions ConfigPath string StaticConfig *config.StaticConfig @@ -157,6 +166,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.CertificateAuthority, flagCertificateAuthority, o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden(flagCertificateAuthority) cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.") + cmd.Flags().StringVar(&o.KialiOptions.Url, flagKialiUrl, o.KialiOptions.Url, "Kiali endpoint to use for kiali tools. Optional. If not set, the kiali tools will not be available.") + cmd.Flags().BoolVar(&o.KialiOptions.Insecure, flagKialiInsecure, o.KialiOptions.Insecure, "If true, allows insecure TLS connections to Kiali. Optional. If true, the kiali tools will not be available.") return cmd } @@ -232,6 +243,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster { m.StaticConfig.ClusterProviderStrategy = config.ClusterProviderDisabled } + if cmd.Flag(flagKialiUrl).Changed { + m.StaticConfig.KialiOptions.Url = m.KialiOptions.Url + } + if cmd.Flag(flagKialiInsecure).Changed { + m.StaticConfig.KialiOptions.Insecure = m.KialiOptions.Insecure + } } func (m *MCPServerOptions) initializeLogging() { @@ -277,6 +294,10 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } + /* If Kiali tools are enabled, validate the Kiali URL */ + if slices.Contains(m.StaticConfig.Toolsets, "kiali") && strings.TrimSpace(m.StaticConfig.KialiOptions.Url) == "" { + return fmt.Errorf("kiali-url is required when kiali tools are enabled") + } return nil } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 22521667..f5fb0803 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kiali).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) @@ -161,6 +161,47 @@ func TestToolsets(t *testing.T) { }) } +func TestKialiURLRequired(t *testing.T) { + t.Run("flag toolsets includes kiali and missing kiali-url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("flag toolsets includes kiali and kiali-url provided passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + rootCmd.SetArgs([]string{"--version", "--port=1337", "--toolsets", "core,kiali", "--kiali-url", "http://kiali"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("config toolsets includes kiali and missing kiali_url returns error", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-missing-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "kiali-url is required when kiali tools are enabled") { + t.Fatalf("expected error about missing kiali-url, got %v", err) + } + }) + t.Run("config toolsets includes kiali and kiali_url present passes", func(t *testing.T) { + ioStreams, _ := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + cfgPath := filepath.Join(filepath.Dir(file), "testdata", "kiali-toolset-with-url.toml") + rootCmd.SetArgs([]string{"--version", "--port=1337", "--config", cfgPath}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + func TestListOutput(t *testing.T) { t.Run("available", func(t *testing.T) { ioStreams, _ := testStream() diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml new file mode 100644 index 00000000..9b65e3ad --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-missing-url.toml @@ -0,0 +1,2 @@ +toolsets = ["core", "kiali"] + diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml new file mode 100644 index 00000000..2762762f --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml @@ -0,0 +1,5 @@ +toolsets = ["core", "kiali"] + +[kiali] +url = "http://kiali" + diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 3b5733e1..d5da8385 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -4,6 +4,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" @@ -37,3 +38,16 @@ func (k *Kubernetes) NewHelm() *helm.Helm { // This is a derived Kubernetes, so it already has the Helm initialized return helm.NewHelm(k.manager) } + +// NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token +// as the underlying Kubernetes manager. The token is taken from the manager rest.Config. +func (k *Kubernetes) NewKiali() *kiali.Kiali { + if k == nil || k.manager == nil || k.manager.staticConfig == nil { + return nil + } + km := kiali.NewManager(k.manager.staticConfig) + if k.manager.cfg != nil { + km.BearerToken = k.manager.cfg.BearerToken + } + return km.GetKiali() +} diff --git a/pkg/mcp/m3labs.go b/pkg/mcp/m3labs.go index ade0f56b..db3b7752 100644 --- a/pkg/mcp/m3labs.go +++ b/pkg/mcp/m3labs.go @@ -45,7 +45,6 @@ func ServerToolToM3LabsServerTool(s *Server, tools []api.ServerTool) ([]server.S if err != nil { return nil, err } - result, err := tool.Handler(api.ToolHandlerParams{ Context: ctx, Kubernetes: k, diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 3295d72b..464eefc8 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -3,3 +3,4 @@ package mcp import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" +import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" diff --git a/pkg/toolsets/kiali/mesh.go b/pkg/toolsets/kiali/mesh.go new file mode 100644 index 00000000..d13fa48b --- /dev/null +++ b/pkg/toolsets/kiali/mesh.go @@ -0,0 +1,42 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initMeshStatus() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "mesh_status", + Description: "Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Mesh Status: Components Overview", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: meshStatusHandler, + }) + return ret +} + +func meshStatusHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + k := params.NewKiali() + content, err := k.MeshStatus(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve mesh status: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go new file mode 100644 index 00000000..a175888a --- /dev/null +++ b/pkg/toolsets/kiali/toolset.go @@ -0,0 +1,31 @@ +package kiali + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +func (t *Toolset) GetName() string { + return "kiali" +} + +func (t *Toolset) GetDescription() string { + return "Most common tools for managing Kiali" +} + +func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { + return slices.Concat( + initMeshStatus(), + ) +} + +func init() { + toolsets.Register(&Toolset{}) +}