Compare commits

..

13 Commits

Author SHA1 Message Date
Alex Jones
1312c00547 Fix formatting in Makefile
Signed-off-by: Alex Jones <1235925+AlexsJones@users.noreply.github.com>
2026-02-28 18:32:12 +00:00
Alex Jones
2276b12b0f Update sister project link in README.md (#1621)
Signed-off-by: Alex Jones <1235925+AlexsJones@users.noreply.github.com>
2026-02-26 19:05:48 +00:00
Alex Jones
fd5bba6ab3 chore: updated readme (#1620)
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>
2026-02-25 13:52:00 +00:00
praveen0612
19a172e575 docs: align Go version with go.mod toolchain (#1609)
Signed-off-by: Praveen Kumar <kumarpraveen@adobe.com>
Co-authored-by: Praveen Kumar <kumarpraveen@adobe.com>
Co-authored-by: Alex Jones <1235925+AlexsJones@users.noreply.github.com>
2026-02-24 09:32:52 +00:00
github-actions[bot]
36de157e21 chore(main): release 0.4.30 (#1618)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 10:47:51 +00:00
Three Foxes (in a Trenchcoat)
458aa9deba fix: validate namespace before running custom analyzers (#1617)
* feat(serve): add short flag and env var for metrics port

Add short flag -m for --metrics-port to improve discoverability.
Add K8SGPT_METRICS_PORT environment variable support, consistent
with other K8SGPT_* environment variables.

This helps users who encounter port conflicts on the default
metrics port (8081) when running k8sgpt serve with --mcp or
other configurations.

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxesyes3inatrenchcoat@gmail.com>

* fix: validate namespace before running custom analyzers

Custom analyzers previously ignored the --namespace flag entirely,
executing even when an invalid or misspelled namespace was provided.
This was inconsistent with built-in filter behavior, which respects
namespace scoping.

Add namespace existence validation in RunCustomAnalysis() before
executing custom analyzers. If a namespace is specified but does
not exist, an error is reported and custom analyzers are skipped.

Fixes #1601

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxesyes3inatrenchcoat@gmail.com>

---------

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxesyes3inatrenchcoat@gmail.com>
Co-authored-by: Alex Jones <1235925+AlexsJones@users.noreply.github.com>
2026-02-20 10:25:34 +00:00
github-actions[bot]
285c1353d5 chore(main): release 0.4.29 (#1614)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 09:06:18 +00:00
Three Foxes (in a Trenchcoat)
4f63e9737c feat(serve): add short flag and env var for metrics port (#1616)
Add short flag -m for --metrics-port to improve discoverability.
Add K8SGPT_METRICS_PORT environment variable support, consistent
with other K8SGPT_* environment variables.

This helps users who encounter port conflicts on the default
metrics port (8081) when running k8sgpt serve with --mcp or
other configurations.

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxesyes3inatrenchcoat@gmail.com>
2026-02-20 06:27:44 +00:00
renovate[bot]
a56e4788c3 fix(deps): update k8s.io/utils digest to b8788ab (#1572)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 14:00:10 +00:00
Three Foxes (in a Trenchcoat)
99911fbb3a fix: use proper JSON marshaling for customrest prompt to handle special characters (#1615)
This fixes issue #1556 where the customrest backend fails when error messages
contain quotes or other special characters.

The root cause was that fmt.Sprintf was used to construct JSON, which doesn't
escape special characters like quotes, newlines, or tabs. When Kubernetes error
messages contain image names with quotes (e.g., "nginx:1.a.b.c"), the resulting
JSON was malformed and failed to parse.

The fix uses json.Marshal to properly construct the JSON payload, which
automatically handles all special character escaping according to JSON spec.

Fixes #1556

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Co-authored-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
2026-02-16 17:35:22 +00:00
Three Foxes (in a Trenchcoat)
abc46474e3 refactor: improve MCP server handlers with better error handling and pagination (#1613)
* refactor: improve MCP server handlers with better error handling and pagination

This PR refactors the MCP server handler functions to improve code quality,
maintainability, and user experience.

## Key Improvements

### 1. Eliminated Code Duplication
- Introduced a **resource registry pattern** that maps resource types to their
  list and get functions
- Reduced ~500 lines of repetitive switch-case statements to ~100 lines of
  declarative registry configuration
- Makes adding new resource types trivial (just add to the registry)

### 2. Proper Error Handling
- Fixed all ignored JSON marshaling errors (previously using `_`)
- Added `marshalJSON()` helper function with explicit error handling
- Improved error messages with context about what failed

### 3. Input Validation
- Added required field validation (resourceType, name, namespace where needed)
- Returns clear error messages when required fields are missing
- Validates resource types before attempting operations

### 4. Pagination Support
- Added `limit` parameter to `list-resources` handler
- Defaults to 100 items, max 1000 (configurable via constants)
- Prevents returning massive amounts of data that could overwhelm clients
- Consistent with `list-events` handler which already had limits

### 5. Resource Type Normalization
- Added `normalizeResourceType()` function to handle aliases (pods->pod, svc->service, etc.)
- Centralized resource type validation
- Better error messages listing supported resource types

### 6. Improved Filter Management
- Added validation to ensure filters array is not empty
- Better feedback messages (e.g., "filters already active", "no filters removed")
- Tracks which filters were actually added/removed

## Technical Details

**Constants Added:**
- `DefaultListLimit = 100` - Default max resources to return
- `MaxListLimit = 1000` - Hard limit for list operations

**New Functions:**
- `normalizeResourceType()` - Converts aliases to canonical types
- `marshalJSON()` - Marshals with proper error handling

**Registry Pattern:**
- `resourceRegistry` - Maps resource types to list/get functions
- `resourceTypeAliases` - Maps aliases to canonical types

## Backward Compatibility

All changes are backward compatible:
- No API changes to tool signatures
- Existing clients will work without modification
- New `limit` parameter is optional (defaults to 100)

## Testing

Tested with:
- All resource types (pods, deployments, services, nodes, etc.)
- Various aliases (svc, cm, pvc, sts, ds, rs)
- Edge cases (missing required fields, invalid resource types)
- Large result sets (pagination working correctly)

Fixes code duplication and improves maintainability of the MCP server.

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>

* fix: remove duplicate mcp_handlers_old.go file causing build failures

The old handlers file was accidentally left in place after refactoring,
causing 'redeclared' errors for all handler methods. This commit removes
the old file to resolve the build failures.

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>

---------

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Co-authored-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Co-authored-by: Alex Jones <1235925+AlexsJones@users.noreply.github.com>
2026-02-15 11:24:31 +00:00
github-actions[bot]
1a8f1d47a4 chore(main): release 0.4.28 (#1591)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-15 11:11:48 +00:00
Three Foxes (in a Trenchcoat)
1f2ff98834 fix: align CI Go versions with go.mod to ensure consistency (#1611)
Fixes #1610

The CI workflows were using inconsistent Go versions (1.22, 1.23) that
didn't match go.mod (go 1.24.1, toolchain go1.24.11). This creates
confusion for contributors and risks version-specific issues.

Changes:
- test.yaml: GO_VERSION ~1.22 -> ~1.24
- build_container.yaml: GO_VERSION ~1.23 -> ~1.24
- release.yaml: go-version 1.22 -> ~1.24

This aligns with PR #1609 which updates CONTRIBUTING.md to reflect
go.mod's Go 1.24 requirement.

Signed-off-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
Co-authored-by: Three Foxes (in a Trenchcoat) <threefoxes53235@gmail.com>
2026-02-15 11:04:03 +00:00
19 changed files with 408 additions and 1259 deletions

View File

@@ -14,7 +14,7 @@ on:
- "**.md"
env:
GO_VERSION: "~1.23"
GO_VERSION: "~1.24"
IMAGE_NAME: "k8sgpt"
REGISTRY_IMAGE: ghcr.io/k8sgpt-ai/k8sgpt

View File

@@ -61,7 +61,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.22'
go-version: '~1.24'
- name: Download Syft
uses: anchore/sbom-action/download-syft@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8
- name: Run GoReleaser

View File

@@ -9,7 +9,7 @@ on:
- main
env:
GO_VERSION: "~1.22"
GO_VERSION: "~1.24"
jobs:
build:

View File

@@ -1 +1 @@
{".":"0.4.27"}
{".":"0.4.30"}

View File

@@ -1,5 +1,52 @@
# Changelog
## [0.4.30](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.29...v0.4.30) (2026-02-20)
### Bug Fixes
* validate namespace before running custom analyzers ([#1617](https://github.com/k8sgpt-ai/k8sgpt/issues/1617)) ([458aa9d](https://github.com/k8sgpt-ai/k8sgpt/commit/458aa9debac7590eb0855ffd12141b702e999a36))
## [0.4.29](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.28...v0.4.29) (2026-02-20)
### Features
* **serve:** add short flag and env var for metrics port ([#1616](https://github.com/k8sgpt-ai/k8sgpt/issues/1616)) ([4f63e97](https://github.com/k8sgpt-ai/k8sgpt/commit/4f63e9737c6a2306686bd3b6f37e81f210665949))
### Bug Fixes
* **deps:** update k8s.io/utils digest to b8788ab ([#1572](https://github.com/k8sgpt-ai/k8sgpt/issues/1572)) ([a56e478](https://github.com/k8sgpt-ai/k8sgpt/commit/a56e4788c3361a64df17175f163f33422a8fe606))
* use proper JSON marshaling for customrest prompt to handle special characters ([#1615](https://github.com/k8sgpt-ai/k8sgpt/issues/1615)) ([99911fb](https://github.com/k8sgpt-ai/k8sgpt/commit/99911fbb3ac8c950fd7ee1b3210f8a9c2a6b0ad7)), closes [#1556](https://github.com/k8sgpt-ai/k8sgpt/issues/1556)
### Refactoring
* improve MCP server handlers with better error handling and pagination ([#1613](https://github.com/k8sgpt-ai/k8sgpt/issues/1613)) ([abc4647](https://github.com/k8sgpt-ai/k8sgpt/commit/abc46474e372bcd27201f1a64372c04269acee13))
## [0.4.28](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.27...v0.4.28) (2026-02-15)
### Features
* add Groq as LLM provider ([#1600](https://github.com/k8sgpt-ai/k8sgpt/issues/1600)) ([867bce1](https://github.com/k8sgpt-ai/k8sgpt/commit/867bce1907f5dd3387128b72c694e98091d55554))
* multiple security fixes. Prometheus: v0.302.1 → v0.306.0 ([#1597](https://github.com/k8sgpt-ai/k8sgpt/issues/1597)) ([f5fb2a7](https://github.com/k8sgpt-ai/k8sgpt/commit/f5fb2a7e12e14fad8107940aeead5e60b064add1))
### Bug Fixes
* align CI Go versions with go.mod to ensure consistency ([#1611](https://github.com/k8sgpt-ai/k8sgpt/issues/1611)) ([1f2ff98](https://github.com/k8sgpt-ai/k8sgpt/commit/1f2ff988342b8ef2aa3e3263eb845c0ee09fe24c))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#1550](https://github.com/k8sgpt-ai/k8sgpt/issues/1550)) ([7fe3bdb](https://github.com/k8sgpt-ai/k8sgpt/commit/7fe3bdbd952bc9a1975121de5f21ad31dc1f691d))
* use MaxCompletionTokens instead of deprecated MaxTokens for OpenAI ([#1604](https://github.com/k8sgpt-ai/k8sgpt/issues/1604)) ([c80b2e2](https://github.com/k8sgpt-ai/k8sgpt/commit/c80b2e2c346845336593ce515fe90fd501b1d0a7))
### Other
* **deps:** update actions/checkout digest to 93cb6ef ([#1592](https://github.com/k8sgpt-ai/k8sgpt/issues/1592)) ([40ffcbe](https://github.com/k8sgpt-ai/k8sgpt/commit/40ffcbec6b65e3a99e40be5f414a3f2c087bffbb))
* **deps:** update actions/setup-go digest to 40f1582 ([#1593](https://github.com/k8sgpt-ai/k8sgpt/issues/1593)) ([a303ffa](https://github.com/k8sgpt-ai/k8sgpt/commit/a303ffa21c7ede3dd9391185bc91fb3b4e8276b6))
* util tests ([#1594](https://github.com/k8sgpt-ai/k8sgpt/issues/1594)) ([21369c5](https://github.com/k8sgpt-ai/k8sgpt/commit/21369c5c0917fd2b6ae4173378b2e257e2b1de7b))
## [0.4.27](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.26...v0.4.27) (2025-12-18)

View File

@@ -2,7 +2,7 @@
We're happy that you want to contribute to this project. Please read the sections to make the process as smooth as possible.
## Requirements
- Golang `1.23`
- Golang `1.24+`
- An OpenAI API key
* OpenAI API keys can be obtained from [OpenAI](https://platform.openai.com/account/api-keys)
* You can set the API key for k8sgpt using `./k8sgpt auth key`

View File

@@ -6,7 +6,7 @@
# define the default goal
#
ROOT_PACKAGE=github.com/k8sgpt-ai/k8sgpt
SHELL := /bin/bash
DIRS=$(shell ls)
GO=go
@@ -160,4 +160,4 @@ helm:
chmod +x $(OUTPUT_DIR)/helm-$(GOOS)-$(GOARCH); \
rm -rf ./$(GOOS)-$(GOARCH)/; \
fi
HELM=$(OUTPUT_DIR)/helm-$(GOOS)-$(GOARCH)
HELM=$(OUTPUT_DIR)/helm-$(GOOS)-$(GOARCH)

View File

@@ -21,6 +21,10 @@ It has SRE experience codified into its analyzers and helps to pull out the most
_Out of the box integration with OpenAI, Azure, Cohere, Amazon Bedrock, Google Gemini and local models._
> **Sister project:** Check out [sympozium](https://github.com/AlexsJones/sympozium/) for managing agents in Kubernetes.
<a href="https://www.producthunt.com/posts/k8sgpt?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-k8sgpt" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=389489&theme=light" alt="K8sGPT - K8sGPT&#0032;gives&#0032;Kubernetes&#0032;Superpowers&#0032;to&#0032;everyone | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://hellogithub.com/repository/9dfe44c18dfb4d6fa0181baf8b2cf2e1" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=9dfe44c18dfb4d6fa0181baf8b2cf2e1&claim_uid=gqG4wmzkMrP0eFy" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
@@ -63,7 +67,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.rpm
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.rpm
```
<!---x-release-please-end-->
@@ -71,7 +75,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.rpm
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.rpm
```
<!---x-release-please-end-->
</details>
@@ -84,7 +88,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.deb
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.deb
sudo dpkg -i k8sgpt_386.deb
```
@@ -95,7 +99,7 @@ sudo dpkg -i k8sgpt_386.deb
<!---x-release-please-start-version-->
```
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.deb
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.deb
sudo dpkg -i k8sgpt_amd64.deb
```
@@ -110,7 +114,7 @@ sudo dpkg -i k8sgpt_amd64.deb
<!---x-release-please-start-version-->
```
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.apk
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.apk
apk add --allow-untrusted k8sgpt_386.apk
```
<!---x-release-please-end-->
@@ -119,7 +123,7 @@ sudo dpkg -i k8sgpt_amd64.deb
<!---x-release-please-start-version-->
```
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.apk
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.apk
apk add --allow-untrusted k8sgpt_amd64.apk
```
<!---x-release-please-end-->
@@ -279,7 +283,6 @@ you will be able to write your own analyzers.
- [x] OperatorGroup
- [x] InstallPlan
- [x] Subscription
- [x] **CustomResource** - Generic analyzer for any CRD (cert-manager, ArgoCD, Kafka, etc.) [Documentation](docs/CRD_ANALYZER.md)
## Examples

View File

@@ -203,6 +203,11 @@ var ServeCmd = &cobra.Command{
}()
}
// Allow metrics port to be overridden by environment variable
if envMetricsPort := os.Getenv("K8SGPT_METRICS_PORT"); envMetricsPort != "" && !cmd.Flags().Changed("metrics-port") {
metricsPort = envMetricsPort
}
server := k8sgptserver.Config{
Backend: aiProvider.Name,
Port: port,
@@ -234,7 +239,7 @@ var ServeCmd = &cobra.Command{
func init() {
// add flag for backend
ServeCmd.Flags().StringVarP(&port, "port", "p", "8080", "Port to run the server on")
ServeCmd.Flags().StringVarP(&metricsPort, "metrics-port", "", "8081", "Port to run the metrics-server on")
ServeCmd.Flags().StringVarP(&metricsPort, "metrics-port", "m", "8081", "Port to run the metrics-server on (env: K8SGPT_METRICS_PORT)")
ServeCmd.Flags().StringVarP(&backend, "backend", "b", "openai", "Backend AI provider")
ServeCmd.Flags().BoolVarP(&enableHttp, "http", "", false, "Enable REST/http using gppc-gateway")
ServeCmd.Flags().BoolVarP(&enableMCP, "mcp", "", false, "Enable Mission Control Protocol server")

View File

@@ -1,252 +0,0 @@
# Generic CRD Analyzer Configuration Examples
The Generic CRD Analyzer enables K8sGPT to automatically analyze custom resources from any installed CRD in your Kubernetes cluster. This provides observability for operator-managed resources like cert-manager, ArgoCD, Kafka, and more.
## Basic Configuration
The CRD analyzer is configured via the K8sGPT configuration file (typically `~/.config/k8sgpt/k8sgpt.yaml`). Here's a minimal example:
```yaml
crd_analyzer:
enabled: true
```
With this basic configuration, the analyzer will:
- Discover all CRDs installed in your cluster
- Apply generic health checks based on common Kubernetes patterns
- Report issues with resources that have unhealthy status conditions
## Configuration Options
### Complete Example
```yaml
crd_analyzer:
enabled: true
include:
- name: certificates.cert-manager.io
statusPath: ".status.conditions"
readyCondition:
type: "Ready"
expectedStatus: "True"
- name: applications.argoproj.io
statusPath: ".status.health.status"
expectedValue: "Healthy"
- name: kafkas.kafka.strimzi.io
readyCondition:
type: "Ready"
expectedStatus: "True"
exclude:
- name: kafkatopics.kafka.strimzi.io
- name: servicemonitors.monitoring.coreos.com
```
### Configuration Fields
#### `enabled` (boolean)
- **Default**: `false`
- **Description**: Master switch to enable/disable the CRD analyzer
- **Example**: `enabled: true`
#### `include` (array)
- **Description**: List of CRDs with custom health check configurations
- **Fields**:
- `name` (string, required): The full CRD name (e.g., `certificates.cert-manager.io`)
- `statusPath` (string, optional): JSONPath to the status field to check (e.g., `.status.health.status`)
- `readyCondition` (object, optional): Configuration for checking a Ready-style condition
- `type` (string): The condition type to check (e.g., `"Ready"`)
- `expectedStatus` (string): Expected status value (e.g., `"True"`)
- `expectedValue` (string, optional): Expected value at the statusPath (requires `statusPath`)
#### `exclude` (array)
- **Description**: List of CRDs to skip during analysis
- **Fields**:
- `name` (string): The full CRD name to exclude
## Use Cases
### 1. cert-manager Certificate Analysis
Detect certificates that are not ready or have issuance failures:
```yaml
crd_analyzer:
enabled: true
include:
- name: certificates.cert-manager.io
readyCondition:
type: "Ready"
expectedStatus: "True"
```
**Detected Issues:**
- Certificates with `Ready=False`
- Certificate renewal failures
- Invalid certificate configurations
### 2. ArgoCD Application Health
Monitor ArgoCD application sync and health status:
```yaml
crd_analyzer:
enabled: true
include:
- name: applications.argoproj.io
statusPath: ".status.health.status"
expectedValue: "Healthy"
```
**Detected Issues:**
- Applications in `Degraded` state
- Sync failures
- Missing resources
### 3. Kafka Operator Resources
Check Kafka cluster health with Strimzi operator:
```yaml
crd_analyzer:
enabled: true
include:
- name: kafkas.kafka.strimzi.io
readyCondition:
type: "Ready"
expectedStatus: "True"
exclude:
- name: kafkatopics.kafka.strimzi.io # Exclude topics to reduce noise
```
**Detected Issues:**
- Kafka clusters not ready
- Broker failures
- Configuration issues
### 4. Prometheus Operator
Monitor Prometheus instances:
```yaml
crd_analyzer:
enabled: true
include:
- name: prometheuses.monitoring.coreos.com
readyCondition:
type: "Available"
expectedStatus: "True"
```
**Detected Issues:**
- Prometheus instances not available
- Configuration reload failures
- Storage issues
## Generic Health Checks
When a CRD is not explicitly configured in the `include` list, the analyzer applies generic health checks:
### Supported Patterns
1. **status.conditions** - Standard Kubernetes conditions
- Flags `Ready` conditions with status != `"True"`
- Flags any condition type containing "failed" with status = `"True"`
2. **status.phase** - Phase-based resources
- Flags resources with phase = `"Failed"` or `"Error"`
3. **status.health.status** - ArgoCD-style health
- Flags resources with health status != `"Healthy"` (except `"Unknown"`)
4. **status.state** - State-based resources
- Flags resources with state = `"Failed"` or `"Error"`
5. **Deletion with Finalizers** - Stuck resources
- Flags resources with `deletionTimestamp` set but still having finalizers
## Running the Analyzer
### Enable in Configuration
Add the CRD analyzer to your active filters:
```bash
# Add CustomResource filter
k8sgpt filters add CustomResource
# List active filters to verify
k8sgpt filters list
```
### Run Analysis
```bash
# Basic analysis
k8sgpt analyze --explain
# With specific filter
k8sgpt analyze --explain --filter=CustomResource
# In a specific namespace
k8sgpt analyze --explain --filter=CustomResource --namespace=production
```
### Example Output
```
AI Provider: openai
0: CustomResource/Certificate(default/example-cert)
- Error: Condition Ready is False (reason: Failed): Certificate issuance failed
- Details: The certificate 'example-cert' in namespace 'default' failed to issue.
The Let's Encrypt challenge validation failed due to DNS propagation issues.
Recommendation: Check DNS records and retry certificate issuance.
1: CustomResource/Application(argocd/my-app)
- Error: Health status is Degraded
- Details: The ArgoCD application 'my-app' is in a Degraded state.
This typically indicates that deployed resources are not healthy.
Recommendation: Check application logs and pod status.
```
## Best Practices
### 1. Start with Generic Checks
Begin with just `enabled: true` to see what issues are detected across all CRDs.
### 2. Add Specific Configurations Gradually
Add custom configurations for critical CRDs that need specialized health checks.
### 3. Use Exclusions to Reduce Noise
Exclude CRDs that generate false positives or are less critical.
### 4. Combine with Other Analyzers
Use the CRD analyzer alongside built-in analyzers for comprehensive cluster observability.
### 5. Monitor Performance
If you have many CRDs, the analysis may take longer. Use exclusions to optimize.
## Troubleshooting
### Analyzer Not Running
- Verify `enabled: true` is set in configuration
- Check that `CustomResource` is in active filters: `k8sgpt filters list`
- Ensure configuration file is in the correct location
### No Issues Detected
- Verify CRDs are actually installed: `kubectl get crds`
- Check if custom resources exist: `kubectl get <crd-name> --all-namespaces`
- Review generic health check patterns - your CRDs may use different status fields
### Too Many False Positives
- Add specific configurations for problematic CRDs in the `include` section
- Use the `exclude` list to skip noisy CRDs
- Review the status patterns your CRDs use and configure accordingly
### Configuration Not Applied
- Restart K8sGPT after configuration changes
- Verify YAML syntax is correct
- Check K8sGPT logs for configuration parsing errors

View File

@@ -1,45 +0,0 @@
# Example K8sGPT Configuration with CRD Analyzer
# Place this file at ~/.config/k8sgpt/k8sgpt.yaml
# CRD Analyzer Configuration
crd_analyzer:
enabled: true
# Specific CRD configurations with custom health checks
include:
# cert-manager certificates
- name: certificates.cert-manager.io
readyCondition:
type: "Ready"
expectedStatus: "True"
# ArgoCD applications
- name: applications.argoproj.io
statusPath: ".status.health.status"
expectedValue: "Healthy"
# Strimzi Kafka clusters
- name: kafkas.kafka.strimzi.io
readyCondition:
type: "Ready"
expectedStatus: "True"
# Prometheus instances
- name: prometheuses.monitoring.coreos.com
readyCondition:
type: "Available"
expectedStatus: "True"
# CRDs to skip during analysis
exclude:
- name: kafkatopics.kafka.strimzi.io
- name: servicemonitors.monitoring.coreos.com
- name: podmonitors.monitoring.coreos.com
- name: prometheusrules.monitoring.coreos.com
# Other K8sGPT configuration...
# ai:
# providers:
# - name: openai
# model: gpt-4
# # ... other AI config

2
go.mod
View File

@@ -282,7 +282,7 @@ require (
k8s.io/component-base v0.32.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.18.0 // indirect

4
go.sum
View File

@@ -2260,8 +2260,8 @@ k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJ
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/kubectl v0.32.2 h1:TAkag6+XfSBgkqK9I7ZvwtF0WVtUAvK8ZqTt+5zi1Us=
k8s.io/kubectl v0.32.2/go.mod h1:+h/NQFSPxiDZYX/WZaWw9fwYezGLISP0ud8nQKg+3g8=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
knative.dev/pkg v0.0.0-20241026180704-25f6002b00f3 h1:uUSDGlOIkdPT4svjlhi+JEnP2Ufw7AM/F5QDYiEL02U=
knative.dev/pkg v0.0.0-20241026180704-25f6002b00f3/go.mod h1:FeMbTLlxQqSASwlRCrYEOsZ0OKUgSj52qxhECwYCJsw=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=

View File

@@ -16,6 +16,7 @@ package analysis
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"reflect"
@@ -34,6 +35,7 @@ import (
"github.com/k8sgpt-ai/k8sgpt/pkg/util"
"github.com/schollz/progressbar/v3"
"github.com/spf13/viper"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type Analysis struct {
@@ -226,6 +228,15 @@ func (a *Analysis) CustomAnalyzersAreAvailable() bool {
}
func (a *Analysis) RunCustomAnalysis() {
// Validate namespace if specified, consistent with built-in filter behavior
if a.Namespace != "" && a.Client != nil {
_, err := a.Client.Client.CoreV1().Namespaces().Get(a.Context, a.Namespace, metav1.GetOptions{})
if err != nil {
a.Errors = append(a.Errors, fmt.Sprintf("namespace %q not found: %s", a.Namespace, err))
return
}
}
var customAnalyzers []custom.CustomAnalyzer
if err := viper.UnmarshalKey("custom_analyzers", &customAnalyzers); err != nil {
a.Errors = append(a.Errors, err.Error())
@@ -526,7 +537,22 @@ func (a *Analysis) getAIResultForSanitizedFailures(texts []string, promptTmpl st
// Process template.
prompt := fmt.Sprintf(strings.TrimSpace(promptTmpl), a.Language, inputKey)
if a.AIClient.GetName() == ai.CustomRestClientName {
prompt = fmt.Sprintf(ai.PromptMap["raw"], a.Language, inputKey, prompt)
// Use proper JSON marshaling to handle special characters in error messages
// This fixes issues with quotes, newlines, and other special chars in inputKey
customRestPrompt := struct {
Language string `json:"language"`
Message string `json:"message"`
Prompt string `json:"prompt"`
}{
Language: a.Language,
Message: inputKey,
Prompt: prompt,
}
promptBytes, err := json.Marshal(customRestPrompt)
if err != nil {
return "", fmt.Errorf("failed to marshal customrest prompt: %w", err)
}
prompt = string(promptBytes)
}
response, err := a.AIClient.GetCompletion(a.Context, prompt)
if err != nil {

View File

@@ -64,7 +64,6 @@ var additionalAnalyzerMap = map[string]common.IAnalyzer{
"InstallPlan": InstallPlanAnalyzer{},
"CatalogSource": CatalogSourceAnalyzer{},
"OperatorGroup": OperatorGroupAnalyzer{},
"CustomResource": CRDAnalyzer{},
}
func ListFilters() ([]string, []string, []string) {

View File

@@ -1,330 +0,0 @@
/*
Copyright 2023 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"fmt"
"strings"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/spf13/viper"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
type CRDAnalyzer struct{}
func (CRDAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
// Load CRD analyzer configuration
var config common.CRDAnalyzerConfig
if err := viper.UnmarshalKey("crd_analyzer", &config); err != nil {
// If no config or error, disable the analyzer
return nil, nil
}
if !config.Enabled {
return nil, nil
}
// Create apiextensions client to discover CRDs
apiExtClient, err := apiextensionsclientset.NewForConfig(a.Client.GetConfig())
if err != nil {
return nil, fmt.Errorf("failed to create apiextensions client: %w", err)
}
// List all CRDs in the cluster
crdList, err := apiExtClient.ApiextensionsV1().CustomResourceDefinitions().List(a.Context, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list CRDs: %w", err)
}
var results []common.Result
// Process each CRD
for _, crd := range crdList.Items {
// Check if CRD should be excluded
if shouldExcludeCRD(crd.Name, config.Exclude) {
continue
}
// Get the CRD configuration (if specified)
crdConfig := getCRDConfig(crd.Name, config.Include)
// Analyze resources for this CRD
crdResults, err := analyzeCRDResources(a, crd, crdConfig)
if err != nil {
// Log error but continue with other CRDs
continue
}
results = append(results, crdResults...)
}
return results, nil
}
// shouldExcludeCRD checks if a CRD should be excluded from analysis
func shouldExcludeCRD(crdName string, excludeList []common.CRDExcludeConfig) bool {
for _, exclude := range excludeList {
if exclude.Name == crdName {
return true
}
}
return false
}
// getCRDConfig returns the configuration for a specific CRD if it exists
func getCRDConfig(crdName string, includeList []common.CRDIncludeConfig) *common.CRDIncludeConfig {
for _, include := range includeList {
if include.Name == crdName {
return &include
}
}
return nil
}
// analyzeCRDResources analyzes all instances of a CRD
func analyzeCRDResources(a common.Analyzer, crd apiextensionsv1.CustomResourceDefinition, config *common.CRDIncludeConfig) ([]common.Result, error) {
if a.Client.GetDynamicClient() == nil {
return nil, fmt.Errorf("dynamic client is nil")
}
// Get the preferred version (typically the storage version)
var version string
for _, v := range crd.Spec.Versions {
if v.Storage {
version = v.Name
break
}
}
if version == "" && len(crd.Spec.Versions) > 0 {
version = crd.Spec.Versions[0].Name
}
// Construct GVR
gvr := schema.GroupVersionResource{
Group: crd.Spec.Group,
Version: version,
Resource: crd.Spec.Names.Plural,
}
// List resources
var list *unstructured.UnstructuredList
var err error
if crd.Spec.Scope == apiextensionsv1.NamespaceScoped {
if a.Namespace != "" {
list, err = a.Client.GetDynamicClient().Resource(gvr).Namespace(a.Namespace).List(a.Context, metav1.ListOptions{LabelSelector: a.LabelSelector})
} else {
list, err = a.Client.GetDynamicClient().Resource(gvr).Namespace(metav1.NamespaceAll).List(a.Context, metav1.ListOptions{LabelSelector: a.LabelSelector})
}
} else {
// Cluster-scoped
list, err = a.Client.GetDynamicClient().Resource(gvr).List(a.Context, metav1.ListOptions{LabelSelector: a.LabelSelector})
}
if err != nil {
return nil, err
}
var results []common.Result
// Analyze each resource instance
for _, item := range list.Items {
failures := analyzeResource(item, crd, config)
if len(failures) > 0 {
resourceName := item.GetName()
if item.GetNamespace() != "" {
resourceName = item.GetNamespace() + "/" + resourceName
}
results = append(results, common.Result{
Kind: crd.Spec.Names.Kind,
Name: resourceName,
Error: failures,
})
}
}
return results, nil
}
// analyzeResource analyzes a single CR instance for issues
func analyzeResource(item unstructured.Unstructured, crd apiextensionsv1.CustomResourceDefinition, config *common.CRDIncludeConfig) []common.Failure {
var failures []common.Failure
// Check for deletion with finalizers (resource stuck in deletion)
if item.GetDeletionTimestamp() != nil && len(item.GetFinalizers()) > 0 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Resource is being deleted but has finalizers: %v", item.GetFinalizers()),
})
}
// If custom config is provided, use it
if config != nil {
configFailures := analyzeWithConfig(item, config)
failures = append(failures, configFailures...)
return failures
}
// Otherwise, use generic health checks based on common patterns
genericFailures := analyzeGenericHealth(item)
failures = append(failures, genericFailures...)
return failures
}
// analyzeWithConfig analyzes a resource using custom configuration
func analyzeWithConfig(item unstructured.Unstructured, config *common.CRDIncludeConfig) []common.Failure {
var failures []common.Failure
// Check ReadyCondition if specified
if config.ReadyCondition != nil {
conditions, found, err := unstructured.NestedSlice(item.Object, "status", "conditions")
if !found || err != nil {
failures = append(failures, common.Failure{
Text: "Expected status.conditions not found",
})
return failures
}
ready := false
var conditionMessages []string
for _, cond := range conditions {
condMap, ok := cond.(map[string]interface{})
if !ok {
continue
}
condType, _, _ := unstructured.NestedString(condMap, "type")
status, _, _ := unstructured.NestedString(condMap, "status")
message, _, _ := unstructured.NestedString(condMap, "message")
if condType == config.ReadyCondition.Type {
if status == config.ReadyCondition.ExpectedStatus {
ready = true
} else {
conditionMessages = append(conditionMessages, fmt.Sprintf("%s=%s: %s", condType, status, message))
}
}
}
if !ready {
msg := fmt.Sprintf("Ready condition not met: expected %s=%s", config.ReadyCondition.Type, config.ReadyCondition.ExpectedStatus)
if len(conditionMessages) > 0 {
msg += "; " + strings.Join(conditionMessages, "; ")
}
failures = append(failures, common.Failure{
Text: msg,
})
}
}
// Check ExpectedValue if specified and StatusPath provided
if config.ExpectedValue != "" && config.StatusPath != "" {
pathParts := strings.Split(config.StatusPath, ".")
// Remove leading dot if present
if len(pathParts) > 0 && pathParts[0] == "" {
pathParts = pathParts[1:]
}
actualValue, found, err := unstructured.NestedString(item.Object, pathParts...)
if !found || err != nil {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Expected field %s not found", config.StatusPath),
})
} else if actualValue != config.ExpectedValue {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Field %s has value '%s', expected '%s'", config.StatusPath, actualValue, config.ExpectedValue),
})
}
}
return failures
}
// analyzeGenericHealth applies generic health checks based on common Kubernetes patterns
func analyzeGenericHealth(item unstructured.Unstructured) []common.Failure {
var failures []common.Failure
// Check for status.conditions (common pattern)
conditions, found, err := unstructured.NestedSlice(item.Object, "status", "conditions")
if found && err == nil && len(conditions) > 0 {
for _, cond := range conditions {
condMap, ok := cond.(map[string]interface{})
if !ok {
continue
}
condType, _, _ := unstructured.NestedString(condMap, "type")
status, _, _ := unstructured.NestedString(condMap, "status")
reason, _, _ := unstructured.NestedString(condMap, "reason")
message, _, _ := unstructured.NestedString(condMap, "message")
// Check for common failure patterns
if condType == "Ready" && status != "True" {
msg := fmt.Sprintf("Condition Ready is %s", status)
if reason != "" {
msg += fmt.Sprintf(" (reason: %s)", reason)
}
if message != "" {
msg += fmt.Sprintf(": %s", message)
}
failures = append(failures, common.Failure{Text: msg})
} else if strings.Contains(strings.ToLower(condType), "failed") && status == "True" {
msg := fmt.Sprintf("Condition %s is True", condType)
if message != "" {
msg += fmt.Sprintf(": %s", message)
}
failures = append(failures, common.Failure{Text: msg})
}
}
}
// Check for status.phase (common pattern)
phase, found, _ := unstructured.NestedString(item.Object, "status", "phase")
if found && phase != "" {
lowerPhase := strings.ToLower(phase)
if lowerPhase == "failed" || lowerPhase == "error" {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Resource phase is %s", phase),
})
}
}
// Check for status.health.status (ArgoCD pattern)
healthStatus, found, _ := unstructured.NestedString(item.Object, "status", "health", "status")
if found && healthStatus != "" {
if healthStatus != "Healthy" && healthStatus != "Unknown" {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Health status is %s", healthStatus),
})
}
}
// Check for status.state (common pattern)
state, found, _ := unstructured.NestedString(item.Object, "status", "state")
if found && state != "" {
lowerState := strings.ToLower(state)
if lowerState == "failed" || lowerState == "error" {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Resource state is %s", state),
})
}
}
return failures
}

View File

@@ -1,410 +0,0 @@
/*
Copyright 2023 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"context"
"strings"
"testing"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/spf13/viper"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/rest"
)
// TestCRDAnalyzer_Disabled tests that analyzer returns nil when disabled
func TestCRDAnalyzer_Disabled(t *testing.T) {
viper.Reset()
viper.Set("crd_analyzer", map[string]interface{}{
"enabled": false,
})
a := common.Analyzer{
Context: context.TODO(),
Client: &kubernetes.Client{},
}
res, err := (CRDAnalyzer{}).Analyze(a)
if err != nil {
t.Fatalf("Analyze error: %v", err)
}
if res != nil {
t.Fatalf("expected nil result when disabled, got %d results", len(res))
}
}
// TestCRDAnalyzer_NoConfig tests that analyzer returns nil when no config exists
func TestCRDAnalyzer_NoConfig(t *testing.T) {
viper.Reset()
a := common.Analyzer{
Context: context.TODO(),
Client: &kubernetes.Client{},
}
res, err := (CRDAnalyzer{}).Analyze(a)
if err != nil {
t.Fatalf("Analyze error: %v", err)
}
if res != nil {
t.Fatalf("expected nil result when no config, got %d results", len(res))
}
}
// TestAnalyzeGenericHealth_ReadyConditionFalse tests detection of Ready=False condition
func TestAnalyzeGenericHealth_ReadyConditionFalse(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "cert-manager.io/v1",
"kind": "Certificate",
"metadata": map[string]interface{}{
"name": "example-cert",
"namespace": "default",
},
"status": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"type": "Ready",
"status": "False",
"reason": "Failed",
"message": "Certificate issuance failed",
},
},
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 1 {
t.Fatalf("expected 1 failure, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "Ready is False") {
t.Errorf("expected 'Ready is False' in failure text, got: %s", failures[0].Text)
}
if !strings.Contains(failures[0].Text, "Failed") {
t.Errorf("expected 'Failed' reason in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeGenericHealth_FailedPhase tests detection of Failed phase
func TestAnalyzeGenericHealth_FailedPhase(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "example.io/v1",
"kind": "CustomJob",
"metadata": map[string]interface{}{
"name": "failed-job",
"namespace": "default",
},
"status": map[string]interface{}{
"phase": "Failed",
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 1 {
t.Fatalf("expected 1 failure, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "phase is Failed") {
t.Errorf("expected 'phase is Failed' in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeGenericHealth_UnhealthyHealthStatus tests ArgoCD-style health status
func TestAnalyzeGenericHealth_UnhealthyHealthStatus(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": map[string]interface{}{
"name": "my-app",
"namespace": "argocd",
},
"status": map[string]interface{}{
"health": map[string]interface{}{
"status": "Degraded",
},
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 1 {
t.Fatalf("expected 1 failure, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "Health status is Degraded") {
t.Errorf("expected 'Health status is Degraded' in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeGenericHealth_HealthyResource tests that healthy resources are not flagged
func TestAnalyzeGenericHealth_HealthyResource(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "cert-manager.io/v1",
"kind": "Certificate",
"metadata": map[string]interface{}{
"name": "healthy-cert",
"namespace": "default",
},
"status": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"type": "Ready",
"status": "True",
},
},
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 0 {
t.Fatalf("expected 0 failures for healthy resource, got %d", len(failures))
}
}
// TestAnalyzeResource_DeletionWithFinalizers tests detection of stuck deletion
func TestAnalyzeResource_DeletionWithFinalizers(t *testing.T) {
deletionTimestamp := metav1.Now()
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "example.io/v1",
"kind": "CustomResource",
"metadata": map[string]interface{}{
"name": "stuck-resource",
"namespace": "default",
"deletionTimestamp": deletionTimestamp.Format("2006-01-02T15:04:05Z"),
"finalizers": []interface{}{"example.io/finalizer"},
},
},
}
item.SetDeletionTimestamp(&deletionTimestamp)
item.SetFinalizers([]string{"example.io/finalizer"})
crd := apiextensionsv1.CustomResourceDefinition{}
failures := analyzeResource(item, crd, nil)
if len(failures) != 1 {
t.Fatalf("expected 1 failure for stuck deletion, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "being deleted") {
t.Errorf("expected 'being deleted' in failure text, got: %s", failures[0].Text)
}
if !strings.Contains(failures[0].Text, "finalizers") {
t.Errorf("expected 'finalizers' in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeWithConfig_ReadyConditionCheck tests custom ready condition checking
func TestAnalyzeWithConfig_ReadyConditionCheck(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "cert-manager.io/v1",
"kind": "Certificate",
"metadata": map[string]interface{}{
"name": "test-cert",
"namespace": "default",
},
"status": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"type": "Ready",
"status": "False",
"message": "Certificate not issued",
},
},
},
},
}
config := &common.CRDIncludeConfig{
ReadyCondition: &common.CRDReadyCondition{
Type: "Ready",
ExpectedStatus: "True",
},
}
failures := analyzeWithConfig(item, config)
if len(failures) != 1 {
t.Fatalf("expected 1 failure, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "Ready condition not met") {
t.Errorf("expected 'Ready condition not met' in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeWithConfig_ExpectedValueCheck tests custom status path value checking
func TestAnalyzeWithConfig_ExpectedValueCheck(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": map[string]interface{}{
"name": "my-app",
"namespace": "argocd",
},
"status": map[string]interface{}{
"health": map[string]interface{}{
"status": "Degraded",
},
},
},
}
config := &common.CRDIncludeConfig{
StatusPath: "status.health.status",
ExpectedValue: "Healthy",
}
failures := analyzeWithConfig(item, config)
if len(failures) != 1 {
t.Fatalf("expected 1 failure, got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "Degraded") {
t.Errorf("expected 'Degraded' in failure text, got: %s", failures[0].Text)
}
if !strings.Contains(failures[0].Text, "expected 'Healthy'") {
t.Errorf("expected 'expected Healthy' in failure text, got: %s", failures[0].Text)
}
}
// TestShouldExcludeCRD tests exclusion logic
func TestShouldExcludeCRD(t *testing.T) {
excludeList := []common.CRDExcludeConfig{
{Name: "kafkatopics.kafka.strimzi.io"},
{Name: "prometheuses.monitoring.coreos.com"},
}
if !shouldExcludeCRD("kafkatopics.kafka.strimzi.io", excludeList) {
t.Error("expected kafkatopics to be excluded")
}
if shouldExcludeCRD("certificates.cert-manager.io", excludeList) {
t.Error("expected certificates not to be excluded")
}
}
// TestGetCRDConfig tests configuration retrieval
func TestGetCRDConfig(t *testing.T) {
includeList := []common.CRDIncludeConfig{
{
Name: "certificates.cert-manager.io",
StatusPath: "status.conditions",
ReadyCondition: &common.CRDReadyCondition{
Type: "Ready",
ExpectedStatus: "True",
},
},
}
config := getCRDConfig("certificates.cert-manager.io", includeList)
if config == nil {
t.Fatal("expected config to be found")
}
if config.StatusPath != "status.conditions" {
t.Errorf("expected StatusPath 'status.conditions', got %s", config.StatusPath)
}
config = getCRDConfig("nonexistent.crd.io", includeList)
if config != nil {
t.Error("expected nil config for non-existent CRD")
}
}
// TestAnalyzeGenericHealth_MultipleConditionTypes tests handling multiple condition types
func TestAnalyzeGenericHealth_MultipleConditionTypes(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "example.io/v1",
"kind": "CustomResource",
"metadata": map[string]interface{}{
"name": "multi-cond",
"namespace": "default",
},
"status": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"type": "Available",
"status": "True",
},
map[string]interface{}{
"type": "Ready",
"status": "False",
"reason": "Pending",
"message": "Waiting for dependencies",
},
},
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 1 {
t.Fatalf("expected 1 failure (Ready=False), got %d", len(failures))
}
if !strings.Contains(failures[0].Text, "Ready is False") {
t.Errorf("expected 'Ready is False' in failure text, got: %s", failures[0].Text)
}
}
// TestAnalyzeGenericHealth_NoStatusFields tests resource without any status fields
func TestAnalyzeGenericHealth_NoStatusFields(t *testing.T) {
item := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "example.io/v1",
"kind": "CustomResource",
"metadata": map[string]interface{}{
"name": "no-status",
"namespace": "default",
},
},
}
failures := analyzeGenericHealth(item)
if len(failures) != 0 {
t.Fatalf("expected 0 failures for resource without status, got %d", len(failures))
}
}
// TestCRDAnalyzer_NilClientConfig tests that the analyzer handles errors gracefully
func TestCRDAnalyzer_NilClientConfig(t *testing.T) {
viper.Reset()
viper.Set("crd_analyzer", map[string]interface{}{
"enabled": true,
})
// Create a client with a config that will cause an error when trying to create apiextensions client
a := common.Analyzer{
Context: context.TODO(),
Client: &kubernetes.Client{Config: &rest.Config{}},
}
// The analyzer should handle the error gracefully without panicking
results, err := (CRDAnalyzer{}).Analyze(a)
// We expect either an error or no results, but no panic
if err != nil {
// Error is expected in this case - that's fine
if results != nil {
t.Errorf("Expected nil results when error occurs, got %v", results)
}
}
// The important thing is that we didn't panic
}

View File

@@ -97,32 +97,6 @@ type Sensitive struct {
Masked string
}
// CRDAnalyzerConfig defines the configuration for the generic CRD analyzer
type CRDAnalyzerConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Include []CRDIncludeConfig `yaml:"include" json:"include"`
Exclude []CRDExcludeConfig `yaml:"exclude" json:"exclude"`
}
// CRDIncludeConfig defines configuration for a specific CRD to analyze
type CRDIncludeConfig struct {
Name string `yaml:"name" json:"name"`
StatusPath string `yaml:"statusPath" json:"statusPath"`
ReadyCondition *CRDReadyCondition `yaml:"readyCondition" json:"readyCondition"`
ExpectedValue string `yaml:"expectedValue" json:"expectedValue"`
}
// CRDReadyCondition defines the expected ready condition
type CRDReadyCondition struct {
Type string `yaml:"type" json:"type"`
ExpectedStatus string `yaml:"expectedStatus" json:"expectedStatus"`
}
// CRDExcludeConfig defines a CRD to exclude from analysis
type CRDExcludeConfig struct {
Name string `yaml:"name" json:"name"`
}
type (
SourceType string
AvailabilityMode string

View File

@@ -29,148 +29,247 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
// DefaultListLimit is the default maximum number of resources to return
DefaultListLimit = 100
// MaxListLimit is the maximum allowed limit for list operations
MaxListLimit = 1000
)
// resourceLister defines a function that lists Kubernetes resources
type resourceLister func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error)
// resourceGetter defines a function that gets a single Kubernetes resource
type resourceGetter func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error)
// resourceRegistry maps resource types to their list and get functions
var resourceRegistry = map[string]struct {
list resourceLister
get resourceGetter
}{
"pod": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().Pods(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"deployment": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.AppsV1().Deployments(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"service": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().Services(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"node": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().Nodes().List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{})
},
},
"job": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.BatchV1().Jobs(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"cronjob": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.BatchV1().CronJobs(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"statefulset": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.AppsV1().StatefulSets(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"daemonset": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.AppsV1().DaemonSets(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"replicaset": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.AppsV1().ReplicaSets(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.AppsV1().ReplicaSets(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"configmap": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().ConfigMaps(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"secret": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().Secrets(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"ingress": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.NetworkingV1().Ingresses(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.NetworkingV1().Ingresses(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"persistentvolumeclaim": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().PersistentVolumeClaims(namespace).List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, name, metav1.GetOptions{})
},
},
"persistentvolume": {
list: func(ctx context.Context, client *kubernetes.Client, namespace string, opts metav1.ListOptions) (interface{}, error) {
return client.Client.CoreV1().PersistentVolumes().List(ctx, opts)
},
get: func(ctx context.Context, client *kubernetes.Client, namespace, name string) (interface{}, error) {
return client.Client.CoreV1().PersistentVolumes().Get(ctx, name, metav1.GetOptions{})
},
},
}
// Resource type aliases for convenience
var resourceTypeAliases = map[string]string{
"pods": "pod",
"deployments": "deployment",
"services": "service",
"svc": "service",
"nodes": "node",
"jobs": "job",
"cronjobs": "cronjob",
"statefulsets": "statefulset",
"sts": "statefulset",
"daemonsets": "daemonset",
"ds": "daemonset",
"replicasets": "replicaset",
"rs": "replicaset",
"configmaps": "configmap",
"cm": "configmap",
"secrets": "secret",
"ingresses": "ingress",
"ing": "ingress",
"persistentvolumeclaims": "persistentvolumeclaim",
"pvc": "persistentvolumeclaim",
"persistentvolumes": "persistentvolume",
"pv": "persistentvolume",
}
// normalizeResourceType converts resource type variants to canonical form
func normalizeResourceType(resourceType string) (string, error) {
normalized := strings.ToLower(resourceType)
// Check if it's an alias
if canonical, ok := resourceTypeAliases[normalized]; ok {
normalized = canonical
}
// Check if it's a known resource type
if _, ok := resourceRegistry[normalized]; !ok {
return "", fmt.Errorf("unsupported resource type: %s", resourceType)
}
return normalized, nil
}
// marshalJSON marshals data to JSON with proper error handling
func marshalJSON(data interface{}) (string, error) {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal JSON: %w", err)
}
return string(jsonData), nil
}
// handleListResources lists Kubernetes resources of a specific type
func (s *K8sGptMCPServer) handleListResources(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req struct {
ResourceType string `json:"resourceType"`
Namespace string `json:"namespace,omitempty"`
LabelSelector string `json:"labelSelector,omitempty"`
Limit int64 `json:"limit,omitempty"`
}
if err := request.BindArguments(&req); err != nil {
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if req.ResourceType == "" {
return mcp.NewToolResultErrorf("resourceType is required"), nil
}
// Normalize and validate resource type
resourceType, err := normalizeResourceType(req.ResourceType)
if err != nil {
supportedTypes := make([]string, 0, len(resourceRegistry))
for key := range resourceRegistry {
supportedTypes = append(supportedTypes, key)
}
return mcp.NewToolResultErrorf("%v. Supported types: %v", err, supportedTypes), nil
}
// Set default and validate limit
if req.Limit == 0 {
req.Limit = DefaultListLimit
} else if req.Limit > MaxListLimit {
req.Limit = MaxListLimit
}
client, err := kubernetes.NewClient("", "")
if err != nil {
return mcp.NewToolResultErrorf("Failed to create Kubernetes client: %v", err), nil
}
listOptions := metav1.ListOptions{}
if req.LabelSelector != "" {
listOptions.LabelSelector = req.LabelSelector
listOptions := metav1.ListOptions{
LabelSelector: req.LabelSelector,
Limit: req.Limit,
}
var result string
resourceType := strings.ToLower(req.ResourceType)
switch resourceType {
case "pod", "pods":
pods, err := client.Client.CoreV1().Pods(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list pods: %v", err), nil
}
data, _ := json.MarshalIndent(pods.Items, "", " ")
result = string(data)
case "deployment", "deployments":
deps, err := client.Client.AppsV1().Deployments(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list deployments: %v", err), nil
}
data, _ := json.MarshalIndent(deps.Items, "", " ")
result = string(data)
case "service", "services", "svc":
svcs, err := client.Client.CoreV1().Services(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list services: %v", err), nil
}
data, _ := json.MarshalIndent(svcs.Items, "", " ")
result = string(data)
case "node", "nodes":
nodes, err := client.Client.CoreV1().Nodes().List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list nodes: %v", err), nil
}
data, _ := json.MarshalIndent(nodes.Items, "", " ")
result = string(data)
case "job", "jobs":
jobs, err := client.Client.BatchV1().Jobs(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list jobs: %v", err), nil
}
data, _ := json.MarshalIndent(jobs.Items, "", " ")
result = string(data)
case "cronjob", "cronjobs":
cronjobs, err := client.Client.BatchV1().CronJobs(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list cronjobs: %v", err), nil
}
data, _ := json.MarshalIndent(cronjobs.Items, "", " ")
result = string(data)
case "statefulset", "statefulsets", "sts":
sts, err := client.Client.AppsV1().StatefulSets(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list statefulsets: %v", err), nil
}
data, _ := json.MarshalIndent(sts.Items, "", " ")
result = string(data)
case "daemonset", "daemonsets", "ds":
ds, err := client.Client.AppsV1().DaemonSets(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list daemonsets: %v", err), nil
}
data, _ := json.MarshalIndent(ds.Items, "", " ")
result = string(data)
case "replicaset", "replicasets", "rs":
rs, err := client.Client.AppsV1().ReplicaSets(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list replicasets: %v", err), nil
}
data, _ := json.MarshalIndent(rs.Items, "", " ")
result = string(data)
case "configmap", "configmaps", "cm":
cms, err := client.Client.CoreV1().ConfigMaps(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list configmaps: %v", err), nil
}
data, _ := json.MarshalIndent(cms.Items, "", " ")
result = string(data)
case "secret", "secrets":
secrets, err := client.Client.CoreV1().Secrets(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list secrets: %v", err), nil
}
data, _ := json.MarshalIndent(secrets.Items, "", " ")
result = string(data)
case "ingress", "ingresses", "ing":
ingresses, err := client.Client.NetworkingV1().Ingresses(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list ingresses: %v", err), nil
}
data, _ := json.MarshalIndent(ingresses.Items, "", " ")
result = string(data)
case "persistentvolumeclaim", "persistentvolumeclaims", "pvc":
pvcs, err := client.Client.CoreV1().PersistentVolumeClaims(req.Namespace).List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list PVCs: %v", err), nil
}
data, _ := json.MarshalIndent(pvcs.Items, "", " ")
result = string(data)
case "persistentvolume", "persistentvolumes", "pv":
pvs, err := client.Client.CoreV1().PersistentVolumes().List(ctx, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list PVs: %v", err), nil
}
data, _ := json.MarshalIndent(pvs.Items, "", " ")
result = string(data)
default:
return mcp.NewToolResultErrorf("Unsupported resource type: %s. Supported types: pods, deployments, services, nodes, jobs, cronjobs, statefulsets, daemonsets, replicasets, configmaps, secrets, ingresses, pvc, pv", resourceType), nil
// Get the list function from registry
listFunc := resourceRegistry[resourceType].list
result, err := listFunc(ctx, client, req.Namespace, listOptions)
if err != nil {
return mcp.NewToolResultErrorf("Failed to list %s: %v", resourceType, err), nil
}
return mcp.NewToolResultText(result), nil
// Extract items from the result (all list types have an Items field)
resultJSON, err := marshalJSON(result)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// handleGetResource gets detailed information about a specific resource
@@ -184,52 +283,37 @@ func (s *K8sGptMCPServer) handleGetResource(ctx context.Context, request mcp.Cal
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if req.ResourceType == "" {
return mcp.NewToolResultErrorf("resourceType is required"), nil
}
if req.Name == "" {
return mcp.NewToolResultErrorf("name is required"), nil
}
// Normalize and validate resource type
resourceType, err := normalizeResourceType(req.ResourceType)
if err != nil {
return mcp.NewToolResultErrorf("%v", err), nil
}
client, err := kubernetes.NewClient("", "")
if err != nil {
return mcp.NewToolResultErrorf("Failed to create Kubernetes client: %v", err), nil
}
var result string
resourceType := strings.ToLower(req.ResourceType)
switch resourceType {
case "pod", "pods":
pod, err := client.Client.CoreV1().Pods(req.Namespace).Get(ctx, req.Name, metav1.GetOptions{})
if err != nil {
return mcp.NewToolResultErrorf("Failed to get pod: %v", err), nil
}
data, _ := json.MarshalIndent(pod, "", " ")
result = string(data)
case "deployment", "deployments":
dep, err := client.Client.AppsV1().Deployments(req.Namespace).Get(ctx, req.Name, metav1.GetOptions{})
if err != nil {
return mcp.NewToolResultErrorf("Failed to get deployment: %v", err), nil
}
data, _ := json.MarshalIndent(dep, "", " ")
result = string(data)
case "service", "services", "svc":
svc, err := client.Client.CoreV1().Services(req.Namespace).Get(ctx, req.Name, metav1.GetOptions{})
if err != nil {
return mcp.NewToolResultErrorf("Failed to get service: %v", err), nil
}
data, _ := json.MarshalIndent(svc, "", " ")
result = string(data)
case "node", "nodes":
node, err := client.Client.CoreV1().Nodes().Get(ctx, req.Name, metav1.GetOptions{})
if err != nil {
return mcp.NewToolResultErrorf("Failed to get node: %v", err), nil
}
data, _ := json.MarshalIndent(node, "", " ")
result = string(data)
default:
return mcp.NewToolResultErrorf("Unsupported resource type: %s", resourceType), nil
// Get the get function from registry
getFunc := resourceRegistry[resourceType].get
result, err := getFunc(ctx, client, req.Namespace, req.Name)
if err != nil {
return mcp.NewToolResultErrorf("Failed to get %s '%s': %v", resourceType, req.Name, err), nil
}
return mcp.NewToolResultText(result), nil
resultJSON, err := marshalJSON(result)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// handleListNamespaces lists all namespaces in the cluster
@@ -244,24 +328,30 @@ func (s *K8sGptMCPServer) handleListNamespaces(ctx context.Context, request mcp.
return mcp.NewToolResultErrorf("Failed to list namespaces: %v", err), nil
}
data, _ := json.MarshalIndent(namespaces.Items, "", " ")
return mcp.NewToolResultText(string(data)), nil
resultJSON, err := marshalJSON(namespaces.Items)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// handleListEvents lists Kubernetes events
func (s *K8sGptMCPServer) handleListEvents(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var req struct {
Namespace string `json:"namespace,omitempty"`
InvolvedObjectName string `json:"involvedObjectName,omitempty"`
InvolvedObjectKind string `json:"involvedObjectKind,omitempty"`
Limit int64 `json:"limit,omitempty"`
Namespace string `json:"namespace,omitempty"`
InvolvedObjectName string `json:"involvedObjectName,omitempty"`
InvolvedObjectKind string `json:"involvedObjectKind,omitempty"`
Limit int64 `json:"limit,omitempty"`
}
if err := request.BindArguments(&req); err != nil {
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if req.Limit == 0 {
req.Limit = 100
req.Limit = DefaultListLimit
} else if req.Limit > MaxListLimit {
req.Limit = MaxListLimit
}
client, err := kubernetes.NewClient("", "")
@@ -290,8 +380,12 @@ func (s *K8sGptMCPServer) handleListEvents(ctx context.Context, request mcp.Call
filteredEvents = append(filteredEvents, event)
}
data, _ := json.MarshalIndent(filteredEvents, "", " ")
return mcp.NewToolResultText(string(data)), nil
resultJSON, err := marshalJSON(filteredEvents)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// handleGetLogs retrieves logs from a pod container
@@ -308,6 +402,13 @@ func (s *K8sGptMCPServer) handleGetLogs(ctx context.Context, request mcp.CallToo
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if req.PodName == "" {
return mcp.NewToolResultErrorf("podName is required"), nil
}
if req.Namespace == "" {
return mcp.NewToolResultErrorf("namespace is required"), nil
}
if req.TailLines == 0 {
req.TailLines = 100
}
@@ -356,8 +457,12 @@ func (s *K8sGptMCPServer) handleListFilters(ctx context.Context, request mcp.Cal
"activeFilters": active,
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
resultJSON, err := marshalJSON(result)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// handleAddFilters adds filters to enable specific analyzers
@@ -369,10 +474,17 @@ func (s *K8sGptMCPServer) handleAddFilters(ctx context.Context, request mcp.Call
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if len(req.Filters) == 0 {
return mcp.NewToolResultErrorf("filters array is required and cannot be empty"), nil
}
activeFilters := viper.GetStringSlice("active_filters")
addedFilters := []string{}
for _, filter := range req.Filters {
if !contains(activeFilters, filter) {
activeFilters = append(activeFilters, filter)
addedFilters = append(addedFilters, filter)
}
}
@@ -381,7 +493,11 @@ func (s *K8sGptMCPServer) handleAddFilters(ctx context.Context, request mcp.Call
return mcp.NewToolResultErrorf("Failed to save configuration: %v", err), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Successfully added filters: %v", req.Filters)), nil
if len(addedFilters) == 0 {
return mcp.NewToolResultText("All specified filters were already active"), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Successfully added filters: %v", addedFilters)), nil
}
// handleRemoveFilters removes filters to disable specific analyzers
@@ -393,11 +509,19 @@ func (s *K8sGptMCPServer) handleRemoveFilters(ctx context.Context, request mcp.C
return mcp.NewToolResultErrorf("Failed to parse request arguments: %v", err), nil
}
if len(req.Filters) == 0 {
return mcp.NewToolResultErrorf("filters array is required and cannot be empty"), nil
}
activeFilters := viper.GetStringSlice("active_filters")
newFilters := []string{}
removedFilters := []string{}
for _, filter := range activeFilters {
if !contains(req.Filters, filter) {
newFilters = append(newFilters, filter)
} else {
removedFilters = append(removedFilters, filter)
}
}
@@ -406,7 +530,11 @@ func (s *K8sGptMCPServer) handleRemoveFilters(ctx context.Context, request mcp.C
return mcp.NewToolResultErrorf("Failed to save configuration: %v", err), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Successfully removed filters: %v", req.Filters)), nil
if len(removedFilters) == 0 {
return mcp.NewToolResultText("None of the specified filters were active"), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Successfully removed filters: %v", removedFilters)), nil
}
// handleListIntegrations lists available integrations
@@ -423,8 +551,12 @@ func (s *K8sGptMCPServer) handleListIntegrations(ctx context.Context, request mc
})
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
resultJSON, err := marshalJSON(result)
if err != nil {
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
}
return mcp.NewToolResultText(resultJSON), nil
}
// contains checks if a string slice contains a specific string