Compare commits

...

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b92512aec0 Complete PR #1602 review - all tests passing
Co-authored-by: AlexsJones <1235925+AlexsJones@users.noreply.github.com>
2026-01-30 16:59:09 +00:00
copilot-swe-agent[bot]
52f7808a3a Apply PR #1602: improve ConfigMap usage detection for sidecar patterns
Co-authored-by: AlexsJones <1235925+AlexsJones@users.noreply.github.com>
2026-01-30 16:52:40 +00:00
copilot-swe-agent[bot]
1f5d9a8fe0 Initial plan 2026-01-30 16:47:59 +00:00
13 changed files with 330 additions and 157 deletions

View File

@@ -42,7 +42,7 @@ var (
mcpPort string
mcpHTTP bool
// filters can be injected into the server (repeatable flag)
filters []string
filters []string
)
var ServeCmd = &cobra.Command{

View File

@@ -20,11 +20,11 @@ type MockBedrockClient struct {
func (m *MockBedrockClient) GetInferenceProfile(ctx context.Context, params *bedrock.GetInferenceProfileInput, optFns ...func(*bedrock.Options)) (*bedrock.GetInferenceProfileOutput, error) {
args := m.Called(ctx, params)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*bedrock.GetInferenceProfileOutput), args.Error(1)
}
@@ -35,11 +35,11 @@ type MockBedrockRuntimeClient struct {
func (m *MockBedrockRuntimeClient) InvokeModel(ctx context.Context, params *bedrockruntime.InvokeModelInput, optFns ...func(*bedrockruntime.Options)) (*bedrockruntime.InvokeModelOutput, error) {
args := m.Called(ctx, params)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*bedrockruntime.InvokeModelOutput), args.Error(1)
}
@@ -59,21 +59,21 @@ func TestBedrockInferenceProfileARNWithMocks(t *testing.T) {
},
},
}
// Create a client with test models
client := &AmazonBedRockClient{models: testModels}
// Create mock clients
mockMgmtClient := new(MockBedrockClient)
mockRuntimeClient := new(MockBedrockRuntimeClient)
// Inject mock clients into the AmazonBedRockClient
client.mgmtClient = mockMgmtClient
client.client = mockRuntimeClient
// Test with a valid inference profile ARN
inferenceProfileARN := "arn:aws:bedrock:us-east-1:123456789012:inference-profile/my-profile"
// Setup mock response for GetInferenceProfile
mockMgmtClient.On("GetInferenceProfile", mock.Anything, &bedrock.GetInferenceProfileInput{
InferenceProfileIdentifier: aws.String("my-profile"),
@@ -84,20 +84,20 @@ func TestBedrockInferenceProfileARNWithMocks(t *testing.T) {
},
},
}, nil)
// Configure the client with the inference profile ARN
config := AIProvider{
Model: inferenceProfileARN,
ProviderRegion: "us-east-1",
}
// Test the Configure method with the inference profile ARN
err := client.Configure(&config)
// Verify that the configuration was successful
assert.NoError(t, err)
assert.Equal(t, inferenceProfileARN, client.model.Config.ModelName)
// Verify that the mock was called
mockMgmtClient.AssertExpectations(t)
}

View File

@@ -45,7 +45,7 @@ func (p *ViperConfigProvider) UnmarshalKey(key string, rawVal interface{}) error
// Default instances to be used
var (
DefaultClientFactory = &DefaultAIClientFactory{}
DefaultClientFactory = &DefaultAIClientFactory{}
DefaultConfigProvider = &ViperConfigProvider{}
)
@@ -84,4 +84,4 @@ func SetTestConfigProvider(provider ConfigProvider) {
func ResetTestImplementations() {
testAIClientFactory = nil
testConfigProvider = nil
}
}

View File

@@ -94,11 +94,11 @@ func (c *OpenAIClient) GetCompletion(ctx context.Context, prompt string) (string
Content: prompt,
},
},
Temperature: c.temperature,
Temperature: c.temperature,
MaxCompletionTokens: maxToken,
PresencePenalty: presencePenalty,
FrequencyPenalty: frequencyPenalty,
TopP: c.topP,
PresencePenalty: presencePenalty,
FrequencyPenalty: frequencyPenalty,
TopP: c.topP,
})
if err != nil {
return "", err

View File

@@ -17,6 +17,7 @@ import (
"fmt"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -80,6 +81,17 @@ func (ConfigMapAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
for _, cm := range configMaps.Items {
var failures []common.Failure
// Check if ConfigMap is dynamically loaded by sidecars
if isKnownSidecarPattern(cm) {
usedConfigMaps[cm.Name] = true
continue
}
// Check if usage check should be skipped
if shouldSkipUsageCheck(cm) {
continue
}
// Check for unused ConfigMaps
if !usedConfigMaps[cm.Name] {
failures = append(failures, common.Failure{
@@ -123,3 +135,33 @@ func (ConfigMapAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
return results, nil
}
// isKnownSidecarPattern detects ConfigMaps that are dynamically loaded by sidecar containers
// These ConfigMaps are not directly referenced in Pod specs but are watched via Kubernetes API
func isKnownSidecarPattern(cm v1.ConfigMap) bool {
// Common sidecar patterns
knownLabels := []string{
"grafana_dashboard", // Grafana sidecar dashboard loader
"grafana_datasource", // Grafana sidecar datasource loader
"prometheus_rule", // Prometheus operator rule loader
"fluentd_config", // Fluentd config reloader
}
for _, label := range knownLabels {
if _, exists := cm.Labels[label]; exists {
return true
}
}
// User-defined marker for dynamically loaded ConfigMaps
if cm.Labels["k8sgpt.ai/dynamically-loaded"] == "true" {
return true
}
return false
}
// shouldSkipUsageCheck allows users to opt-out of usage checking
func shouldSkipUsageCheck(cm v1.ConfigMap) bool {
return cm.Annotations["k8sgpt.ai/skip-usage-check"] == "true"
}

View File

@@ -147,3 +147,134 @@ func TestConfigMapAnalyzer(t *testing.T) {
})
}
}
// TestConfigMapAnalyzer_SidecarPatterns tests known sidecar patterns and skip annotations
func TestConfigMapAnalyzer_SidecarPatterns(t *testing.T) {
tests := []struct {
name string
namespace string
configMaps []v1.ConfigMap
pods []v1.Pod
expectedErrors int
}{
{
name: "grafana dashboard configmap should not be flagged as unused",
namespace: "monitoring",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "grafana-dashboard",
Namespace: "monitoring",
Labels: map[string]string{
"grafana_dashboard": "1",
},
},
Data: map[string]string{
"dashboard.json": `{"title": "My Dashboard"}`,
},
},
},
pods: []v1.Pod{},
expectedErrors: 0,
},
{
name: "configmap with skip annotation should be ignored",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "ignored-cm",
Namespace: "default",
Annotations: map[string]string{
"k8sgpt.ai/skip-usage-check": "true",
},
},
Data: map[string]string{
"key": "value",
},
},
},
expectedErrors: 0,
},
{
name: "normal unused configmap should still be flagged",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "unused-cm",
Namespace: "default",
},
Data: map[string]string{
"key": "value",
},
},
},
expectedErrors: 1,
},
{
name: "prometheus rule configmap should not be flagged",
namespace: "monitoring",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "prometheus-rules",
Namespace: "monitoring",
Labels: map[string]string{
"prometheus_rule": "1",
},
},
Data: map[string]string{
"rules.yaml": "groups: []",
},
},
},
expectedErrors: 0,
},
{
name: "custom dynamically-loaded label should work",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-sidecar-cm",
Namespace: "default",
Labels: map[string]string{
"k8sgpt.ai/dynamically-loaded": "true",
},
},
Data: map[string]string{
"config": "value",
},
},
},
expectedErrors: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := fake.NewSimpleClientset()
for _, cm := range tt.configMaps {
_, err := client.CoreV1().ConfigMaps(tt.namespace).Create(context.TODO(), &cm, metav1.CreateOptions{})
assert.NoError(t, err)
}
for _, pod := range tt.pods {
_, err := client.CoreV1().Pods(tt.namespace).Create(context.TODO(), &pod, metav1.CreateOptions{})
assert.NoError(t, err)
}
analyzer := ConfigMapAnalyzer{}
results, err := analyzer.Analyze(common.Analyzer{
Client: &kubernetes.Client{Client: client},
Context: context.TODO(),
Namespace: tt.namespace,
})
assert.NoError(t, err)
assert.Equal(t, tt.expectedErrors, len(results), "Expected %d errors but got %d", tt.expectedErrors, len(results))
})
}
}

View File

@@ -55,7 +55,7 @@ func (d DeploymentAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error)
for _, deployment := range deployments.Items {
var failures []common.Failure
if *deployment.Spec.Replicas != deployment.Status.ReadyReplicas {
if deployment.Status.Replicas > *deployment.Spec.Replicas {
if deployment.Status.Replicas > *deployment.Spec.Replicas {
doc := apiDoc.GetApiDocV2("spec.replicas")
failures = append(failures, common.Failure{
@@ -88,7 +88,7 @@ func (d DeploymentAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error)
Masked: util.MaskString(deployment.Name),
},
}})
}
}
}
if len(failures) > 0 {
preAnalysis[fmt.Sprintf("%s/%s", deployment.Namespace, deployment.Name)] = common.PreAnalysis{

View File

@@ -1,60 +1,60 @@
package cache
import (
"os"
"testing"
"os"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)
func TestNewReturnsExpectedCache(t *testing.T) {
require.IsType(t, &FileBasedCache{}, New("file"))
require.IsType(t, &AzureCache{}, New("azure"))
require.IsType(t, &GCSCache{}, New("gcs"))
require.IsType(t, &S3Cache{}, New("s3"))
require.IsType(t, &InterplexCache{}, New("interplex"))
// default fallback
require.IsType(t, &FileBasedCache{}, New("unknown"))
require.IsType(t, &FileBasedCache{}, New("file"))
require.IsType(t, &AzureCache{}, New("azure"))
require.IsType(t, &GCSCache{}, New("gcs"))
require.IsType(t, &S3Cache{}, New("s3"))
require.IsType(t, &InterplexCache{}, New("interplex"))
// default fallback
require.IsType(t, &FileBasedCache{}, New("unknown"))
}
func TestNewCacheProvider_InterplexAndInvalid(t *testing.T) {
// valid: interplex
cp, err := NewCacheProvider("interplex", "", "", "localhost:1", "", "", "", false)
require.NoError(t, err)
require.Equal(t, "interplex", cp.CurrentCacheType)
require.Equal(t, "localhost:1", cp.Interplex.ConnectionString)
// valid: interplex
cp, err := NewCacheProvider("interplex", "", "", "localhost:1", "", "", "", false)
require.NoError(t, err)
require.Equal(t, "interplex", cp.CurrentCacheType)
require.Equal(t, "localhost:1", cp.Interplex.ConnectionString)
// invalid type
_, err = NewCacheProvider("not-a-type", "", "", "", "", "", "", false)
require.Error(t, err)
// invalid type
_, err = NewCacheProvider("not-a-type", "", "", "", "", "", "", false)
require.Error(t, err)
}
func TestAddRemoveRemoteCacheAndGet(t *testing.T) {
// isolate viper with temp config file
tmpFile, err := os.CreateTemp("", "k8sgpt-cache-config-*.yaml")
require.NoError(t, err)
defer func() {
_ = os.Remove(tmpFile.Name())
}()
viper.Reset()
viper.SetConfigFile(tmpFile.Name())
// isolate viper with temp config file
tmpFile, err := os.CreateTemp("", "k8sgpt-cache-config-*.yaml")
require.NoError(t, err)
defer func() {
_ = os.Remove(tmpFile.Name())
}()
viper.Reset()
viper.SetConfigFile(tmpFile.Name())
// add interplex remote cache
cp := CacheProvider{}
cp.CurrentCacheType = "interplex"
cp.Interplex.ConnectionString = "localhost:1"
require.NoError(t, AddRemoteCache(cp))
// add interplex remote cache
cp := CacheProvider{}
cp.CurrentCacheType = "interplex"
cp.Interplex.ConnectionString = "localhost:1"
require.NoError(t, AddRemoteCache(cp))
// read back via GetCacheConfiguration
c, err := GetCacheConfiguration()
require.NoError(t, err)
require.IsType(t, &InterplexCache{}, c)
// read back via GetCacheConfiguration
c, err := GetCacheConfiguration()
require.NoError(t, err)
require.IsType(t, &InterplexCache{}, c)
// remove remote cache
require.NoError(t, RemoveRemoteCache())
// now default should be file-based
c2, err := GetCacheConfiguration()
require.NoError(t, err)
require.IsType(t, &FileBasedCache{}, c2)
}
// remove remote cache
require.NoError(t, RemoveRemoteCache())
// now default should be file-based
c2, err := GetCacheConfiguration()
require.NoError(t, err)
require.IsType(t, &FileBasedCache{}, c2)
}

View File

@@ -1,77 +1,77 @@
package cache
import (
"os"
"path/filepath"
"testing"
"os"
"path/filepath"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/require"
"github.com/adrg/xdg"
"github.com/stretchr/testify/require"
)
// withTempCacheHome sets XDG_CACHE_HOME to a temp dir for test isolation.
func withTempCacheHome(t *testing.T) func() {
t.Helper()
tmp, err := os.MkdirTemp("", "k8sgpt-cache-test-*")
require.NoError(t, err)
old := os.Getenv("XDG_CACHE_HOME")
require.NoError(t, os.Setenv("XDG_CACHE_HOME", tmp))
return func() {
_ = os.Setenv("XDG_CACHE_HOME", old)
_ = os.RemoveAll(tmp)
}
t.Helper()
tmp, err := os.MkdirTemp("", "k8sgpt-cache-test-*")
require.NoError(t, err)
old := os.Getenv("XDG_CACHE_HOME")
require.NoError(t, os.Setenv("XDG_CACHE_HOME", tmp))
return func() {
_ = os.Setenv("XDG_CACHE_HOME", old)
_ = os.RemoveAll(tmp)
}
}
func TestFileBasedCache_BasicOps(t *testing.T) {
cleanup := withTempCacheHome(t)
defer cleanup()
cleanup := withTempCacheHome(t)
defer cleanup()
c := &FileBasedCache{}
// Configure should be a no-op
require.NoError(t, c.Configure(CacheProvider{}))
require.Equal(t, "file", c.GetName())
require.False(t, c.IsCacheDisabled())
c.DisableCache()
require.True(t, c.IsCacheDisabled())
c := &FileBasedCache{}
// Configure should be a no-op
require.NoError(t, c.Configure(CacheProvider{}))
require.Equal(t, "file", c.GetName())
require.False(t, c.IsCacheDisabled())
c.DisableCache()
require.True(t, c.IsCacheDisabled())
key := "testkey"
data := "hello"
key := "testkey"
data := "hello"
// Store
require.NoError(t, c.Store(key, data))
// Store
require.NoError(t, c.Store(key, data))
// Exists
require.True(t, c.Exists(key))
// Exists
require.True(t, c.Exists(key))
// Load
got, err := c.Load(key)
require.NoError(t, err)
require.Equal(t, data, got)
// Load
got, err := c.Load(key)
require.NoError(t, err)
require.Equal(t, data, got)
// List should include our key file
items, err := c.List()
require.NoError(t, err)
// ensure at least one item and that one matches our key
found := false
for _, it := range items {
if it.Name == key {
found = true
break
}
}
require.True(t, found)
// List should include our key file
items, err := c.List()
require.NoError(t, err)
// ensure at least one item and that one matches our key
found := false
for _, it := range items {
if it.Name == key {
found = true
break
}
}
require.True(t, found)
// Remove
require.NoError(t, c.Remove(key))
require.False(t, c.Exists(key))
// Remove
require.NoError(t, c.Remove(key))
require.False(t, c.Exists(key))
}
func TestFileBasedCache_PathShape(t *testing.T) {
cleanup := withTempCacheHome(t)
defer cleanup()
// Verify xdg.CacheFile path shape (directory and filename)
p, err := xdg.CacheFile(filepath.Join("k8sgpt", "abc"))
require.NoError(t, err)
require.Equal(t, "abc", filepath.Base(p))
require.Contains(t, p, "k8sgpt")
}
cleanup := withTempCacheHome(t)
defer cleanup()
// Verify xdg.CacheFile path shape (directory and filename)
p, err := xdg.CacheFile(filepath.Join("k8sgpt", "abc"))
require.NoError(t, err)
require.Equal(t, "abc", filepath.Base(p))
require.Contains(t, p, "k8sgpt")
}

View File

@@ -1,41 +1,41 @@
package custom
import (
"context"
"testing"
"context"
"testing"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
// mockAnalyzerClient implements rpc.CustomAnalyzerServiceClient for testing
type mockAnalyzerClient struct{
resp *schemav1.RunResponse
err error
type mockAnalyzerClient struct {
resp *schemav1.RunResponse
err error
}
func (m *mockAnalyzerClient) Run(ctx context.Context, in *schemav1.RunRequest, opts ...grpc.CallOption) (*schemav1.RunResponse, error) {
return m.resp, m.err
return m.resp, m.err
}
func TestClientRunMapsResponse(t *testing.T) {
// prepare fake response
resp := &schemav1.RunResponse{
Result: &schemav1.Result{
Name: "AnalyzerA",
Kind: "Pod",
Details: "details",
ParentObject: "Deployment/foo",
},
}
cli := &Client{analyzerClient: &mockAnalyzerClient{resp: resp}}
// prepare fake response
resp := &schemav1.RunResponse{
Result: &schemav1.Result{
Name: "AnalyzerA",
Kind: "Pod",
Details: "details",
ParentObject: "Deployment/foo",
},
}
cli := &Client{analyzerClient: &mockAnalyzerClient{resp: resp}}
got, err := cli.Run()
require.NoError(t, err)
require.Equal(t, "AnalyzerA", got.Name)
require.Equal(t, "Pod", got.Kind)
require.Equal(t, "details", got.Details)
require.Equal(t, "Deployment/foo", got.ParentObject)
require.Len(t, got.Error, 0)
}
got, err := cli.Run()
require.NoError(t, err)
require.Equal(t, "AnalyzerA", got.Name)
require.Equal(t, "Pod", got.Kind)
require.Equal(t, "details", got.Details)
require.Equal(t, "Deployment/foo", got.ParentObject)
require.Len(t, got.Error, 0)
}

View File

@@ -251,10 +251,10 @@ func (s *K8sGptMCPServer) handleListNamespaces(ctx context.Context, request mcp.
// 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

View File

@@ -307,4 +307,4 @@ func TestQuery_GetCompletionError(t *testing.T) {
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}
}

View File

@@ -55,10 +55,10 @@ type Config struct {
QueryHandler *query.Handler
Logger *zap.Logger
// Filters can be injected into the server to limit analysis to specific analyzers
Filters []string
metricsServer *http.Server
listener net.Listener
EnableHttp bool
Filters []string
metricsServer *http.Server
listener net.Listener
EnableHttp bool
}
type Health struct {