mirror of
https://github.com/k8sgpt-ai/k8sgpt.git
synced 2026-03-19 03:23:47 +00:00
Compare commits
4 Commits
AlexsJones
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c7b7c4751 | ||
|
|
03bd9a8387 | ||
|
|
dfdcf9edd2 | ||
|
|
2253625f40 |
2
.github/workflows/build_container.yaml
vendored
2
.github/workflows/build_container.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- "**.md"
|
||||
|
||||
env:
|
||||
GO_VERSION: "~1.24"
|
||||
GO_VERSION: "~1.23"
|
||||
IMAGE_NAME: "k8sgpt"
|
||||
REGISTRY_IMAGE: ghcr.io/k8sgpt-ai/k8sgpt
|
||||
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '~1.24'
|
||||
go-version: '1.22'
|
||||
- name: Download Syft
|
||||
uses: anchore/sbom-action/download-syft@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8
|
||||
- name: Run GoReleaser
|
||||
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
GO_VERSION: "~1.24"
|
||||
GO_VERSION: "~1.22"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.4.30"}
|
||||
{".":"0.4.27"}
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,52 +1,5 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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.24+`
|
||||
- Golang `1.23`
|
||||
- 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`
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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)
|
||||
17
README.md
17
README.md
@@ -21,10 +21,6 @@ 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 gives Kubernetes Superpowers to 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="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
|
||||
@@ -67,7 +63,7 @@ brew install k8sgpt
|
||||
<!---x-release-please-start-version-->
|
||||
|
||||
```
|
||||
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.rpm
|
||||
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.rpm
|
||||
```
|
||||
<!---x-release-please-end-->
|
||||
|
||||
@@ -75,7 +71,7 @@ brew install k8sgpt
|
||||
|
||||
<!---x-release-please-start-version-->
|
||||
```
|
||||
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.rpm
|
||||
```
|
||||
<!---x-release-please-end-->
|
||||
</details>
|
||||
@@ -88,7 +84,7 @@ brew install k8sgpt
|
||||
<!---x-release-please-start-version-->
|
||||
|
||||
```
|
||||
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.deb
|
||||
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.deb
|
||||
sudo dpkg -i k8sgpt_386.deb
|
||||
```
|
||||
|
||||
@@ -99,7 +95,7 @@ sudo dpkg -i k8sgpt_386.deb
|
||||
<!---x-release-please-start-version-->
|
||||
|
||||
```
|
||||
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.deb
|
||||
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.deb
|
||||
sudo dpkg -i k8sgpt_amd64.deb
|
||||
```
|
||||
|
||||
@@ -114,7 +110,7 @@ sudo dpkg -i k8sgpt_amd64.deb
|
||||
|
||||
<!---x-release-please-start-version-->
|
||||
```
|
||||
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_386.apk
|
||||
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_386.apk
|
||||
apk add --allow-untrusted k8sgpt_386.apk
|
||||
```
|
||||
<!---x-release-please-end-->
|
||||
@@ -123,7 +119,7 @@ sudo dpkg -i k8sgpt_amd64.deb
|
||||
|
||||
<!---x-release-please-start-version-->
|
||||
```
|
||||
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.30/k8sgpt_amd64.apk
|
||||
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.27/k8sgpt_amd64.apk
|
||||
apk add --allow-untrusted k8sgpt_amd64.apk
|
||||
```
|
||||
<!---x-release-please-end-->
|
||||
@@ -283,6 +279,7 @@ 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
|
||||
|
||||
|
||||
@@ -203,11 +203,6 @@ 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,
|
||||
@@ -239,7 +234,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", "m", "8081", "Port to run the metrics-server on (env: K8SGPT_METRICS_PORT)")
|
||||
ServeCmd.Flags().StringVarP(&metricsPort, "metrics-port", "", "8081", "Port to run the metrics-server on")
|
||||
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")
|
||||
|
||||
252
docs/CRD_ANALYZER.md
Normal file
252
docs/CRD_ANALYZER.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 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
|
||||
45
examples/crd_analyzer_config.yaml
Normal file
45
examples/crd_analyzer_config.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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
2
go.mod
@@ -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-20260210185600-b8788abfbbc2
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
|
||||
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
4
go.sum
@@ -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-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
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=
|
||||
|
||||
@@ -16,7 +16,6 @@ package analysis
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -35,7 +34,6 @@ 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 {
|
||||
@@ -228,15 +226,6 @@ 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())
|
||||
@@ -537,22 +526,7 @@ 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 {
|
||||
// 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)
|
||||
prompt = fmt.Sprintf(ai.PromptMap["raw"], a.Language, inputKey, prompt)
|
||||
}
|
||||
response, err := a.AIClient.GetCompletion(a.Context, prompt)
|
||||
if err != nil {
|
||||
|
||||
@@ -64,6 +64,7 @@ var additionalAnalyzerMap = map[string]common.IAnalyzer{
|
||||
"InstallPlan": InstallPlanAnalyzer{},
|
||||
"CatalogSource": CatalogSourceAnalyzer{},
|
||||
"OperatorGroup": OperatorGroupAnalyzer{},
|
||||
"CustomResource": CRDAnalyzer{},
|
||||
}
|
||||
|
||||
func ListFilters() ([]string, []string, []string) {
|
||||
|
||||
330
pkg/analyzer/crd.go
Normal file
330
pkg/analyzer/crd.go
Normal file
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
410
pkg/analyzer/crd_test.go
Normal file
410
pkg/analyzer/crd_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
@@ -97,6 +97,32 @@ 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
|
||||
|
||||
@@ -29,247 +29,148 @@ 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{
|
||||
LabelSelector: req.LabelSelector,
|
||||
Limit: req.Limit,
|
||||
listOptions := metav1.ListOptions{}
|
||||
if req.LabelSelector != "" {
|
||||
listOptions.LabelSelector = req.LabelSelector
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
return mcp.NewToolResultText(result), nil
|
||||
}
|
||||
|
||||
// handleGetResource gets detailed information about a specific resource
|
||||
@@ -283,37 +184,52 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
resultJSON, err := marshalJSON(result)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(resultJSON), nil
|
||||
return mcp.NewToolResultText(result), nil
|
||||
}
|
||||
|
||||
// handleListNamespaces lists all namespaces in the cluster
|
||||
@@ -328,30 +244,24 @@ func (s *K8sGptMCPServer) handleListNamespaces(ctx context.Context, request mcp.
|
||||
return mcp.NewToolResultErrorf("Failed to list namespaces: %v", err), nil
|
||||
}
|
||||
|
||||
resultJSON, err := marshalJSON(namespaces.Items)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(resultJSON), nil
|
||||
data, _ := json.MarshalIndent(namespaces.Items, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), 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 = DefaultListLimit
|
||||
} else if req.Limit > MaxListLimit {
|
||||
req.Limit = MaxListLimit
|
||||
req.Limit = 100
|
||||
}
|
||||
|
||||
client, err := kubernetes.NewClient("", "")
|
||||
@@ -380,12 +290,8 @@ func (s *K8sGptMCPServer) handleListEvents(ctx context.Context, request mcp.Call
|
||||
filteredEvents = append(filteredEvents, event)
|
||||
}
|
||||
|
||||
resultJSON, err := marshalJSON(filteredEvents)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(resultJSON), nil
|
||||
data, _ := json.MarshalIndent(filteredEvents, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
|
||||
// handleGetLogs retrieves logs from a pod container
|
||||
@@ -402,13 +308,6 @@ 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
|
||||
}
|
||||
@@ -457,12 +356,8 @@ func (s *K8sGptMCPServer) handleListFilters(ctx context.Context, request mcp.Cal
|
||||
"activeFilters": active,
|
||||
}
|
||||
|
||||
resultJSON, err := marshalJSON(result)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(resultJSON), nil
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
|
||||
// handleAddFilters adds filters to enable specific analyzers
|
||||
@@ -474,17 +369,10 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,11 +381,7 @@ func (s *K8sGptMCPServer) handleAddFilters(ctx context.Context, request mcp.Call
|
||||
return mcp.NewToolResultErrorf("Failed to save configuration: %v", err), 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
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Successfully added filters: %v", req.Filters)), nil
|
||||
}
|
||||
|
||||
// handleRemoveFilters removes filters to disable specific analyzers
|
||||
@@ -509,19 +393,11 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,11 +406,7 @@ func (s *K8sGptMCPServer) handleRemoveFilters(ctx context.Context, request mcp.C
|
||||
return mcp.NewToolResultErrorf("Failed to save configuration: %v", err), 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
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Successfully removed filters: %v", req.Filters)), nil
|
||||
}
|
||||
|
||||
// handleListIntegrations lists available integrations
|
||||
@@ -551,12 +423,8 @@ func (s *K8sGptMCPServer) handleListIntegrations(ctx context.Context, request mc
|
||||
})
|
||||
}
|
||||
|
||||
resultJSON, err := marshalJSON(result)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultErrorf("Failed to serialize result: %v", err), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(resultJSON), nil
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific string
|
||||
|
||||
Reference in New Issue
Block a user