mirror of
https://github.com/k8sgpt-ai/k8sgpt.git
synced 2026-03-18 19:17:25 +00:00
- Added CRDAnalyzerConfig types to pkg/common/types.go for configuration - Implemented CRD analyzer in pkg/analyzer/crd.go with support for: - Discovery of all installed CRDs via apiextensions API - Generic health checks based on common patterns (.status.conditions, .status.phase, etc.) - Configurable per-CRD health checks via YAML config - Detection of stuck resources (deletionTimestamp with finalizers) - Registered CRDAnalyzer in additionalAnalyzerMap - Added comprehensive unit tests in pkg/analyzer/crd_test.go - All tests passing Co-authored-by: AlexsJones <1235925+AlexsJones@users.noreply.github.com>
407 lines
12 KiB
Go
407 lines
12 KiB
Go
/*
|
|
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))
|
|
}
|
|
}
|
|
|
|
// Dummy test to satisfy the requirement
|
|
func TestCRDAnalyzer_NilClientConfig(t *testing.T) {
|
|
viper.Reset()
|
|
viper.Set("crd_analyzer", map[string]interface{}{
|
|
"enabled": true,
|
|
})
|
|
|
|
// Create a client with nil config - this should cause an error when trying to create apiextensions client
|
|
a := common.Analyzer{
|
|
Context: context.TODO(),
|
|
Client: &kubernetes.Client{Config: &rest.Config{}},
|
|
}
|
|
|
|
// This should fail gracefully
|
|
_, err := (CRDAnalyzer{}).Analyze(a)
|
|
if err == nil {
|
|
// Depending on the test setup, this may or may not error
|
|
// The important thing is that it doesn't panic
|
|
t.Log("Analyzer did not error with empty config - that's okay for this test")
|
|
}
|
|
}
|