diff --git a/README.md b/README.md index ee592bd5..d9d4c01e 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Enabling only the toolsets you need can help reduce the context size and improve ### Available Toolsets -The following sets of tools are available (all on by default): +The following sets of tools are available (all on by default). @@ -213,9 +213,12 @@ The following sets of tools are available (all on by default): | config | View and manage the current local Kubernetes configuration (kubeconfig) | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | | helm | Tools for managing Helm charts and releases | +| kiali | Most common tools for managing Kiali | +See more info about Kiali integration in [docs/KIALI_INTEGRATION.md](docs/KIALI_INTEGRATION.md). + ### Tools In case multi-cluster support is enabled (default) and you have access to multiple clusters, all applicable tools will include an additional `context` argument to specify the Kubernetes context (cluster) to use for that operation. @@ -343,6 +346,136 @@ In case multi-cluster support is enabled (default) and you have access to multip +
+ +kiali + +- **graph** - Check the status of my mesh by querying Kiali graph + - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph + +- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status + +- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details + +- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_data` (`string`) **(required)** - JSON data for the new object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_delete** - Delete an existing Istio object using DELETE method. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **validations_list** - List all the validations in the current cluster from all namespaces + - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from + +- **namespaces** - Get all namespaces in the mesh that the user has access to + +- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces + +- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get details for + +- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the service + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `service` (`string`) **(required)** - Name of the service to get metrics for + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + +- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces + +- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `workload` (`string`) **(required)** - Name of the workload to get details for + +- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + - `workload` (`string`) **(required)** - Name of the workload to get metrics for + +- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type + - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces + - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional + - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' + - `type` (`string`) - Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app' + +- **workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. + - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs + - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) + - `workload` (`string`) **(required)** - Name of the workload to get logs for + +- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `app` (`string`) **(required)** - Name of the app to get traces for + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the app + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get traces for + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + - `workload` (`string`) **(required)** - Name of the workload to get traces for + +
+ diff --git a/docs/KIALI_INTEGRATION.md b/docs/KIALI_INTEGRATION.md new file mode 100644 index 00000000..00952744 --- /dev/null +++ b/docs/KIALI_INTEGRATION.md @@ -0,0 +1,170 @@ +## Kiali integration + +This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph). + +### Enable the Kiali toolset + +Enable the Kiali tools via the server TOML configuration file. + +Config (TOML): + +```toml +toolsets = ["core", "kiali"] + +[toolset_configs.kiali] +url = "https://kiali.example" +# insecure = true # optional: allow insecure TLS (not recommended in production) +# certificate_authority = """-----BEGIN CERTIFICATE----- +# MIID... +# -----END CERTIFICATE-----""" +# When url is https and insecure is false, certificate_authority is required. +``` + +When the `kiali` toolset is enabled, a Kiali toolset configuration is required via `[toolset_configs.kiali]`. If missing or invalid, the server will refuse to start. + +### How authentication works + +- The server uses your existing Kubernetes credentials (from kubeconfig or in-cluster) to set a bearer token for Kiali calls. +- If you pass an HTTP Authorization header to the MCP HTTP endpoint, that is not required for Kiali; Kiali calls use the server's configured token. + +### Available tools (initial) + +
+ +kiali + +- **graph** - Check the status of my mesh by querying Kiali graph + - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph + +- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status + +- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details + +- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_data` (`string`) **(required)** - JSON data for the new object + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **istio_object_delete** - Delete an existing Istio object using DELETE method. + - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) **(required)** - Name of the Istio object + - `namespace` (`string`) **(required)** - Namespace containing the Istio object + - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **validations_list** - List all the validations in the current cluster from all namespaces + - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from + +- **namespaces** - Get all namespaces in the mesh that the user has access to + +- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces + +- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get details for + +- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the service + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `service` (`string`) **(required)** - Name of the service to get metrics for + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + +- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information + - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces + +- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `workload` (`string`) **(required)** - Name of the workload to get details for + +- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + - `workload` (`string`) **(required)** - Name of the workload to get metrics for + +- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type + - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces + - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional + - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' + - `type` (`string`) - Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app' + +- **workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. + - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs + - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) + - `workload` (`string`) **(required)** - Name of the workload to get logs for + +- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `app` (`string`) **(required)** - Name of the app to get traces for + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the app + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the service + - `service` (`string`) **(required)** - Name of the service to get traces for + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + +- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) + - `limit` (`integer`) - Maximum number of traces to return (default: 100) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) + - `tags` (`string`) - JSON string of tags to filter traces (optional) + - `workload` (`string`) **(required)** - Name of the workload to get traces for + +
+ +### Troubleshooting + +- Missing Kiali configuration when `kiali` toolset is enabled → set `[toolset_configs.kiali].url` in the config TOML. +- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL. +- TLS certificate validation: + - If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the PEM-encoded certificate(s) used by the Kiali server. This field expects inline PEM content, not a file path. You may concatenate multiple PEM blocks to include an intermediate chain. + - For non-production environments you can set `[toolset_configs.kiali].insecure = true` to skip certificate verification. + + diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index cdf695fc..1a9ba276 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -15,6 +15,7 @@ import ( _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali" ) type OpenShift struct{} diff --git a/pkg/config/config.go b/pkg/config/config.go index 81bec2b7..5601e7f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -68,8 +68,14 @@ 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"` + // Toolset-specific configurations + // This map holds raw TOML primitives that will be parsed by registered toolset parsers + ToolsetConfigs map[string]toml.Primitive `toml:"toolset_configs,omitempty"` + // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]ProviderConfig + // Internal: parsed toolset configs (not exposed to TOML package) + parsedToolsetConfigs map[string]ToolsetConfig // Internal: the config.toml directory, to help resolve relative file paths configDirPath string @@ -127,6 +133,10 @@ func ReadToml(configData []byte, opts ...ReadConfigOpt) (*StaticConfig, error) { return nil, err } + if err := config.parseToolsetConfigs(md); err != nil { + return nil, err + } + return config, nil } @@ -163,3 +173,43 @@ func (c *StaticConfig) parseClusterProviderConfigs(md toml.MetaData) error { return nil } + +func (c *StaticConfig) parseToolsetConfigs(md toml.MetaData) error { + if c.parsedToolsetConfigs == nil { + c.parsedToolsetConfigs = make(map[string]ToolsetConfig, len(c.ToolsetConfigs)) + } + + ctx := withConfigDirPath(context.Background(), c.configDirPath) + + for name, primitive := range c.ToolsetConfigs { + parser, ok := getToolsetConfigParser(name) + if !ok { + continue + } + + toolsetConfig, err := parser(ctx, primitive, md) + if err != nil { + return fmt.Errorf("failed to parse config for Toolset '%s': %w", name, err) + } + + if err := toolsetConfig.Validate(); err != nil { + return fmt.Errorf("invalid config file for Toolset '%s': %w", name, err) + } + + c.parsedToolsetConfigs[name] = toolsetConfig + } + + return nil +} + +func (c *StaticConfig) GetToolsetConfig(name string) (ToolsetConfig, bool) { + cfg, ok := c.parsedToolsetConfigs[name] + return cfg, ok +} + +func (c *StaticConfig) SetToolsetConfig(name string, cfg ToolsetConfig) { + if c.parsedToolsetConfigs == nil { + c.parsedToolsetConfigs = make(map[string]ToolsetConfig) + } + c.parsedToolsetConfigs[name] = cfg +} diff --git a/pkg/config/toolset_config.go b/pkg/config/toolset_config.go new file mode 100644 index 00000000..fb230e71 --- /dev/null +++ b/pkg/config/toolset_config.go @@ -0,0 +1,34 @@ +package config + +import ( + "context" + "fmt" + + "github.com/BurntSushi/toml" +) + +// ToolsetConfig is the interface that all toolset-specific configurations must implement. +// Each toolset registers a factory function to parse its config from TOML primitives +type ToolsetConfig interface { + Validate() error +} + +type ToolsetConfigParser func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ToolsetConfig, error) + +var ( + toolsetConfigParsers = make(map[string]ToolsetConfigParser) +) + +func RegisterToolsetConfig(name string, parser ToolsetConfigParser) { + if _, exists := toolsetConfigParsers[name]; exists { + panic(fmt.Sprintf("toolset config parser already registered for toolset '%s'", name)) + } + + toolsetConfigParsers[name] = parser +} + +func getToolsetConfigParser(name string) (ToolsetConfigParser, bool) { + parser, ok := toolsetConfigParsers[name] + + return parser, ok +} diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go new file mode 100644 index 00000000..82e8d7f3 --- /dev/null +++ b/pkg/kiali/config.go @@ -0,0 +1,49 @@ +package kiali + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/pkg/config" +) + +// Config holds Kiali toolset configuration +type Config struct { + Url string `toml:"url"` + Insecure bool `toml:"insecure,omitempty"` + CertificateAuthority string `toml:"certificate_authority,omitempty"` +} + +var _ config.ToolsetConfig = (*Config)(nil) + +func (c *Config) Validate() error { + if c == nil { + return errors.New("kiali config is nil") + } + if c.Url == "" { + return errors.New("url is required") + } + if u, err := url.Parse(c.Url); err != nil || u.Scheme == "" || u.Host == "" { + return errors.New("url must be a valid URL") + } + u, _ := url.Parse(c.Url) + if strings.EqualFold(u.Scheme, "https") && !c.Insecure && strings.TrimSpace(c.CertificateAuthority) == "" { + return errors.New("certificate_authority is required for https when insecure is false") + } + return nil +} + +func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.ToolsetConfig, error) { + var cfg Config + if err := md.PrimitiveDecode(primitive, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func init() { + config.RegisterToolsetConfig("kiali", kialiToolsetParser) +} diff --git a/pkg/kiali/endpoints.go b/pkg/kiali/endpoints.go new file mode 100644 index 00000000..1c4c3938 --- /dev/null +++ b/pkg/kiali/endpoints.go @@ -0,0 +1,27 @@ +package kiali + +// Kiali API endpoint paths shared across this package. +const ( + // MeshGraph is the Kiali API path that returns the mesh graph/status. + AuthInfoEndpoint = "/api/auth/info" + MeshGraphEndpoint = "/api/mesh/graph" + GraphEndpoint = "/api/namespaces/graph" + HealthEndpoint = "/api/clusters/health" + IstioConfigEndpoint = "/api/istio/config" + IstioObjectEndpoint = "/api/namespaces/%s/istio/%s/%s/%s/%s" + IstioObjectCreateEndpoint = "/api/namespaces/%s/istio/%s/%s/%s" + NamespacesEndpoint = "/api/namespaces" + PodDetailsEndpoint = "/api/namespaces/%s/pods/%s" + PodsLogsEndpoint = "/api/namespaces/%s/pods/%s/logs" + ServicesEndpoint = "/api/clusters/services" + ServiceDetailsEndpoint = "/api/namespaces/%s/services/%s" + ServiceMetricsEndpoint = "/api/namespaces/%s/services/%s/metrics" + AppTracesEndpoint = "/api/namespaces/%s/apps/%s/traces" + ServiceTracesEndpoint = "/api/namespaces/%s/services/%s/traces" + WorkloadTracesEndpoint = "/api/namespaces/%s/workloads/%s/traces" + WorkloadsEndpoint = "/api/clusters/workloads" + WorkloadDetailsEndpoint = "/api/namespaces/%s/workloads/%s" + WorkloadMetricsEndpoint = "/api/namespaces/%s/workloads/%s/metrics" + ValidationsEndpoint = "/api/istio/validations" + ValidationsListEndpoint = "/api/istio/validations" +) diff --git a/pkg/kiali/graph.go b/pkg/kiali/graph.go new file mode 100644 index 00000000..be3ac3c4 --- /dev/null +++ b/pkg/kiali/graph.go @@ -0,0 +1,45 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// Graph calls the Kiali graph API using the provided Authorization header value. +// `namespaces` may contain zero, one or many namespaces. If empty, the API may return an empty graph +// or the server default, depending on Kiali configuration. +func (k *Kiali) Graph(ctx context.Context, namespaces []string) (string, error) { + u, err := url.Parse(GraphEndpoint) + if err != nil { + return "", err + } + q := u.Query() + // Static graph parameters per requirements + q.Set("duration", "60s") + q.Set("graphType", "versionedApp") + q.Set("includeIdleEdges", "false") + q.Set("injectServiceNodes", "true") + q.Set("boxBy", "cluster,namespace,app") + q.Set("ambientTraffic", "none") + q.Set("appenders", "deadNode,istio,serviceEntry,meshCheck,workloadEntry,health") + q.Set("rateGrpc", "requests") + q.Set("rateHttp", "requests") + q.Set("rateTcp", "sent") + // Optional namespaces param + cleaned := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + ns = strings.TrimSpace(ns) + if ns != "" { + cleaned = append(cleaned, ns) + } + } + if len(cleaned) > 0 { + q.Set("namespaces", strings.Join(cleaned, ",")) + } + u.RawQuery = q.Encode() + endpoint := u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/health.go b/pkg/kiali/health.go new file mode 100644 index 00000000..ff9d6226 --- /dev/null +++ b/pkg/kiali/health.go @@ -0,0 +1,40 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" +) + +// Health returns health status for apps, workloads, and services across namespaces. +// Parameters: +// - namespaces: comma-separated list of namespaces (optional, if empty returns health for all accessible namespaces) +// - queryParams: optional query parameters map for filtering health data (e.g., "type", "rateInterval", "queryTime") +// - type: health type - "app", "service", or "workload" (default: "app") +// - rateInterval: rate interval for fetching error rate (default: "10m") +// - queryTime: Unix timestamp for the prometheus query (optional) +func (k *Kiali) Health(ctx context.Context, namespaces string, queryParams map[string]string) (string, error) { + // Build query parameters + u, err := url.Parse(HealthEndpoint) + if err != nil { + return "", err + } + q := u.Query() + + // Add namespaces if provided + if namespaces != "" { + q.Set("namespaces", namespaces) + } + + // Add optional query parameters + if len(queryParams) > 0 { + for key, value := range queryParams { + q.Set(key, value) + } + } + + u.RawQuery = q.Encode() + endpoint := u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/istio.go b/pkg/kiali/istio.go new file mode 100644 index 00000000..bd831d6c --- /dev/null +++ b/pkg/kiali/istio.go @@ -0,0 +1,152 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" +) + +// IstioConfig calls the Kiali Istio config API to get all Istio objects in the mesh. +// Returns the full YAML resources and additional details about each object. +func (k *Kiali) IstioConfig(ctx context.Context) (string, error) { + endpoint := IstioConfigEndpoint + "?validate=true" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// IstioObjectDetails returns detailed information about a specific Istio object. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - name: the name of the resource +func (k *Kiali) IstioObjectDetails(ctx context.Context, namespace, group, version, kind, name string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint+"?validate=true&help=true", + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// IstioObjectPatch patches an existing Istio object using PATCH method. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - name: the name of the resource +// - jsonPatch: the JSON patch data to apply +func (k *Kiali) IstioObjectPatch(ctx context.Context, namespace, group, version, kind, name, jsonPatch string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + if jsonPatch == "" { + return "", fmt.Errorf("json patch data is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodPatch, endpoint, "application/json", strings.NewReader(jsonPatch)) +} + +// IstioObjectCreate creates a new Istio object using POST method. +// Parameters: +// - namespace: the namespace where the Istio object will be created +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute") +// - jsonData: the JSON data for the new object +func (k *Kiali) IstioObjectCreate(ctx context.Context, namespace, group, version, kind, jsonData string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if jsonData == "" { + return "", fmt.Errorf("json data is required") + } + endpoint := fmt.Sprintf(IstioObjectCreateEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind)) + + return k.executeRequest(ctx, http.MethodPost, endpoint, "application/json", strings.NewReader(jsonData)) +} + +// IstioObjectDelete deletes an existing Istio object using DELETE method. +// Parameters: +// - namespace: the namespace containing the Istio object +// - group: the API group (e.g., "networking.istio.io", "gateway.networking.k8s.io") +// - version: the API version (e.g., "v1", "v1beta1") +// - kind: the resource kind (e.g., "DestinationRule", "VirtualService", "HTTPRoute", "Gateway") +// - name: the name of the resource +func (k *Kiali) IstioObjectDelete(ctx context.Context, namespace, group, version, kind, name string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if group == "" { + return "", fmt.Errorf("group is required") + } + if version == "" { + return "", fmt.Errorf("version is required") + } + if kind == "" { + return "", fmt.Errorf("kind is required") + } + if name == "" { + return "", fmt.Errorf("name is required") + } + endpoint := fmt.Sprintf(IstioObjectEndpoint, + url.PathEscape(namespace), + url.PathEscape(group), + url.PathEscape(version), + url.PathEscape(kind), + url.PathEscape(name)) + + return k.executeRequest(ctx, http.MethodDelete, endpoint, "", nil) +} diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go new file mode 100644 index 00000000..cf5c9284 --- /dev/null +++ b/pkg/kiali/kiali.go @@ -0,0 +1,144 @@ +package kiali + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" +) + +type Kiali struct { + bearerToken string + kialiURL string + kialiInsecure bool + certificateAuthority string +} + +// NewKiali creates a new Kiali instance +func NewKiali(config *config.StaticConfig, kubernetes *rest.Config) *Kiali { + kiali := &Kiali{bearerToken: kubernetes.BearerToken} + if cfg, ok := config.GetToolsetConfig("kiali"); ok { + if kc, ok := cfg.(*Config); ok && kc != nil { + kiali.kialiURL = kc.Url + kiali.kialiInsecure = kc.Insecure + kiali.certificateAuthority = kc.CertificateAuthority + } + } + return kiali +} + +// 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.kialiURL == "" { + return "", fmt.Errorf("kiali client not initialized") + } + baseStr := strings.TrimSpace(k.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 { + // Base TLS configuration, optionally extended with a custom CA + tlsConfig := &tls.Config{ + InsecureSkipVerify: k.kialiInsecure, + } + + // If a custom Certificate Authority PEM is configured, load and add it + if caPEM := strings.TrimSpace(k.certificateAuthority); caPEM != "" { + // Start with the host system pool when possible so we don't drop system roots + var certPool *x509.CertPool + if systemPool, err := x509.SystemCertPool(); err == nil && systemPool != nil { + certPool = systemPool + } else { + certPool = x509.NewCertPool() + } + if ok := certPool.AppendCertsFromPEM([]byte(caPEM)); ok { + tlsConfig.RootCAs = certPool + } else { + klog.V(0).Infof("failed to append provided certificate authority PEM; proceeding without custom CA") + } + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + } +} + +// 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 { + return "" + } + token := strings.TrimSpace(k.bearerToken) + if token == "" { + return "" + } + if strings.HasPrefix(token, "Bearer ") { + return token + } + return "Bearer " + token +} + +// executeRequest executes an HTTP request (optionally with a body) and handles common error scenarios. +func (k *Kiali) executeRequest(ctx context.Context, method, endpoint, contentType string, body io.Reader) (string, error) { + if method == "" { + method = http.MethodGet + } + ApiCallURL, err := k.validateAndGetURL(endpoint) + if err != nil { + return "", err + } + klog.V(0).Infof("kiali API call: %s %s", method, ApiCallURL) + req, err := http.NewRequestWithContext(ctx, method, ApiCallURL, body) + if err != nil { + return "", err + } + authHeader := k.authorizationHeader() + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + client := k.createHTTPClient() + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if len(respBody) > 0 { + return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(respBody))) + } + return "", fmt.Errorf("kiali API error: status %d", resp.StatusCode) + } + return string(respBody), nil +} diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go new file mode 100644 index 00000000..fc08cb9f --- /dev/null +++ b/pkg/kiali/kiali_test.go @@ -0,0 +1,137 @@ +package kiali + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/suite" +) + +type KialiSuite struct { + suite.Suite + MockServer *test.MockServer + Config *config.StaticConfig +} + +func (s *KialiSuite) SetupTest() { + s.MockServer = test.NewMockServer() + s.MockServer.Config().BearerToken = "" + s.Config = config.Default() +} + +func (s *KialiSuite) TearDownTest() { + s.MockServer.Close() +} + +func (s *KialiSuite) TestNewKiali_SetsFields() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + insecure = true + `))) + s.MockServer.Config().BearerToken = "bearer-token" + k := NewKiali(s.Config, s.MockServer.Config()) + + s.Run("URL is set", func() { + s.Equal("https://kiali.example/", k.kialiURL, "Unexpected Kiali URL") + }) + s.Run("Insecure is set", func() { + s.True(k.kialiInsecure, "Expected Kiali Insecure to be true") + }) + s.Run("BearerToken is set", func() { + s.Equal("bearer-token", k.bearerToken, "Unexpected Kiali BearerToken") + }) +} + +func (s *KialiSuite) TestNewKiali_InvalidConfig() { + cfg, err := config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "://invalid-url" + `)) + s.Error(err, "Expected error reading invalid config") + s.ErrorContains(err, "url must be a valid URL", "Unexpected error message") + s.Nil(cfg, "Unexpected Kiali config") +} + +func (s *KialiSuite) TestCertificateRequiredForHTTPSWhenNotInsecure() { + cfg, err := config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + `)) + s.Error(err, "Expected error when https and insecure=false without certificate_authority") + s.ErrorContains(err, "certificate_authority is required for https when insecure is false", "Unexpected error message") + s.Nil(cfg, "Unexpected Kiali config") +} + +func (s *KialiSuite) TestValidateAndGetURL() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + insecure = true + `))) + k := NewKiali(s.Config, s.MockServer.Config()) + + s.Run("Computes full URL", func() { + s.Run("with leading slash", func() { + full, err := k.validateAndGetURL("/api/path") + s.Require().NoError(err, "Expected no error validating URL") + s.Equal("https://kiali.example/api/path", full, "Unexpected full URL") + }) + + s.Run("without leading slash", func() { + full, err := k.validateAndGetURL("api/path") + s.Require().NoError(err, "Expected no error validating URL") + s.Equal("https://kiali.example/api/path", full, "Unexpected full URL") + }) + + s.Run("with query parameters, preserves query", func() { + full, err := k.validateAndGetURL("/api/path?x=1&y=2") + s.Require().NoError(err, "Expected no error validating URL") + u, err := url.Parse(full) + s.Require().NoError(err, "Expected to parse full URL") + s.Equal("/api/path", u.Path, "Unexpected path in parsed URL") + s.Equal("1", u.Query().Get("x"), "Unexpected query parameter x") + s.Equal("2", u.Query().Get("y"), "Unexpected query parameter y") + }) + }) +} + +// CurrentAuthorizationHeader behavior is now implicit via executeRequest using Manager.BearerToken + +func (s *KialiSuite) TestExecuteRequest() { + // setup test server to assert path and auth header + var seenAuth string + var seenPath string + s.MockServer.Config().BearerToken = "token-xyz" + s.MockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + seenPath = r.URL.String() + _, _ = w.Write([]byte("ok")) + })) + + s.Config = test.Must(config.ReadToml([]byte(fmt.Sprintf(` + [toolset_configs.kiali] + url = "%s" + `, s.MockServer.Config().Host)))) + k := NewKiali(s.Config, s.MockServer.Config()) + + out, err := k.executeRequest(s.T().Context(), http.MethodGet, "/api/ping?q=1", "", nil) + s.Require().NoError(err, "Expected no error executing request") + s.Run("auth header set", func() { + s.Equal("Bearer token-xyz", seenAuth, "Unexpected Authorization header") + }) + s.Run("path is correct", func() { + s.Equal("/api/ping?q=1", seenPath, "Unexpected path") + }) + s.Run("response body is correct", func() { + s.Equal("ok", out, "Unexpected response body") + }) +} + +func TestKiali(t *testing.T) { + suite.Run(t, new(KialiSuite)) +} diff --git a/pkg/kiali/logs.go b/pkg/kiali/logs.go new file mode 100644 index 00000000..aae6a8eb --- /dev/null +++ b/pkg/kiali/logs.go @@ -0,0 +1,190 @@ +package kiali + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// WorkloadLogs returns logs for a specific workload's pods in a namespace. +// This method first gets workload details to find associated pods, then retrieves logs for each pod. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - container: container name (optional, will be auto-detected if not provided) +// - service: service name (optional) +// - duration: time duration (e.g., "5m", "1h") - optional +// - logType: type of logs (app, proxy, ztunnel, waypoint) - optional +// - sinceTime: Unix timestamp for start time - optional +// - maxLines: maximum number of lines to return - optional +func (k *Kiali) WorkloadLogs(ctx context.Context, namespace string, workload string, container string, service string, duration string, logType string, sinceTime string, maxLines string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + // Container is optional - will be auto-detected if not provided + + // First, get workload details to find associated pods + workloadDetails, err := k.WorkloadDetails(ctx, namespace, workload) + if err != nil { + return "", fmt.Errorf("failed to get workload details: %v", err) + } + + // Parse the workload details JSON to extract pod names and containers + var workloadData struct { + Pods []struct { + Name string `json:"name"` + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } `json:"pods"` + } + + if err := json.Unmarshal([]byte(workloadDetails), &workloadData); err != nil { + return "", fmt.Errorf("failed to parse workload details: %v", err) + } + + if len(workloadData.Pods) == 0 { + return "", fmt.Errorf("no pods found for workload %s in namespace %s", workload, namespace) + } + + // Collect logs from all pods + var allLogs []string + for _, pod := range workloadData.Pods { + // Auto-detect container if not provided + podContainer := container + if podContainer == "" { + // Find the main application container (not istio-proxy or istio-init) + for _, c := range pod.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + podContainer = c.Name + break + } + } + // If no app container found, use the first container + if podContainer == "" && len(pod.Containers) > 0 { + podContainer = pod.Containers[0].Name + } + } + + if podContainer == "" { + allLogs = append(allLogs, fmt.Sprintf("Error: No container found for pod %s", pod.Name)) + continue + } + + podLogs, err := k.PodLogs(ctx, namespace, pod.Name, podContainer, workload, service, duration, logType, sinceTime, maxLines) + if err != nil { + // Log the error but continue with other pods + allLogs = append(allLogs, fmt.Sprintf("Error getting logs for pod %s: %v", pod.Name, err)) + continue + } + if podLogs != "" { + allLogs = append(allLogs, fmt.Sprintf("=== Pod: %s (Container: %s) ===\n%s", pod.Name, podContainer, podLogs)) + } + } + + if len(allLogs) == 0 { + return "", fmt.Errorf("no logs found for workload %s in namespace %s", workload, namespace) + } + + return strings.Join(allLogs, "\n\n"), nil +} + +// PodLogs returns logs for a specific pod using the Kiali API endpoint. +// Parameters: +// - namespace: the namespace containing the pod +// - podName: the name of the pod +// - container: container name (optional, will be auto-detected if not provided) +// - workload: workload name (optional) +// - service: service name (optional) +// - duration: time duration (e.g., "5m", "1h") - optional +// - logType: type of logs (app, proxy, ztunnel, waypoint) - optional +// - sinceTime: Unix timestamp for start time - optional +// - maxLines: maximum number of lines to return - optional +func (k *Kiali) PodLogs(ctx context.Context, namespace string, podName string, container string, workload string, service string, duration string, logType string, sinceTime string, maxLines string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if podName == "" { + return "", fmt.Errorf("pod name is required") + } + // Container is optional - will be auto-detected if not provided + podContainer := container + if podContainer == "" { + // Get pod details to find containers + podDetails, err := k.executeRequest(ctx, http.MethodGet, fmt.Sprintf(PodDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(podName)), "", nil) + if err != nil { + return "", fmt.Errorf("failed to get pod details: %v", err) + } + + // Parse pod details to extract container names + var podData struct { + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } + + if err := json.Unmarshal([]byte(podDetails), &podData); err != nil { + return "", fmt.Errorf("failed to parse pod details: %v", err) + } + + // Find the main application container (not istio-proxy or istio-init) + for _, c := range podData.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + podContainer = c.Name + break + } + } + // If no app container found, use the first container + if podContainer == "" && len(podData.Containers) > 0 { + podContainer = podData.Containers[0].Name + } + + if podContainer == "" { + return "", fmt.Errorf("no container found for pod %s in namespace %s", podName, namespace) + } + } + + endpoint := fmt.Sprintf(PodsLogsEndpoint, + url.PathEscape(namespace), url.PathEscape(podName)) + + // Add query parameters + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + + // Required parameters + q.Set("container", podContainer) + + // Optional parameters + if workload != "" { + q.Set("workload", workload) + } + if service != "" { + q.Set("service", service) + } + if duration != "" { + q.Set("duration", duration) + } + if logType != "" { + q.Set("logType", logType) + } + if sinceTime != "" { + q.Set("sinceTime", sinceTime) + } + if maxLines != "" { + q.Set("maxLines", maxLines) + } + + u.RawQuery = q.Encode() + endpoint = u.String() + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/mesh.go b/pkg/kiali/mesh.go new file mode 100644 index 00000000..c9043791 --- /dev/null +++ b/pkg/kiali/mesh.go @@ -0,0 +1,22 @@ +package kiali + +import ( + "context" + "net/http" + "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(MeshGraphEndpoint) + if err != nil { + return "", err + } + q := u.Query() + q.Set("includeGateways", "false") + q.Set("includeWaypoints", "false") + u.RawQuery = q.Encode() + return k.executeRequest(ctx, http.MethodGet, u.String(), "", nil) +} diff --git a/pkg/kiali/mesh_test.go b/pkg/kiali/mesh_test.go new file mode 100644 index 00000000..a729015d --- /dev/null +++ b/pkg/kiali/mesh_test.go @@ -0,0 +1,40 @@ +package kiali + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" +) + +func (s *KialiSuite) TestMeshStatus() { + var capturedURL *url.URL + s.MockServer.Config().BearerToken = "token-xyz" + s.MockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := *r.URL + capturedURL = &u + _, _ = w.Write([]byte("graph")) + })) + + s.Config = test.Must(config.ReadToml([]byte(fmt.Sprintf(` + [toolset_configs.kiali] + url = "%s" + `, s.MockServer.Config().Host)))) + k := NewKiali(s.Config, s.MockServer.Config()) + + out, err := k.MeshStatus(s.T().Context()) + s.Require().NoError(err, "Expected no error executing request") + s.Run("response body is correct", func() { + s.Equal("graph", out, "Unexpected response body") + }) + s.Run("path is correct", func() { + s.Equal("/api/mesh/graph", capturedURL.Path, "Unexpected path") + }) + s.Run("query parameters are correct", func() { + s.Equal("false", capturedURL.Query().Get("includeGateways"), "Unexpected includeGateways query parameter") + s.Equal("false", capturedURL.Query().Get("includeWaypoints"), "Unexpected includeWaypoints query parameter") + }) + +} diff --git a/pkg/kiali/namespaces.go b/pkg/kiali/namespaces.go new file mode 100644 index 00000000..00217ebb --- /dev/null +++ b/pkg/kiali/namespaces.go @@ -0,0 +1,12 @@ +package kiali + +import ( + "context" + "net/http" +) + +// ListNamespaces calls the Kiali namespaces API using the provided Authorization header value. +// Returns all namespaces in the mesh that the user has access to. +func (k *Kiali) ListNamespaces(ctx context.Context) (string, error) { + return k.executeRequest(ctx, http.MethodGet, NamespacesEndpoint, "", nil) +} diff --git a/pkg/kiali/services.go b/pkg/kiali/services.go new file mode 100644 index 00000000..1b8dc9be --- /dev/null +++ b/pkg/kiali/services.go @@ -0,0 +1,64 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// ServicesList returns the list of services across specified namespaces. +func (k *Kiali) ServicesList(ctx context.Context, namespaces string) (string, error) { + endpoint := ServicesEndpoint + "?health=true&istioResources=true&rateInterval=60s&onlyDefinitions=false" + if namespaces != "" { + endpoint += "&namespaces=" + url.QueryEscape(namespaces) + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceDetails returns the details for a specific service in a namespace. +func (k *Kiali) ServiceDetails(ctx context.Context, namespace string, service string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + endpoint := fmt.Sprintf(ServiceDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(service)) + "?validate=true&rateInterval=60s" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceMetrics returns the metrics for a specific service in a namespace. +// Parameters: +// - namespace: the namespace containing the service +// - service: the name of the service +// - queryParams: optional query parameters map for filtering metrics (e.g., "duration", "step", "rateInterval", "direction", "reporter", "filters[]", "byLabels[]", etc.) +func (k *Kiali) ServiceMetrics(ctx context.Context, namespace string, service string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + + endpoint := fmt.Sprintf(ServiceMetricsEndpoint, + url.PathEscape(namespace), url.PathEscape(service)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/traces.go b/pkg/kiali/traces.go new file mode 100644 index 00000000..ba54b54a --- /dev/null +++ b/pkg/kiali/traces.go @@ -0,0 +1,107 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// AppTraces returns distributed tracing data for a specific app in a namespace. +// Parameters: +// - namespace: the namespace containing the app +// - app: the name of the app +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) AppTraces(ctx context.Context, namespace string, app string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if app == "" { + return "", fmt.Errorf("app name is required") + } + + endpoint := fmt.Sprintf(AppTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(app)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// ServiceTraces returns distributed tracing data for a specific service in a namespace. +// Parameters: +// - namespace: the namespace containing the service +// - service: the name of the service +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) ServiceTraces(ctx context.Context, namespace string, service string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if service == "" { + return "", fmt.Errorf("service name is required") + } + + endpoint := fmt.Sprintf(ServiceTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(service)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadTraces returns distributed tracing data for a specific workload in a namespace. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - queryParams: optional query parameters map for filtering traces (e.g., "startMicros", "endMicros", "limit", "minDuration", "tags", "clusterName") +func (k *Kiali) WorkloadTraces(ctx context.Context, namespace string, workload string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + + endpoint := fmt.Sprintf(WorkloadTracesEndpoint, + url.PathEscape(namespace), url.PathEscape(workload)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/validations.go b/pkg/kiali/validations.go new file mode 100644 index 00000000..400ed66f --- /dev/null +++ b/pkg/kiali/validations.go @@ -0,0 +1,34 @@ +package kiali + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// ValidationsList calls the Kiali validations API using the provided Authorization header value. +// `namespaces` may contain zero, one or many namespaces. If empty, returns validations from all namespaces. +func (k *Kiali) ValidationsList(ctx context.Context, namespaces []string) (string, error) { + // Add namespaces query parameter if any provided + cleaned := make([]string, 0, len(namespaces)) + endpoint := ValidationsEndpoint + for _, ns := range namespaces { + ns = strings.TrimSpace(ns) + if ns != "" { + cleaned = append(cleaned, ns) + } + } + if len(cleaned) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + q.Set("namespaces", strings.Join(cleaned, ",")) + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kiali/workloads.go b/pkg/kiali/workloads.go new file mode 100644 index 00000000..ccd5538a --- /dev/null +++ b/pkg/kiali/workloads.go @@ -0,0 +1,64 @@ +package kiali + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// WorkloadsList returns the list of workloads across specified namespaces. +func (k *Kiali) WorkloadsList(ctx context.Context, namespaces string) (string, error) { + + endpoint := WorkloadsEndpoint + "?health=true&istioResources=true&rateInterval=60s" + if namespaces != "" { + endpoint += "&namespaces=" + url.QueryEscape(namespaces) + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadDetails returns the details for a specific workload in a namespace. +func (k *Kiali) WorkloadDetails(ctx context.Context, namespace string, workload string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + endpoint := fmt.Sprintf(WorkloadDetailsEndpoint, url.PathEscape(namespace), url.PathEscape(workload)) + "?validate=true&rateInterval=60s&health=true" + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} + +// WorkloadMetrics returns the metrics for a specific workload in a namespace. +// Parameters: +// - namespace: the namespace containing the workload +// - workload: the name of the workload +// - queryParams: optional query parameters map for filtering metrics (e.g., "duration", "step", "rateInterval", "direction", "reporter", "filters[]", "byLabels[]", etc.) +func (k *Kiali) WorkloadMetrics(ctx context.Context, namespace string, workload string, queryParams map[string]string) (string, error) { + if namespace == "" { + return "", fmt.Errorf("namespace is required") + } + if workload == "" { + return "", fmt.Errorf("workload name is required") + } + + endpoint := fmt.Sprintf(WorkloadMetricsEndpoint, url.PathEscape(namespace), url.PathEscape(workload)) + + // Add query parameters if provided + if len(queryParams) > 0 { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + endpoint = u.String() + } + + return k.executeRequest(ctx, http.MethodGet, endpoint, "", nil) +} diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 22521667..a464daab 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) } }) 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..b389d264 --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/kiali-toolset-with-url.toml @@ -0,0 +1,5 @@ +toolsets = ["core", "kiali"] + +[toolset_configs.kiali] +url = "http://kiali" + diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 3b5733e1..7de8d6ff 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,9 @@ 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 derived Kubernetes manager. +func (k *Kubernetes) NewKiali() *kiali.Kiali { + return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg) +} 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/graph.go b/pkg/toolsets/kiali/graph.go new file mode 100644 index 00000000..6bf32d47 --- /dev/null +++ b/pkg/toolsets/kiali/graph.go @@ -0,0 +1,86 @@ +package kiali + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initGraph() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "graph", + Description: "Check the status of my mesh by querying Kiali graph", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional single namespace to include in the graph (alternative to namespaces)", + }, + "namespaces": { + Type: "string", + Description: "Optional comma-separated list of namespaces to include in the graph", + }, + }, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Graph: Mesh status", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: graphHandler, + }) + return ret +} + +func graphHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + + // Parse arguments: allow either `namespace` or `namespaces` (comma-separated string) + namespaces := make([]string, 0) + if v, ok := params.GetArguments()["namespace"].(string); ok { + v = strings.TrimSpace(v) + if v != "" { + namespaces = append(namespaces, v) + } + } + if v, ok := params.GetArguments()["namespaces"].(string); ok { + for _, ns := range strings.Split(v, ",") { + ns = strings.TrimSpace(ns) + if ns != "" { + namespaces = append(namespaces, ns) + } + } + } + // Deduplicate namespaces if both provided + if len(namespaces) > 1 { + seen := map[string]struct{}{} + unique := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + key := strings.TrimSpace(ns) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, key) + } + namespaces = unique + } + k := params.NewKiali() + content, err := k.Graph(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve mesh graph: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/health.go b/pkg/toolsets/kiali/health.go new file mode 100644 index 00000000..01b86e1e --- /dev/null +++ b/pkg/toolsets/kiali/health.go @@ -0,0 +1,80 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initHealth() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Cluster health tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "health", + Description: "Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces", + }, + "type": { + Type: "string", + Description: "Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app'", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m'", + }, + "queryTime": { + Type: "string", + Description: "Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Health", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: clusterHealthHandler, + }) + + return ret +} + +func clusterHealthHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + // Extract optional query parameters + queryParams := make(map[string]string) + if healthType, ok := params.GetArguments()["type"].(string); ok && healthType != "" { + // Validate type parameter + if healthType != "app" && healthType != "service" && healthType != "workload" { + return api.NewToolCallResult("", fmt.Errorf("invalid type parameter: must be one of 'app', 'service', or 'workload'")), nil + } + queryParams["type"] = healthType + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if queryTime, ok := params.GetArguments()["queryTime"].(string); ok && queryTime != "" { + queryParams["queryTime"] = queryTime + } + + k := params.NewKiali() + content, err := k.Health(params.Context, namespaces, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get health: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/istio_config.go b/pkg/toolsets/kiali/istio_config.go new file mode 100644 index 00000000..df8a97c6 --- /dev/null +++ b/pkg/toolsets/kiali/istio_config.go @@ -0,0 +1,288 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initIstioConfig() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_config", + Description: "Get all Istio configuration objects in the mesh including their full YAML resources and details", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Config: List All", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: istioConfigHandler, + }) + return ret +} + +func istioConfigHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + k := params.NewKiali() + content, err := k.IstioConfig(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve Istio configuration: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectDetails() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_details", + Description: "Get detailed information about a specific Istio object including validation and help information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: istioObjectDetailsHandler, + }) + return ret +} + +func istioObjectDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectDetails(params.Context, namespace, group, version, kind, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to retrieve Istio object details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectPatch() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_patch", + Description: "Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + "json_patch": { + Type: "string", + Description: "JSON patch data to apply to the object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name", "json_patch"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Patch", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectPatchHandler, + }) + return ret +} + +func istioObjectPatchHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + jsonPatch, _ := params.GetArguments()["json_patch"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectPatch(params.Context, namespace, group, version, kind, name, jsonPatch) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to patch Istio object: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectCreate() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_create", + Description: "Create a new Istio object using POST method. The JSON data will be used to create the new object.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace where the Istio object will be created", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "json_data": { + Type: "string", + Description: "JSON data for the new object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "json_data"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Create", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectCreateHandler, + }) + return ret +} + +func istioObjectCreateHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + jsonData, _ := params.GetArguments()["json_data"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectCreate(params.Context, namespace, group, version, kind, jsonData) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create Istio object: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func initIstioObjectDelete() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "istio_object_delete", + Description: "Delete an existing Istio object using DELETE method.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the Istio object", + }, + "group": { + Type: "string", + Description: "API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')", + }, + "version": { + Type: "string", + Description: "API version of the Istio object (e.g., 'v1', 'v1beta1')", + }, + "kind": { + Type: "string", + Description: "Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')", + }, + "name": { + Type: "string", + Description: "Name of the Istio object", + }, + }, + Required: []string{"namespace", "group", "version", "kind", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Istio Object: Delete", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, Handler: istioObjectDeleteHandler, + }) + return ret +} + +func istioObjectDeleteHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + group, _ := params.GetArguments()["group"].(string) + version, _ := params.GetArguments()["version"].(string) + kind, _ := params.GetArguments()["kind"].(string) + name, _ := params.GetArguments()["name"].(string) + + k := params.NewKiali() + content, err := k.IstioObjectDelete(params.Context, namespace, group, version, kind, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to delete Istio object: %v", err)), nil + } + + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/logs.go b/pkg/toolsets/kiali/logs.go new file mode 100644 index 00000000..5c74ce68 --- /dev/null +++ b/pkg/toolsets/kiali/logs.go @@ -0,0 +1,154 @@ +package kiali + +import ( + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initLogs() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Workload logs tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_logs", + Description: "Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get logs for", + }, + "container": { + Type: "string", + Description: "Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init)", + }, + "since": { + Type: "string", + Description: "Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs", + }, + "tail": { + Type: "integer", + Description: "Number of lines to retrieve from the end of logs (default: 100)", + Minimum: ptr.To(float64(1)), + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Logs", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadLogsHandler, + }) + + return ret +} + +func workloadLogsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + k := params.NewKiali() + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + // Extract optional parameters + container, _ := params.GetArguments()["container"].(string) + since, _ := params.GetArguments()["since"].(string) + tail := params.GetArguments()["tail"] + + // Convert parameters to Kiali API format + var duration, logType, sinceTime, maxLines string + var service string // We don't have service parameter in our schema, but Kiali API supports it + + // Convert since to duration (Kiali expects duration format like "5m", "1h") + if since != "" { + duration = since + } + + // Convert tail to maxLines + if tail != nil { + switch v := tail.(type) { + case float64: + maxLines = fmt.Sprintf("%.0f", v) + case int: + maxLines = fmt.Sprintf("%d", v) + case int64: + maxLines = fmt.Sprintf("%d", v) + } + } + + // If no container specified, we need to get workload details first to find the main app container + if container == "" { + workloadDetails, err := k.WorkloadDetails(params.Context, namespace, workload) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload details: %v", err)), nil + } + + // Parse the workload details JSON to extract container names + var workloadData struct { + Pods []struct { + Name string `json:"name"` + Containers []struct { + Name string `json:"name"` + } `json:"containers"` + } `json:"pods"` + } + + if err := json.Unmarshal([]byte(workloadDetails), &workloadData); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to parse workload details: %v", err)), nil + } + + if len(workloadData.Pods) == 0 { + return api.NewToolCallResult("", fmt.Errorf("no pods found for workload %s in namespace %s", workload, namespace)), nil + } + + // Find the main application container (not istio-proxy or istio-init) + for _, pod := range workloadData.Pods { + for _, c := range pod.Containers { + if c.Name != "istio-proxy" && c.Name != "istio-init" { + container = c.Name + break + } + } + if container != "" { + break + } + } + + // If no app container found, use the first container + if container == "" && len(workloadData.Pods) > 0 && len(workloadData.Pods[0].Containers) > 0 { + container = workloadData.Pods[0].Containers[0].Name + } + } + + if container == "" { + return api.NewToolCallResult("", fmt.Errorf("no container found for workload %s in namespace %s", workload, namespace)), nil + } + + // Use the WorkloadLogs method with the correct parameters + logs, err := k.WorkloadLogs(params.Context, namespace, workload, container, service, duration, logType, sinceTime, maxLines) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload logs: %v", err)), nil + } + + return api.NewToolCallResult(logs, nil), nil +} 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/namespaces.go b/pkg/toolsets/kiali/namespaces.go new file mode 100644 index 00000000..a006f2b1 --- /dev/null +++ b/pkg/toolsets/kiali/namespaces.go @@ -0,0 +1,40 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initNamespaces() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "namespaces", + Description: "Get all namespaces in the mesh that the user has access to", + InputSchema: &jsonschema.Schema{ + Type: "object", + }, + Annotations: api.ToolAnnotations{ + Title: "Namespaces: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: namespacesHandler, + }) + return ret +} + +func namespacesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + k := params.NewKiali() + content, err := k.ListNamespaces(params.Context) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/services.go b/pkg/toolsets/kiali/services.go new file mode 100644 index 00000000..1fd2018c --- /dev/null +++ b/pkg/toolsets/kiali/services.go @@ -0,0 +1,209 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initServices() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Services list tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "services_list", + Description: "Get all services in the mesh across specified namespaces with health and Istio resource information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Services: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: servicesListHandler, + }) + + // Service details tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_details", + Description: "Get detailed information for a specific service in a namespace, including validation, health status, and configuration", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get details for", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: serviceDetailsHandler, + }) + + // Service metrics tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_metrics", + Description: "Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get metrics for", + }, + "duration": { + Type: "string", + Description: "Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds", + }, + "step": { + Type: "string", + Description: "Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m'", + }, + "direction": { + Type: "string", + Description: "Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound'", + }, + "reporter": { + Type: "string", + Description: "Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'", + }, + "requestProtocol": { + Type: "string", + Description: "Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional", + }, + "quantiles": { + Type: "string", + Description: "Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional", + }, + "byLabels": { + Type: "string", + Description: "Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Metrics", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: serviceMetricsHandler, + }) + + return ret +} + +func servicesListHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + k := params.NewKiali() + content, err := k.ServicesList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list services: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace, _ := params.GetArguments()["namespace"].(string) + service, _ := params.GetArguments()["service"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if service == "" { + return api.NewToolCallResult("", fmt.Errorf("service parameter is required")), nil + } + + k := params.NewKiali() + content, err := k.ServiceDetails(params.Context, namespace, service) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + service, _ := params.GetArguments()["service"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if service == "" { + return api.NewToolCallResult("", fmt.Errorf("service parameter is required")), nil + } + + // Extract optional query parameters + queryParams := make(map[string]string) + if duration, ok := params.GetArguments()["duration"].(string); ok && duration != "" { + queryParams["duration"] = duration + } + if step, ok := params.GetArguments()["step"].(string); ok && step != "" { + queryParams["step"] = step + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if direction, ok := params.GetArguments()["direction"].(string); ok && direction != "" { + queryParams["direction"] = direction + } + if reporter, ok := params.GetArguments()["reporter"].(string); ok && reporter != "" { + queryParams["reporter"] = reporter + } + if requestProtocol, ok := params.GetArguments()["requestProtocol"].(string); ok && requestProtocol != "" { + queryParams["requestProtocol"] = requestProtocol + } + if quantiles, ok := params.GetArguments()["quantiles"].(string); ok && quantiles != "" { + queryParams["quantiles"] = quantiles + } + if byLabels, ok := params.GetArguments()["byLabels"].(string); ok && byLabels != "" { + queryParams["byLabels"] = byLabels + } + + k := params.NewKiali() + content, err := k.ServiceMetrics(params.Context, namespace, service, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service metrics: %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..0cd5508b --- /dev/null +++ b/pkg/toolsets/kiali/toolset.go @@ -0,0 +1,44 @@ +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( + initGraph(), + initMeshStatus(), + initIstioConfig(), + initIstioObjectDetails(), + initIstioObjectPatch(), + initIstioObjectCreate(), + initIstioObjectDelete(), + initValidations(), + initNamespaces(), + initServices(), + initWorkloads(), + initHealth(), + initLogs(), + initTraces(), + ) +} + +func init() { + toolsets.Register(&Toolset{}) +} diff --git a/pkg/toolsets/kiali/traces.go b/pkg/toolsets/kiali/traces.go new file mode 100644 index 00000000..fd5aacc9 --- /dev/null +++ b/pkg/toolsets/kiali/traces.go @@ -0,0 +1,285 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initTraces() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // App traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "app_traces", + Description: "Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the app", + }, + "app": { + Type: "string", + Description: "Name of the app to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "app"}, + }, + Annotations: api.ToolAnnotations{ + Title: "App: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: appTracesHandler, + }) + + // Service traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "service_traces", + Description: "Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the service", + }, + "service": { + Type: "string", + Description: "Name of the service to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "service"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Service: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: serviceTracesHandler, + }) + + // Workload traces tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_traces", + Description: "Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get traces for", + }, + "startMicros": { + Type: "string", + Description: "Start time for traces in microseconds since epoch (optional)", + }, + "endMicros": { + Type: "string", + Description: "End time for traces in microseconds since epoch (optional)", + }, + "limit": { + Type: "integer", + Description: "Maximum number of traces to return (default: 100)", + Minimum: ptr.To(float64(1)), + }, + "minDuration": { + Type: "integer", + Description: "Minimum trace duration in microseconds (optional)", + Minimum: ptr.To(float64(0)), + }, + "tags": { + Type: "string", + Description: "JSON string of tags to filter traces (optional)", + }, + "clusterName": { + Type: "string", + Description: "Cluster name for multi-cluster environments (optional)", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Traces", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: workloadTracesHandler, + }) + + return ret +} + +func appTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + app := params.GetArguments()["app"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + k := params.NewKiali() + content, err := k.AppTraces(params.Context, namespace, app, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get app traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func serviceTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + service := params.GetArguments()["service"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + + k := params.NewKiali() + content, err := k.ServiceTraces(params.Context, namespace, service, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get service traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadTracesHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace := params.GetArguments()["namespace"].(string) + workload := params.GetArguments()["workload"].(string) + + // Build query parameters from optional arguments + queryParams := make(map[string]string) + if startMicros, ok := params.GetArguments()["startMicros"].(string); ok && startMicros != "" { + queryParams["startMicros"] = startMicros + } + if endMicros, ok := params.GetArguments()["endMicros"].(string); ok && endMicros != "" { + queryParams["endMicros"] = endMicros + } + if limit, ok := params.GetArguments()["limit"].(string); ok && limit != "" { + queryParams["limit"] = limit + } + if minDuration, ok := params.GetArguments()["minDuration"].(string); ok && minDuration != "" { + queryParams["minDuration"] = minDuration + } + if tags, ok := params.GetArguments()["tags"].(string); ok && tags != "" { + queryParams["tags"] = tags + } + if clusterName, ok := params.GetArguments()["clusterName"].(string); ok && clusterName != "" { + queryParams["clusterName"] = clusterName + } + + k := params.NewKiali() + content, err := k.WorkloadTraces(params.Context, namespace, workload, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload traces: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/validations.go b/pkg/toolsets/kiali/validations.go new file mode 100644 index 00000000..898f7d03 --- /dev/null +++ b/pkg/toolsets/kiali/validations.go @@ -0,0 +1,86 @@ +package kiali + +import ( + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initValidations() []api.ServerTool { + ret := make([]api.ServerTool, 0) + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "validations_list", + Description: "List all the validations in the current cluster from all namespaces", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Optional single namespace to retrieve validations from (alternative to namespaces)", + }, + "namespaces": { + Type: "string", + Description: "Optional comma-separated list of namespaces to retrieve validations from", + }, + }, + Required: []string{}, + }, + Annotations: api.ToolAnnotations{ + Title: "Validations: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, Handler: validationsList, + }) + return ret +} + +func validationsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse arguments: allow either `namespace` or `namespaces` (comma-separated string) + namespaces := make([]string, 0) + if v, ok := params.GetArguments()["namespace"].(string); ok { + v = strings.TrimSpace(v) + if v != "" { + namespaces = append(namespaces, v) + } + } + if v, ok := params.GetArguments()["namespaces"].(string); ok { + for _, ns := range strings.Split(v, ",") { + ns = strings.TrimSpace(ns) + if ns != "" { + namespaces = append(namespaces, ns) + } + } + } + // Deduplicate namespaces if both provided + if len(namespaces) > 1 { + seen := map[string]struct{}{} + unique := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + key := strings.TrimSpace(ns) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, key) + } + namespaces = unique + } + + k := params.NewKiali() + content, err := k.ValidationsList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list validations: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} diff --git a/pkg/toolsets/kiali/workloads.go b/pkg/toolsets/kiali/workloads.go new file mode 100644 index 00000000..f8d03a28 --- /dev/null +++ b/pkg/toolsets/kiali/workloads.go @@ -0,0 +1,209 @@ +package kiali + +import ( + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initWorkloads() []api.ServerTool { + ret := make([]api.ServerTool, 0) + + // Workloads list tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workloads_list", + Description: "Get all workloads in the mesh across specified namespaces with health and Istio resource information", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespaces": { + Type: "string", + Description: "Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "Workloads: List", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadsListHandler, + }) + + // Workload details tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_details", + Description: "Get detailed information for a specific workload in a namespace, including validation, health status, and configuration", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get details for", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Details", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadDetailsHandler, + }) + + // Workload metrics tool + ret = append(ret, api.ServerTool{ + Tool: api.Tool{ + Name: "workload_metrics", + Description: "Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace containing the workload", + }, + "workload": { + Type: "string", + Description: "Name of the workload to get metrics for", + }, + "duration": { + Type: "string", + Description: "Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds", + }, + "step": { + Type: "string", + Description: "Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds", + }, + "rateInterval": { + Type: "string", + Description: "Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m'", + }, + "direction": { + Type: "string", + Description: "Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound'", + }, + "reporter": { + Type: "string", + Description: "Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'", + }, + "requestProtocol": { + Type: "string", + Description: "Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional", + }, + "quantiles": { + Type: "string", + Description: "Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional", + }, + "byLabels": { + Type: "string", + Description: "Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional", + }, + }, + Required: []string{"namespace", "workload"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Workload: Metrics", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: workloadMetricsHandler, + }) + + return ret +} + +func workloadsListHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespaces, _ := params.GetArguments()["namespaces"].(string) + + k := params.NewKiali() + content, err := k.WorkloadsList(params.Context, namespaces) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list workloads: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadDetailsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + k := params.NewKiali() + content, err := k.WorkloadDetails(params.Context, namespace, workload) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload details: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +} + +func workloadMetricsHandler(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required parameters + namespace, _ := params.GetArguments()["namespace"].(string) + workload, _ := params.GetArguments()["workload"].(string) + + if namespace == "" { + return api.NewToolCallResult("", fmt.Errorf("namespace parameter is required")), nil + } + if workload == "" { + return api.NewToolCallResult("", fmt.Errorf("workload parameter is required")), nil + } + + // Extract optional query parameters + queryParams := make(map[string]string) + if duration, ok := params.GetArguments()["duration"].(string); ok && duration != "" { + queryParams["duration"] = duration + } + if step, ok := params.GetArguments()["step"].(string); ok && step != "" { + queryParams["step"] = step + } + if rateInterval, ok := params.GetArguments()["rateInterval"].(string); ok && rateInterval != "" { + queryParams["rateInterval"] = rateInterval + } + if direction, ok := params.GetArguments()["direction"].(string); ok && direction != "" { + queryParams["direction"] = direction + } + if reporter, ok := params.GetArguments()["reporter"].(string); ok && reporter != "" { + queryParams["reporter"] = reporter + } + if requestProtocol, ok := params.GetArguments()["requestProtocol"].(string); ok && requestProtocol != "" { + queryParams["requestProtocol"] = requestProtocol + } + if quantiles, ok := params.GetArguments()["quantiles"].(string); ok && quantiles != "" { + queryParams["quantiles"] = quantiles + } + if byLabels, ok := params.GetArguments()["byLabels"].(string); ok && byLabels != "" { + queryParams["byLabels"] = byLabels + } + + k := params.NewKiali() + content, err := k.WorkloadMetrics(params.Context, namespace, workload, queryParams) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get workload metrics: %v", err)), nil + } + return api.NewToolCallResult(content, nil), nil +}