mirror of
https://github.com/k8sgpt-ai/k8sgpt.git
synced 2026-03-18 19:17:25 +00:00
Add Generic CRD Analyzer implementation with tests
- 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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
406
pkg/analyzer/crd_test.go
Normal file
406
pkg/analyzer/crd_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
/*
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user