diff --git a/.github/ct.yaml b/.github/ct.yaml index 9045650e..2bce5433 100644 --- a/.github/ct.yaml +++ b/.github/ct.yaml @@ -4,7 +4,7 @@ target-branch: main chart-repos: - - newrelic=https://helm-charts.newrelic.com + - newrelic=https://newrelic.github.io/helm-charts/ # Charts will be released manually. check-version-increment: false diff --git a/.github/workflows/release-charts.yml b/.github/workflows/release-charts.yml index e0d55601..9cab855e 100644 --- a/.github/workflows/release-charts.yml +++ b/.github/workflows/release-charts.yml @@ -26,7 +26,7 @@ jobs: git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - name: Add newrelic repository - run: helm repo add newrelic https://helm-charts.newrelic.com + run: helm repo add newrelic https://newrelic.github.io/helm-charts/ - name: Run chart-releaser uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 diff --git a/api/v1alpha2/instrumentation_webhook.go b/api/v1alpha2/instrumentation_webhook.go index e7500a73..51ee4bea 100644 --- a/api/v1alpha2/instrumentation_webhook.go +++ b/api/v1alpha2/instrumentation_webhook.go @@ -118,6 +118,11 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa return nil, fmt.Errorf("instrumentation must be in operator namespace") } + // Check if agent is empty first, before validating individual fields + if inst.Spec.Agent.IsEmpty() { + return nil, fmt.Errorf("instrumentation %q agent is empty", inst.Name) + } + if agentLang := inst.Spec.Agent.Language; !slices.Contains(acceptableLangs, agentLang) { return nil, fmt.Errorf("instrumentation agent language %q must be one of the accepted languages (%s)", agentLang, strings.Join(acceptableLangs, ", ")) } @@ -125,10 +130,6 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa if err := r.validateEnv(inst.Spec.Agent.Env); err != nil { return nil, err } - - if inst.Spec.Agent.IsEmpty() { - return nil, fmt.Errorf("instrumentation %q agent is empty", inst.Name) - } if _, err := metav1.LabelSelectorAsSelector(&inst.Spec.PodLabelSelector); err != nil { return nil, err } @@ -141,6 +142,14 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa // validateEnv to validate the environment variables used all start with the required prefixes func (r *InstrumentationValidator) validateEnv(envs []corev1.EnvVar) error { + // First, check that NEW_RELIC_LICENSE_KEY is not set (it should only be in the secret) + for _, env := range envs { + if env.Name == "NEW_RELIC_LICENSE_KEY" { + return fmt.Errorf("NEW_RELIC_LICENSE_KEY should not be set in agent.env; the license key should be set via the licenseKeySecret field") + } + } + + // Then validate that all env vars start with valid prefixes var invalidNames []string for _, env := range envs { var valid bool diff --git a/api/v1alpha2/instrumentation_webhook_test.go b/api/v1alpha2/instrumentation_webhook_test.go new file mode 100644 index 00000000..ab3def3a --- /dev/null +++ b/api/v1alpha2/instrumentation_webhook_test.go @@ -0,0 +1,202 @@ +package v1alpha2 + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestValidateLicenseKeyInEnv(t *testing.T) { + operatorNamespace := "operator-ns" + validator := &InstrumentationValidator{ + OperatorNamespace: operatorNamespace, + } + + tests := []struct { + name string + inst *Instrumentation + wantErr bool + errMsg string + }{ + { + name: "license key in env should fail", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEW_RELIC_LICENSE_KEY", + Value: "secret-key", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "NEW_RELIC_LICENSE_KEY should not be set in agent.env", + }, + { + name: "other NEW_RELIC env vars should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEW_RELIC_APP_NAME", + Value: "my-app", + }, + { + Name: "NEW_RELIC_DISTRIBUTED_TRACING_ENABLED", + Value: "true", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + { + name: "NEWRELIC prefix env vars should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "python", + Image: "newrelic/python-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEWRELIC_LOG_LEVEL", + Value: "debug", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validator.validate(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error, got nil") + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} + +func TestValidationOrder(t *testing.T) { + operatorNamespace := "operator-ns" + validator := &InstrumentationValidator{ + OperatorNamespace: operatorNamespace, + } + + tests := []struct { + name string + inst *Instrumentation + wantErr bool + errMsg string + }{ + { + name: "empty agent should fail with empty agent error, not language error", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "", // Empty language + Image: "", // Empty image + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "agent is empty", // Should get this error, not language error + }, + { + name: "agent with image but invalid language should fail with language error", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "invalid-lang", + Image: "some-image:latest", + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "must be one of the accepted languages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validator.validate(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error, got nil") + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} + +// contains is a helper to check if a string contains a substring +func contains(s, substr string) bool { + if len(s) == 0 || len(substr) == 0 { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/api/v1beta1/instrumentation_webhook.go b/api/v1beta1/instrumentation_webhook.go index 9a111137..3b7ced12 100644 --- a/api/v1beta1/instrumentation_webhook.go +++ b/api/v1beta1/instrumentation_webhook.go @@ -120,6 +120,11 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa return nil, fmt.Errorf("instrumentation must be in operator namespace") } + // Check if agent is empty first, before validating individual fields + if inst.Spec.Agent.IsEmpty() { + return nil, fmt.Errorf("instrumentation %q agent is empty", inst.Name) + } + agentLang := inst.Spec.Agent.Language if !slices.Contains(acceptableLangs, agentLang) { return nil, fmt.Errorf("instrumentation agent language %q must be one of the accepted languages (%s)", agentLang, strings.Join(acceptableLangs, ", ")) @@ -129,15 +134,17 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa if !slices.Contains(acceptLangsForAgentConfigMap, agentLang) { return nil, fmt.Errorf("instrumentation agent language %q does not support an agentConfigMap, agentConfigMap can only be configured with one of these languages (%q)", agentLang, strings.Join(acceptLangsForAgentConfigMap, ", ")) } + // Check that NEWRELIC_FILE is not set when using agentConfigMap (Java only) + for _, env := range inst.Spec.Agent.Env { + if env.Name == "NEWRELIC_FILE" { + return nil, fmt.Errorf("%q is already set by the agentConfigMap", env.Name) + } + } } if err := r.validateEnv(inst.Spec.Agent.Env); err != nil { return nil, err } - - if inst.Spec.Agent.IsEmpty() { - return nil, fmt.Errorf("instrumentation %q agent is empty", inst.Name) - } if len(inst.Spec.HealthAgent.Env) > 0 && inst.Spec.HealthAgent.Image == "" { return nil, fmt.Errorf("instrumentation %q healthAgent.image is empty, meanwhile the environment is not", inst.Name) } @@ -154,6 +161,14 @@ func (r *InstrumentationValidator) validate(inst *Instrumentation) (admission.Wa // validateEnv to validate the environment variables used all start with the required prefixes func (r *InstrumentationValidator) validateEnv(envs []corev1.EnvVar) error { + // First, check that NEW_RELIC_LICENSE_KEY is not set (it should only be in the secret) + for _, env := range envs { + if env.Name == "NEW_RELIC_LICENSE_KEY" { + return fmt.Errorf("NEW_RELIC_LICENSE_KEY should not be set in agent.env; the license key should be set via the licenseKeySecret field") + } + } + + // Then validate that all env vars start with valid prefixes var invalidNames []string for _, env := range envs { var valid bool diff --git a/api/v1beta1/instrumentation_webhook_test.go b/api/v1beta1/instrumentation_webhook_test.go new file mode 100644 index 00000000..63f0aa59 --- /dev/null +++ b/api/v1beta1/instrumentation_webhook_test.go @@ -0,0 +1,330 @@ +package v1beta1 + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestValidateLicenseKeyInEnv(t *testing.T) { + operatorNamespace := "operator-ns" + validator := &InstrumentationValidator{ + OperatorNamespace: operatorNamespace, + } + + tests := []struct { + name string + inst *Instrumentation + wantErr bool + errMsg string + }{ + { + name: "license key in env should fail", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEW_RELIC_LICENSE_KEY", + Value: "secret-key", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "NEW_RELIC_LICENSE_KEY should not be set in agent.env", + }, + { + name: "other NEW_RELIC env vars should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEW_RELIC_APP_NAME", + Value: "my-app", + }, + { + Name: "NEW_RELIC_DISTRIBUTED_TRACING_ENABLED", + Value: "true", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + { + name: "NEWRELIC prefix env vars should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "python", + Image: "newrelic/python-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEWRELIC_LOG_LEVEL", + Value: "debug", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validator.validate(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error, got nil") + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} + +func TestValidationOrder(t *testing.T) { + operatorNamespace := "operator-ns" + validator := &InstrumentationValidator{ + OperatorNamespace: operatorNamespace, + } + + tests := []struct { + name string + inst *Instrumentation + wantErr bool + errMsg string + }{ + { + name: "empty agent should fail with empty agent error, not language error", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "", // Empty language + Image: "", // Empty image + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "agent is empty", // Should get this error, not language error + }, + { + name: "agent with image but invalid language should fail with language error", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "invalid-lang", + Image: "some-image:latest", + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "must be one of the accepted languages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validator.validate(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error, got nil") + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} + +// contains is a helper to check if a string contains a substring +func contains(s, substr string) bool { + if len(s) == 0 || len(substr) == 0 { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestValidateAgentConfigMapWithNewrelicFile(t *testing.T) { + operatorNamespace := "operator-ns" + validator := &InstrumentationValidator{ + OperatorNamespace: operatorNamespace, + } + + tests := []struct { + name string + inst *Instrumentation + wantErr bool + errMsg string + }{ + { + name: "agentConfigMap with NEWRELIC_FILE should fail", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + AgentConfigMap: "my-config-map", + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEWRELIC_FILE", + Value: "/path/to/config.yml", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: true, + errMsg: "is already set by the agentConfigMap", + }, + { + name: "agentConfigMap without NEWRELIC_FILE should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + AgentConfigMap: "my-config-map", + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEW_RELIC_APP_NAME", + Value: "my-app", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + { + name: "no agentConfigMap with NEWRELIC_FILE should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{ + { + Name: "NEWRELIC_FILE", + Value: "/path/to/config.yml", + }, + }, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + { + name: "agentConfigMap with empty env should pass", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: operatorNamespace, + }, + Spec: InstrumentationSpec{ + AgentConfigMap: "my-config-map", + Agent: Agent{ + Language: "java", + Image: "newrelic/java-agent:latest", + Env: []corev1.EnvVar{}, + }, + PodLabelSelector: metav1.LabelSelector{}, + NamespaceLabelSelector: metav1.LabelSelector{}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := validator.validate(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validate() expected error, got nil") + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("validate() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validate() unexpected error = %v", err) + } + } + }) + } +} diff --git a/api/v1beta3/instrumentation_webhook_test.go b/api/v1beta3/instrumentation_webhook_test.go index bfa0b639..05c449f8 100644 --- a/api/v1beta3/instrumentation_webhook_test.go +++ b/api/v1beta3/instrumentation_webhook_test.go @@ -2,9 +2,12 @@ package v1beta3 import ( "context" + "strings" "testing" "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -29,3 +32,643 @@ func TestInstrumentationDefaulter(t *testing.T) { t.Fatalf("Unexpected diff (-want +got): %v", diff) } } + +func TestValidateAgent_Languages(t *testing.T) { + tests := []struct { + name string + language string + wantErr bool + }{ + {"java", "java", false}, + {"python", "python", false}, + {"nodejs", "nodejs", false}, + {"ruby", "ruby", false}, + {"go", "go", false}, + {"dotnet", "dotnet", false}, + {"dotnet-windows2022", "dotnet-windows2022", false}, + {"php-8.3", "php-8.3", false}, + {"invalid", "invalid-lang", true}, + {"empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: tt.language, + Image: "agent:latest", + }, + }, + } + + err := validateAgent(inst) + if (err != nil) != tt.wantErr { + t.Errorf("validateAgent() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateAgent_ImagePullPolicy(t *testing.T) { + tests := []struct { + name string + imagePullPolicy corev1.PullPolicy + wantErr bool + }{ + {"Always", corev1.PullAlways, false}, + {"Never", corev1.PullNever, false}, + {"IfNotPresent", corev1.PullIfNotPresent, false}, + {"Empty", "", false}, + {"Invalid", "InvalidPolicy", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + ImagePullPolicy: tt.imagePullPolicy, + }, + }, + } + + err := validateAgent(inst) + if (err != nil) != tt.wantErr { + t.Errorf("validateAgent() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateHealthAgent_ResourcesWithoutImage(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + HealthAgent: HealthAgent{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + } + + err := validateHealthAgent(inst) + if err == nil { + t.Error("validateHealthAgent() expected error for resources without image, got nil") + } +} + +func TestValidateLicenseKeySecret_InEnv(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + Env: []corev1.EnvVar{ + {Name: "NEW_RELIC_LICENSE_KEY", Value: "secret"}, + }, + }, + }, + } + + err := validateLicenseKeySecret(inst) + if err == nil { + t.Error("validateLicenseKeySecret() expected error for license key in env, got nil") + } +} + +func TestValidateAgentConfigMap_UnsupportedLanguage(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "python", + Image: "agent:latest", + }, + AgentConfigMap: "python-config", + }, + } + + err := validateAgentConfigMap(inst) + if err == nil { + t.Error("validateAgentConfigMap() expected error for python with configmap, got nil") + } +} + +func TestValidateEnvs_ValidPrefixes(t *testing.T) { + tests := []struct { + name string + envVars []corev1.EnvVar + wantErr bool + }{ + { + name: "valid NEW_RELIC_ prefix", + envVars: []corev1.EnvVar{ + {Name: "NEW_RELIC_APP_NAME", Value: "test"}, + }, + wantErr: false, + }, + { + name: "valid NEWRELIC_ prefix", + envVars: []corev1.EnvVar{ + {Name: "NEWRELIC_APP_NAME", Value: "test"}, + }, + wantErr: false, + }, + { + name: "invalid prefix", + envVars: []corev1.EnvVar{ + {Name: "INVALID_VAR", Value: "test"}, + }, + wantErr: true, + }, + { + name: "mixed valid and invalid", + envVars: []corev1.EnvVar{ + {Name: "NEW_RELIC_APP_NAME", Value: "test"}, + {Name: "INVALID_VAR", Value: "test"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEnvs(tt.envVars) + if (err != nil) != tt.wantErr { + t.Errorf("validateEnvs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidatePodSelector_Invalid(t *testing.T) { + inst := &Instrumentation{ + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + PodLabelSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "app", + Operator: "InvalidOperator", + }, + }, + }, + }, + } + + err := validatePodSelector(inst) + if err == nil { + t.Error("validatePodSelector() expected error for invalid selector, got nil") + } +} + +func TestInstrumentationValidator_NamespaceValidation(t *testing.T) { + validator := NewInstrumentationValidator("newrelic") + + tests := []struct { + name string + instNamespace string + wantErr bool + }{ + {"same namespace", "newrelic", false}, + {"different namespace", "default", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: tt.instNamespace, + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + }, + } + + _, err := validator.ValidateCreate(context.Background(), inst) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateCreate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateContainerSelector(t *testing.T) { + tests := []struct { + name string + inst *Instrumentation + wantErr bool + wantErrContains string + }{ + { + name: "valid name selector", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + NameSelector: NameSelector{ + MatchNames: map[string]string{ + "container": "app", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid name selector expression", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + NameSelector: NameSelector{ + MatchExpressions: []NameSelectorRequirement{ + { + Key: "InvalidKey", + Operator: "InvalidOperator", + Values: []string{"value"}, + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "nameSelector is invalid", + }, + { + name: "valid image selector", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + ImageSelector: ImageSelector{ + MatchImages: map[string]string{ + "url": "myapp:*", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid image selector expression", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + ImageSelector: ImageSelector{ + MatchExpressions: []ImageSelectorRequirement{ + { + Key: "InvalidKey", + Operator: "InvalidOperator", + Values: []string{"value"}, + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "imageSelector is invalid", + }, + { + name: "valid env selector", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + EnvSelector: EnvSelector{ + MatchEnvs: map[string]string{ + "APP_ENV": "prod", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid env selector expression", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + EnvSelector: EnvSelector{ + MatchExpressions: []EnvSelectorRequirement{ + { + Key: "APP_ENV", + Operator: "InvalidOperator", + Values: []string{"prod"}, + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "envSelector is invalid", + }, + { + name: "multiple valid selectors", + inst: &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + ContainerSelector: ContainerSelector{ + NameSelector: NameSelector{ + MatchNames: map[string]string{ + "container": "app", + }, + }, + ImageSelector: ImageSelector{ + MatchImages: map[string]string{ + "url": "myapp:*", + }, + }, + EnvSelector: EnvSelector{ + MatchEnvs: map[string]string{ + "APP_ENV": "prod", + }, + }, + NamesFromPodAnnotation: "container.names", + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateContainerSelector(tt.inst) + + if tt.wantErr { + if err == nil { + t.Errorf("validateContainerSelector() expected error, got nil") + } else if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("validateContainerSelector() error = %v, want error containing %q", err, tt.wantErrContains) + } + } else { + if err != nil { + t.Errorf("validateContainerSelector() unexpected error = %v", err) + } + } + }) + } +} + +func TestValidateAgent_EmptyImage(t *testing.T) { + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "", // Empty image + }, + }, + } + + err := validateAgent(inst) + if err == nil { + t.Error("validateAgent() expected error for empty image, got nil") + } +} + +func TestValidateAgent_SecurityContext(t *testing.T) { + runAsUser := int64(1000) + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsUser, + }, + }, + }, + } + + err := validateAgent(inst) + if err != nil { + t.Errorf("validateAgent() unexpected error for agent with security context = %v", err) + } +} + +func TestValidateHealthAgent_SecurityContext(t *testing.T) { + runAsNonRoot := true + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + HealthAgent: HealthAgent{ + Image: "health-agent:latest", + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: &runAsNonRoot, + }, + }, + }, + } + + err := validateHealthAgent(inst) + if err != nil { + t.Errorf("validateHealthAgent() unexpected error for health agent with security context = %v", err) + } +} + +func TestValidateNamespaceSelector_InvalidExpression(t *testing.T) { + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + NamespaceLabelSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "env", + Operator: "InvalidOperator", + Values: []string{"prod"}, + }, + }, + }, + }, + } + + err := validateNamespaceSelector(inst) + if err == nil { + t.Error("validateNamespaceSelector() expected error for invalid operator, got nil") + } +} + +func TestValidateAgent_VolumeSizeLimit(t *testing.T) { + volumeSize := resource.MustParse("2Gi") + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + VolumeSizeLimit: &volumeSize, + }, + }, + } + + err := validateAgent(inst) + if err != nil { + t.Errorf("validateAgent() unexpected error for agent with volume size limit = %v", err) + } +} + +func TestValidateHealthAgent_ImagePullPolicyEmpty(t *testing.T) { + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + HealthAgent: HealthAgent{ + Image: "health-agent:latest", + ImagePullPolicy: "", // Empty is valid, uses default + }, + }, + } + + err := validateHealthAgent(inst) + if err != nil { + t.Errorf("validateHealthAgent() unexpected error for empty image pull policy = %v", err) + } +} + +func TestInstrumentationValidator_ValidateUpdate(t *testing.T) { + validator := NewInstrumentationValidator("newrelic") + + oldInst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "newrelic", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:v1", + }, + }, + } + + newInst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "newrelic", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:v2", + }, + }, + } + + _, err := validator.ValidateUpdate(context.Background(), oldInst, newInst) + if err != nil { + t.Errorf("ValidateUpdate() unexpected error = %v", err) + } +} + +func TestInstrumentationValidator_ValidateDelete(t *testing.T) { + validator := NewInstrumentationValidator("newrelic") + + inst := &Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "newrelic", + }, + Spec: InstrumentationSpec{ + Agent: Agent{ + Language: "java", + Image: "agent:latest", + }, + }, + } + + _, err := validator.ValidateDelete(context.Background(), inst) + if err != nil { + t.Errorf("ValidateDelete() unexpected error = %v", err) + } +} diff --git a/charts/k8s-agents-operator/.helmignore b/charts/k8s-agents-operator/.helmignore index 0e8a0eb3..50ad0161 100644 --- a/charts/k8s-agents-operator/.helmignore +++ b/charts/k8s-agents-operator/.helmignore @@ -21,3 +21,4 @@ .idea/ *.tmproj .vscode/ +bin/ diff --git a/charts/k8s-agents-operator/Chart.lock b/charts/k8s-agents-operator/Chart.lock index d61ef236..c85276ac 100644 --- a/charts/k8s-agents-operator/Chart.lock +++ b/charts/k8s-agents-operator/Chart.lock @@ -1,6 +1,6 @@ dependencies: - name: common-library - repository: https://helm-charts.newrelic.com + repository: https://newrelic.github.io/helm-charts/ version: 1.4.0 -digest: sha256:5655aeb5921ac47b5413c3873218309e4a5a78bc0d75d6dd7aabe13ad0e63cc7 -generated: "2025-12-15T15:58:37.202570702Z" +digest: sha256:750ad2f95a0027755066b9c31415b9d1c69952abeefd359d6ebabf655d9efca2 +generated: "2026-02-04T12:16:48.413231-06:00" diff --git a/charts/k8s-agents-operator/Chart.yaml b/charts/k8s-agents-operator/Chart.yaml index 4cd0c009..c81cba55 100644 --- a/charts/k8s-agents-operator/Chart.yaml +++ b/charts/k8s-agents-operator/Chart.yaml @@ -6,7 +6,7 @@ version: '0.37.1' dependencies: - name: common-library version: 1.4.0 - repository: "https://helm-charts.newrelic.com" + repository: "https://newrelic.github.io/helm-charts/" appVersion: '0.37.1' home: https://github.com/newrelic/k8s-agents-operator/blob/main/charts/k8s-agents-operator/README.md sources: diff --git a/charts/k8s-agents-operator/README.md b/charts/k8s-agents-operator/README.md index c9aaa278..67f51a9e 100644 --- a/charts/k8s-agents-operator/README.md +++ b/charts/k8s-agents-operator/README.md @@ -306,7 +306,7 @@ If you want to see a list of all available charts and releases, check [index.yam | Repository | Name | Version | |------------|------|---------| -| https://helm-charts.newrelic.com | common-library | 1.4.0 | +| https://newrelic.github.io/helm-charts/ | common-library | 1.4.0 | ## Values @@ -333,7 +333,7 @@ If you want to see a list of all available charts and releases, check [index.yam | controllerManager.manager.resources.requests.cpu | string | `"100m"` | | | controllerManager.manager.resources.requests.memory | string | `"64Mi"` | | | controllerManager.manager.verboseLog | string | `nil` | Enable or disable verbose (debug) logging | -| controllerManager.replicas | int | `1` | | +| controllerManager.replicas | int | `2` | | | crds.enabled | bool | `true` | | | dnsConfig | object | `{}` | Sets pod's dnsConfig. Can be configured also with `global.dnsConfig` | | healthProbe | object | `{"port":8081}` | when the operator is healthy. It is used by Kubernetes to check the health of the operator. | diff --git a/charts/k8s-agents-operator/templates/_naming.tpl b/charts/k8s-agents-operator/templates/_naming.tpl index 073e4f45..46d89a39 100644 --- a/charts/k8s-agents-operator/templates/_naming.tpl +++ b/charts/k8s-agents-operator/templates/_naming.tpl @@ -62,3 +62,7 @@ {{- define "k8s-agents-operator.rbac.instrumentationViewer.role.name" -}} {{- include "newrelic.common.naming.truncateToDNSWithSuffix" (dict "name" (include "newrelic.common.naming.fullname" .) "suffix" "instrumentation-viewer-role") -}} {{- end -}} + +{{- define "k8s-agents-operator.policy.poddisruptionbudget.name" -}} +{{- include "newrelic.common.naming.truncateToDNSWithSuffix" (dict "name" (include "newrelic.common.naming.fullname" .) "suffix" "pdb") -}} +{{- end -}} diff --git a/charts/k8s-agents-operator/templates/deployment.yaml b/charts/k8s-agents-operator/templates/deployment.yaml index 6d74f8de..0f87ffb2 100644 --- a/charts/k8s-agents-operator/templates/deployment.yaml +++ b/charts/k8s-agents-operator/templates/deployment.yaml @@ -8,6 +8,10 @@ metadata: {{- include "newrelic.common.labels" . | nindent 4 }} spec: replicas: {{ .Values.controllerManager.replicas }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 selector: matchLabels: {{- include "newrelic.common.labels.selectorLabels" . | nindent 6 }} @@ -94,6 +98,16 @@ spec: value: {{ quote .Values.kubernetesClusterDomain }} - name: ENABLE_WEBHOOKS value: "true" + - name: WEBHOOK_VALIDATING_CONFIG_NAME + value: {{ include "k8s-agents-operator.webhook.validating.name" . }} + - name: WEBHOOK_MUTATING_CONFIG_NAME + value: {{ include "k8s-agents-operator.webhook.mutating.name" . }} + - name: WEBHOOK_SECRET_NAME + value: {{ include "k8s-agents-operator.certificateSecret.name" . }} + - name: CERT_MANAGER_ENABLED + value: {{ .Values.admissionWebhooks.certManager.enabled | quote }} + - name: CERT_MANAGER_CERTIFICATE_NAME + value: {{ include "k8s-agents-operator.cert-manager.certificate.name" . }} {{- $globalProxy := "" }} {{- if .Values.global }} {{- $globalProxy = .Values.global.proxy | default "" }} @@ -107,6 +121,20 @@ spec: {{- end }} image: {{ include "k8s-agents-operator.manager.image" . }} imagePullPolicy: {{ .Values.controllerManager.manager.image.pullPolicy | default "Always" }} + lifecycle: + preStop: + exec: + command: + - /bin/sh + - -c + - sleep 15 + startupProbe: + httpGet: + path: /readyz + port: {{ .Values.healthProbe.port }} + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 livenessProbe: httpGet: path: /healthz @@ -123,6 +151,7 @@ spec: port: {{ .Values.healthProbe.port }} initialDelaySeconds: 5 periodSeconds: 10 + failureThreshold: 3 resources: {{- toYaml .Values.controllerManager.manager.resources | nindent 10 }} volumeMounts: diff --git a/charts/k8s-agents-operator/templates/manager-rbac.yaml b/charts/k8s-agents-operator/templates/manager-rbac.yaml index b9eb8ab5..3d245ff1 100644 --- a/charts/k8s-agents-operator/templates/manager-rbac.yaml +++ b/charts/k8s-agents-operator/templates/manager-rbac.yaml @@ -88,6 +88,14 @@ rules: - patch - update - watch +- apiGroups: + - admissionregistration.k8s.io + resources: + - mutatingwebhookconfigurations + - validatingwebhookconfigurations + verbs: + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/charts/k8s-agents-operator/templates/pod-disruption-budget.yaml b/charts/k8s-agents-operator/templates/pod-disruption-budget.yaml new file mode 100644 index 00000000..d204bba4 --- /dev/null +++ b/charts/k8s-agents-operator/templates/pod-disruption-budget.yaml @@ -0,0 +1,14 @@ +{{- if gt (int .Values.controllerManager.replicas) 1 }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "k8s-agents-operator.policy.poddisruptionbudget.name" . }} + labels: + {{- include "newrelic.common.labels" . | nindent 4 }} +spec: + minAvailable: 1 + selector: + matchLabels: + {{- include "newrelic.common.labels.selectorLabels" . | nindent 6 }} + control-plane: controller-manager +{{- end }} diff --git a/charts/k8s-agents-operator/tests/global-inheritance_test.yaml b/charts/k8s-agents-operator/tests/global-inheritance_test.yaml index b37b29d5..fbd6c7b0 100644 --- a/charts/k8s-agents-operator/tests/global-inheritance_test.yaml +++ b/charts/k8s-agents-operator/tests/global-inheritance_test.yaml @@ -228,7 +228,7 @@ tests: asserts: - equal: path: spec.template.spec.containers[0].image - value: "docker.io/newrelic/k8s-agents-operator:0.35.1" + value: "docker.io/newrelic/k8s-agents-operator:0.37.0" template: templates/deployment.yaml - it: uses global.images.registry for container image diff --git a/charts/k8s-agents-operator/values.yaml b/charts/k8s-agents-operator/values.yaml index d8a88ead..a77607df 100644 --- a/charts/k8s-agents-operator/values.yaml +++ b/charts/k8s-agents-operator/values.yaml @@ -52,7 +52,7 @@ crds: enabled: true controllerManager: - replicas: 1 + replicas: 2 manager: # -- Enable or disable verbose (debug) logging -- # diff --git a/cmd/health_check_test.go b/cmd/health_check_test.go new file mode 100644 index 00000000..82fa057a --- /dev/null +++ b/cmd/health_check_test.go @@ -0,0 +1,655 @@ +/* +Copyright 2024. + +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 main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + certmgrv1meta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + admissionv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// generateTestCertificate generates a self-signed certificate for testing +func generateTestCertificate() (certPEM, keyPEM []byte, err error) { + // Generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test Org"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + // Create self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, err + } + + // Encode certificate to PEM + certPEM = pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + // Encode private key to PEM + keyPEM = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return certPEM, keyPEM, nil +} + +func TestCheckTLSCertSecret(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + certPEM, keyPEM, err := generateTestCertificate() + if err != nil { + t.Fatalf("failed to generate test certificate: %v", err) + } + + tests := []struct { + name string + secret *corev1.Secret + setupFilesystem func(t *testing.T) (cleanup func()) + wantErr bool + wantErrContains string + skipFilesystemValidation bool + }{ + { + name: "secret does not exist", + secret: nil, + wantErr: false, // client.IgnoreNotFound returns nil for NotFound errors + }, + { + name: "secret exists but tls.crt is empty", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte{}, + corev1.TLSPrivateKeyKey: keyPEM, + }, + }, + wantErr: true, + wantErrContains: "tls.crt' or 'tls.key' data is empty", + }, + { + name: "secret exists but tls.key is empty", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: []byte{}, + }, + }, + wantErr: true, + wantErrContains: "tls.crt' or 'tls.key' data is empty", + }, + { + name: "secret exists with valid data but certificate not on filesystem", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + }, + setupFilesystem: func(t *testing.T) func() { + // Don't create files - simulate propagation delay + return func() {} + }, + wantErr: true, + wantErrContains: "certificate file not yet available on filesystem", + }, + { + name: "secret exists with valid data but private key not on filesystem", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + }, + setupFilesystem: func(t *testing.T) func() { + // Create temp cert dir with only cert file + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, corev1.TLSCertKey) + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + t.Fatalf("failed to write cert file: %v", err) + } + // Override cert dir for this test + originalCertDir := "/tmp/k8s-webhook-server/serving-certs" + // Note: This test would need to modify the function to accept certDir as parameter + // For now, we'll skip filesystem validation in the actual implementation test + return func() { + _ = originalCertDir // Cleanup + } + }, + skipFilesystemValidation: true, // Skip because we can't override certDir easily + wantErr: false, + }, + { + name: "secret exists with valid data and valid certificate on filesystem", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: certPEM, + corev1.TLSPrivateKeyKey: keyPEM, + }, + }, + setupFilesystem: func(t *testing.T) func() { + // Create temp cert dir + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, corev1.TLSCertKey) + keyPath := filepath.Join(tmpDir, corev1.TLSPrivateKeyKey) + + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + t.Fatalf("failed to write cert file: %v", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatalf("failed to write key file: %v", err) + } + + return func() { + // Cleanup handled by t.TempDir() + } + }, + skipFilesystemValidation: true, // Skip because we can't override certDir easily + wantErr: false, + }, + { + name: "secret exists with invalid certificate data", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("invalid cert data"), + corev1.TLSPrivateKeyKey: []byte("invalid key data"), + }, + }, + setupFilesystem: func(t *testing.T) func() { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, corev1.TLSCertKey) + keyPath := filepath.Join(tmpDir, corev1.TLSPrivateKeyKey) + + if err := os.WriteFile(certPath, []byte("invalid cert data"), 0644); err != nil { + t.Fatalf("failed to write cert file: %v", err) + } + if err := os.WriteFile(keyPath, []byte("invalid key data"), 0600); err != nil { + t.Fatalf("failed to write key file: %v", err) + } + + return func() {} + }, + skipFilesystemValidation: true, + wantErr: false, // Will fail on filesystem validation + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skipFilesystemValidation { + t.Skip("Skipping filesystem validation tests - requires certDir parameter support") + } + + // Create fake client + objs := []client.Object{} + if tt.secret != nil { + objs = append(objs, tt.secret) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + // Setup filesystem if needed + var cleanup func() + if tt.setupFilesystem != nil { + cleanup = tt.setupFilesystem(t) + defer cleanup() + } + + // Run the test + err := checkTLSCertSecret(ctx, fakeClient, "test-ns", "test-secret") + + // Check results + if tt.wantErr { + if err == nil { + t.Errorf("checkTLSCertSecret() expected error, got nil") + } else if tt.wantErrContains != "" && !containsString(err.Error(), tt.wantErrContains) { + t.Errorf("checkTLSCertSecret() error = %v, want error containing %q", err, tt.wantErrContains) + } + } else { + if err != nil { + t.Errorf("checkTLSCertSecret() unexpected error = %v", err) + } + } + }) + } +} + +func TestCheckMutatingWebhookCABundleInjection(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = admissionv1.AddToScheme(scheme) + + tests := []struct { + name string + webhookConfig *admissionv1.MutatingWebhookConfiguration + wantErr bool + wantErrContains string + }{ + { + name: "webhook config does not exist", + webhookConfig: nil, + wantErr: true, + wantErrContains: "not found", + }, + { + name: "webhook config exists with no webhooks", + webhookConfig: &admissionv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.MutatingWebhook{}, + }, + wantErr: true, + wantErrContains: "has no webhooks", + }, + { + name: "webhook config exists with empty CA bundle", + webhookConfig: &admissionv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.MutatingWebhook{ + { + Name: "test.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte{}, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "is missing the CA bundle", + }, + { + name: "webhook config exists with valid CA bundle", + webhookConfig: &admissionv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.MutatingWebhook{ + { + Name: "test.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte("fake-ca-bundle-data-that-is-long-enough-to-pass-the-size-validation-check-of-at-least-100-bytes-which-is-required"), + }, + }, + }, + }, + wantErr: false, + }, + { + name: "webhook config with multiple webhooks and valid CA bundle", + webhookConfig: &admissionv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.MutatingWebhook{ + { + Name: "test1.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte("fake-ca-bundle-data-that-is-long-enough-to-pass-the-size-validation-check-of-at-least-100-bytes-which-is-required"), + }, + }, + { + Name: "test2.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte("another-ca-bundle-that-is-also-long-enough-to-pass-the-100-byte-minimum-size-requirement-for-testing"), + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := []client.Object{} + if tt.webhookConfig != nil { + objs = append(objs, tt.webhookConfig) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + err := checkMutatingWebhookCABundleInjection(ctx, fakeClient, "test-webhook") + + if tt.wantErr { + if err == nil { + t.Errorf("checkMutatingWebhookCABundleInjection() expected error, got nil") + } else if tt.wantErrContains != "" && !containsString(err.Error(), tt.wantErrContains) { + t.Errorf("checkMutatingWebhookCABundleInjection() error = %v, want error containing %q", err, tt.wantErrContains) + } + } else { + if err != nil { + t.Errorf("checkMutatingWebhookCABundleInjection() unexpected error = %v", err) + } + } + }) + } +} + +func TestCheckValidatingWebhookCABundleInjection(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = admissionv1.AddToScheme(scheme) + + tests := []struct { + name string + webhookConfig *admissionv1.ValidatingWebhookConfiguration + wantErr bool + wantErrContains string + }{ + { + name: "webhook config does not exist", + webhookConfig: nil, + wantErr: true, + wantErrContains: "not found", + }, + { + name: "webhook config exists with no webhooks", + webhookConfig: &admissionv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.ValidatingWebhook{}, + }, + wantErr: true, + wantErrContains: "has no webhooks", + }, + { + name: "webhook config exists with empty CA bundle", + webhookConfig: &admissionv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.ValidatingWebhook{ + { + Name: "test.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte{}, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "is missing the CA bundle", + }, + { + name: "webhook config exists with valid CA bundle", + webhookConfig: &admissionv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhook", + }, + Webhooks: []admissionv1.ValidatingWebhook{ + { + Name: "test.webhook.example.com", + ClientConfig: admissionv1.WebhookClientConfig{ + CABundle: []byte("fake-ca-bundle-data-that-is-long-enough-to-pass-the-size-validation-check-of-at-least-100-bytes-which-is-required"), + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := []client.Object{} + if tt.webhookConfig != nil { + objs = append(objs, tt.webhookConfig) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + err := checkValidatingWebhookCABundleInjection(ctx, fakeClient, "test-webhook") + + if tt.wantErr { + if err == nil { + t.Errorf("checkValidatingWebhookCABundleInjection() expected error, got nil") + } else if tt.wantErrContains != "" && !containsString(err.Error(), tt.wantErrContains) { + t.Errorf("checkValidatingWebhookCABundleInjection() error = %v, want error containing %q", err, tt.wantErrContains) + } + } else { + if err != nil { + t.Errorf("checkValidatingWebhookCABundleInjection() unexpected error = %v", err) + } + } + }) + } +} + +func TestCheckCertManagerCertificate(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = certmgrv1.AddToScheme(scheme) + + tests := []struct { + name string + certificate *certmgrv1.Certificate + wantErr bool + wantErrContains string + }{ + { + name: "certificate does not exist", + certificate: nil, + wantErr: true, + wantErrContains: "not found", + }, + { + name: "certificate exists and is ready", + certificate: &certmgrv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cert", + Namespace: "test-ns", + }, + Status: certmgrv1.CertificateStatus{ + Conditions: []certmgrv1.CertificateCondition{ + { + Type: certmgrv1.CertificateConditionReady, + Status: certmgrv1meta.ConditionTrue, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "certificate exists but not ready", + certificate: &certmgrv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cert", + Namespace: "test-ns", + }, + Status: certmgrv1.CertificateStatus{ + Conditions: []certmgrv1.CertificateCondition{ + { + Type: certmgrv1.CertificateConditionReady, + Status: certmgrv1meta.ConditionFalse, + Reason: "Pending", + Message: "Waiting for certificate to be issued", + }, + }, + }, + }, + wantErr: true, + wantErrContains: "is not ready", + }, + { + name: "certificate exists but no ready condition", + certificate: &certmgrv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cert", + Namespace: "test-ns", + }, + Status: certmgrv1.CertificateStatus{ + Conditions: []certmgrv1.CertificateCondition{}, + }, + }, + wantErr: true, + wantErrContains: "do not yet include Ready type", + }, + { + name: "certificate exists with multiple conditions including ready=true", + certificate: &certmgrv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cert", + Namespace: "test-ns", + }, + Status: certmgrv1.CertificateStatus{ + Conditions: []certmgrv1.CertificateCondition{ + { + Type: "Issuing", + Status: certmgrv1meta.ConditionFalse, + }, + { + Type: certmgrv1.CertificateConditionReady, + Status: certmgrv1meta.ConditionTrue, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "certificate exists with ready condition unknown", + certificate: &certmgrv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cert", + Namespace: "test-ns", + }, + Status: certmgrv1.CertificateStatus{ + Conditions: []certmgrv1.CertificateCondition{ + { + Type: certmgrv1.CertificateConditionReady, + Status: certmgrv1meta.ConditionUnknown, + Reason: "Unknown", + Message: "Certificate status unknown", + }, + }, + }, + }, + wantErr: true, + wantErrContains: "is not ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := []client.Object{} + if tt.certificate != nil { + objs = append(objs, tt.certificate) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + err := checkCertManagerCertificate(ctx, fakeClient, "test-ns", "test-cert") + + if tt.wantErr { + if err == nil { + t.Errorf("checkCertManagerCertificate() expected error, got nil") + } else if tt.wantErrContains != "" && !containsString(err.Error(), tt.wantErrContains) { + t.Errorf("checkCertManagerCertificate() error = %v, want error containing %q", err, tt.wantErrContains) + } + } else { + if err != nil { + t.Errorf("checkCertManagerCertificate() unexpected error = %v", err) + } + } + }) + } +} + +// containsString is a helper to check if a string contains a substring +func containsString(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && stringContains(s, substr)) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/main.go b/cmd/main.go index 3650b017..5171105b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -24,21 +24,30 @@ import ( "net" "net/http" "os" + "path/filepath" "runtime" "strconv" "strings" + "sync" + "sync/atomic" "time" + certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + certmgrv1meta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" "github.com/go-logr/logr" openshift_routev1 "github.com/openshift/api/route/v1" + admissionv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8sscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -208,6 +217,30 @@ func main() { os.Exit(1) } + // Webhook configuration names from environment (set by Helm) + webhookValidatingConfigName := os.Getenv("WEBHOOK_VALIDATING_CONFIG_NAME") + webhookMutatingConfigName := os.Getenv("WEBHOOK_MUTATING_CONFIG_NAME") + webhookSecretName := os.Getenv("WEBHOOK_SECRET_NAME") + certManagerEnabled := os.Getenv("CERT_MANAGER_ENABLED") == "true" + certManagerCertName := os.Getenv("CERT_MANAGER_CERTIFICATE_NAME") + + if webhookValidatingConfigName == "" { + setupLog.Info("env var WEBHOOK_VALIDATING_CONFIG_NAME is required") + os.Exit(1) + } + if webhookMutatingConfigName == "" { + setupLog.Info("env var WEBHOOK_MUTATING_CONFIG_NAME is required") + os.Exit(1) + } + if webhookSecretName == "" { + setupLog.Info("env var WEBHOOK_SECRET_NAME is required") + os.Exit(1) + } + if certManagerEnabled && certManagerCertName == "" { + setupLog.Info("env var CERT_MANAGER_CERTIFICATE_NAME is required when cert-manager is enabled") + os.Exit(1) + } + v := version.Get() setupLog.Info("Starting the Kubernetes Agents Operator", "k8s-agents-operator", v.Operator, @@ -308,7 +341,7 @@ func main() { } // +kubebuilder:scaffold:builder - if err = registerApiHealth(mgr); err != nil { + if err = registerApiHealth(mgr, operatorNamespace, webhookValidatingConfigName, webhookMutatingConfigName, webhookSecretName, certManagerEnabled, certManagerCertName); err != nil { setupLog.Error(err, "failed to register api healthz and readyz") os.Exit(1) } @@ -320,13 +353,65 @@ func main() { } } -func registerApiHealth(mgr manager.Manager) error { - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { - return fmt.Errorf("unable to register api health check: %w", err) +// cachedHealthChecker caches health check results to avoid expensive API calls on every probe +type cachedHealthChecker struct { + check func(*http.Request) error + cache atomic.Value // stores *healthCheckResult + cacheTTL time.Duration + mu sync.Mutex +} + +type healthCheckResult struct { + err error + timestamp time.Time +} + +func (c *cachedHealthChecker) Check(req *http.Request) error { + // Try cache first + if cached := c.cache.Load(); cached != nil { + result := cached.(*healthCheckResult) + if time.Since(result.timestamp) < c.cacheTTL { + return result.err + } + } + + // Cache miss or expired, do real check + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring lock + if cached := c.cache.Load(); cached != nil { + result := cached.(*healthCheckResult) + if time.Since(result.timestamp) < c.cacheTTL { + return result.err + } + } + + // Perform actual check + err := c.check(req) + c.cache.Store(&healthCheckResult{ + err: err, + timestamp: time.Now(), + }) + return err +} + +func registerApiHealth(mgr manager.Manager, operatorNamespace, webhookValidatingConfigName, webhookMutatingConfigName, webhookSecretName string, certManagerEnabled bool, certManagerCertName string) error { + // Create cached health checker with 5-second TTL + cachedChecker := &cachedHealthChecker{ + check: WebhookReadyCheck("webhook-ready", mgr, webhookValidatingConfigName, webhookMutatingConfigName, operatorNamespace, webhookSecretName, certManagerEnabled, certManagerCertName), + cacheTTL: 5 * time.Second, + } + + if err := mgr.AddHealthzCheck("webhook-ready", cachedChecker.Check); err != nil { + setupLog.Error(err, "unable to register webhook ready check") + os.Exit(1) } - if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { - return fmt.Errorf("unable to register api ready check: %w", err) + + if err := mgr.AddReadyzCheck("ready", cachedChecker.Check); err != nil { + return fmt.Errorf("unable to register ready check: %w", err) } + return nil } @@ -409,3 +494,203 @@ func addDependencies(_ context.Context, mgr ctrl.Manager, cfg config.Config) err } return nil } + +// WebhookReadyCheck returns an error if the webhook is not ready, marking the operator as not ready. +func WebhookReadyCheck(name string, mgr ctrl.Manager, webhookValidatingConfigName, webhookMutatingConfigName, webhookServiceNamespace, webhookSecretName string, certManagerEnabled bool, certManagerCertName string) func(req *http.Request) error { + + // Use the manager's client to access Kubernetes resources + c := mgr.GetClient() + + return func(req *http.Request) (err error) { + defer func() { + if err != nil { + mgr.GetLogger().Error(err, "health check failed") + } + }() + setupLog.Info("health check occurred", "name", name, "path", req.RequestURI) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // NOTE: We intentionally do NOT check for service endpoint readiness here because + // that would create a circular dependency - the pod won't be marked ready until + // this readiness check passes, but we'd be checking if the service has ready endpoints. + // The readiness of the webhook service is implicitly validated by the other checks. + + // 2. Check for TLS Secret Readiness (Self-Signed or cert-manager) + // This ensures the certificate and key are present for the Pods to use. + if err = checkTLSCertSecret(ctx, c, webhookServiceNamespace, webhookSecretName); err != nil { + return fmt.Errorf("webhook TLS secret not ready: %w", err) + } + + // 2b. Check for cert-manager Certificate readiness (if cert-manager is enabled) + // Note: This check is optional and may fail if cert-manager CRDs are not installed + if certManagerEnabled { + if err = checkCertManagerCertificate(ctx, c, webhookServiceNamespace, certManagerCertName); err != nil { + // Log but don't fail if cert-manager types aren't registered + // This can happen if cert-manager isn't fully installed yet + mgr.GetLogger().V(1).Info("cert-manager certificate check skipped", "error", err.Error()) + } + } + + // 3. Check for CA Bundle Injection/Presence in Webhook Configuration + // This ensures the API server will trust the webhook. + if err = checkValidatingWebhookCABundleInjection(ctx, c, webhookValidatingConfigName); err != nil { + return fmt.Errorf("webhook config CA bundle not ready: %w", err) + } + + // 3. Check for CA Bundle Injection/Presence in Webhook Configuration + // This ensures the API server will trust the webhook. + if err = checkMutatingWebhookCABundleInjection(ctx, c, webhookMutatingConfigName); err != nil { + return fmt.Errorf("webhook config CA bundle not ready: %w", err) + } + + // If all checks pass, the webhook is ready! + return nil + } +} + +// checkTLSCertSecret Check for TLS Secret readiness (based on self-signed scenario) +func checkTLSCertSecret(ctx context.Context, c client.Client, webhookServiceNamespace, webhookSecretName string) error { + var tlsSecret corev1.Secret + namespacedName := types.NamespacedName{Name: webhookSecretName, Namespace: webhookServiceNamespace} + + // First, check if the secret exists in Kubernetes API + if err := c.Get(ctx, namespacedName, &tlsSecret); err != nil { + return client.IgnoreNotFound(err) + } + + if len(tlsSecret.Data[corev1.TLSCertKey]) == 0 || len(tlsSecret.Data[corev1.TLSPrivateKeyKey]) == 0 { + return fmt.Errorf("tls secret found, but 'tls.crt' or 'tls.key' data is empty") + } + + // Second, verify the certificates are actually available on the filesystem + // This is critical because Kubernetes secret propagation to mounted volumes can take several seconds + // The webhook server reads from the filesystem, not the K8s API + certDir := "/tmp/k8s-webhook-server/serving-certs" // controller-runtime default + certPath := filepath.Join(certDir, corev1.TLSCertKey) + keyPath := filepath.Join(certDir, corev1.TLSPrivateKeyKey) + + // Check if certificate files exist and are readable + if _, err := os.Stat(certPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("certificate file not yet available on filesystem: %s (secret propagation in progress)", certPath) + } + return fmt.Errorf("cannot access certificate file %s: %w", certPath, err) + } + + if _, err := os.Stat(keyPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("private key file not yet available on filesystem: %s (secret propagation in progress)", keyPath) + } + return fmt.Errorf("cannot access private key file %s: %w", keyPath, err) + } + + // Verify we can actually load and parse the certificate + // This ensures the files aren't corrupted or incomplete during propagation + certPEMBlock, err := os.ReadFile(certPath) + if err != nil { + return fmt.Errorf("failed to read certificate file: %w", err) + } + keyPEMBlock, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("failed to read private key file: %w", err) + } + + // Validate the certificate and key are properly formatted and match + _, err = tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + return fmt.Errorf("invalid TLS certificate/key pair on filesystem: %w", err) + } + + return nil +} + +// checkMutatingWebhookCABundleInjection Check for CA Bundle Injection/Presence +func checkMutatingWebhookCABundleInjection(ctx context.Context, c client.Client, webhookConfigName string) error { + var mwhc admissionv1.MutatingWebhookConfiguration + namespacedName := types.NamespacedName{Name: webhookConfigName} // WebhookConfig is cluster-scoped + + if err := c.Get(ctx, namespacedName, &mwhc); err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("mutating webhook configuration %s not found", webhookConfigName) + } + return err + } + + if len(mwhc.Webhooks) == 0 { + return fmt.Errorf("mutating webhook configuration %s has no webhooks", webhookConfigName) + } + + for _, webhook := range mwhc.Webhooks { + if len(webhook.ClientConfig.CABundle) == 0 { + return fmt.Errorf("mutating webhook %s is missing the CA bundle", webhook.Name) + } + // Validate it's a reasonable size for a certificate (basic sanity check) + if len(webhook.ClientConfig.CABundle) < 100 { + return fmt.Errorf("mutating webhook %s has suspiciously short CA bundle (%d bytes)", webhook.Name, len(webhook.ClientConfig.CABundle)) + } + } + return nil +} + +// checkValidatingWebhookCABundleInjection Check for CA Bundle Injection/Presence +func checkValidatingWebhookCABundleInjection(ctx context.Context, c client.Client, webhookConfigName string) error { + var vwhc admissionv1.ValidatingWebhookConfiguration + namespacedName := types.NamespacedName{Name: webhookConfigName} // WebhookConfig is cluster-scoped + + if err := c.Get(ctx, namespacedName, &vwhc); err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("validating webhook configuration %s not found", webhookConfigName) + } + return err + } + + if len(vwhc.Webhooks) == 0 { + return fmt.Errorf("validating webhook configuration %s has no webhooks", webhookConfigName) + } + + for _, webhook := range vwhc.Webhooks { + if len(webhook.ClientConfig.CABundle) == 0 { + return fmt.Errorf("validating webhook %s is missing the CA bundle", webhook.Name) + } + // Validate it's a reasonable size for a certificate (basic sanity check) + if len(webhook.ClientConfig.CABundle) < 100 { + return fmt.Errorf("validating webhook %s has suspiciously short CA bundle (%d bytes)", webhook.Name, len(webhook.ClientConfig.CABundle)) + } + } + return nil +} + +// checkCertManagerCertificate Check for cert-manager Certificate readiness (optional, only used when cert-manager is enabled) +func checkCertManagerCertificate(ctx context.Context, c client.Client, namespace, certificateName string) error { + certNamespacedName := types.NamespacedName{ + Name: certificateName, + Namespace: namespace, + } + + var certificate certmgrv1.Certificate + if err := c.Get(ctx, certNamespacedName, &certificate); err != nil { + if errors.IsNotFound(err) { + // Certificate resource not found, it hasn't been created yet. + return fmt.Errorf("certificate resource %s/%s not found", namespace, certificateName) + } + return err // Other error + } + + // Check the Certificate's Status Conditions + for _, condition := range certificate.Status.Conditions { + if condition.Type == certmgrv1.CertificateConditionReady { + if condition.Status == certmgrv1meta.ConditionTrue { + // The Certificate is Ready, meaning the backing Secret has been created and populated. + return nil + } + // Certificate is not ready, check the reason for debugging/logging + return fmt.Errorf("certificate %s is not ready: Reason: %s, Message: %s", + certificateName, condition.Reason, condition.Message) + } + } + + // If the Ready condition is not present at all, assume not ready yet. + return fmt.Errorf("certificate %s status conditions do not yet include Ready type", certificateName) +} diff --git a/cmd/main_test.go b/cmd/main_test.go index e15cfc6c..a870efce 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -82,7 +82,7 @@ func TestMain(m *testing.M) { // Note that you must have the required binaries setup under the bin directory to perform // the tests directly. When we run make test it will be setup and used automatically. BinaryAssetsDirectory: filepath.Join("..", "bin", "k8s", - fmt.Sprintf("1.34.1-%s-%s", stdruntime.GOOS, stdruntime.GOARCH)), + fmt.Sprintf("1.35.0-%s-%s", stdruntime.GOOS, stdruntime.GOARCH)), WebhookInstallOptions: envtest.WebhookInstallOptions{ Paths: []string{filepath.Join("..", "config", "webhook")}, diff --git a/go.mod b/go.mod index d103ff40..2ac3c49a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/newrelic/k8s-agents-operator go 1.25.6 require ( + github.com/cert-manager/cert-manager v1.19.2 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 github.com/google/go-cmp v0.7.0 @@ -16,28 +17,28 @@ require ( k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 k8s.io/klog/v2 v2.130.1 - k8s.io/utils v0.0.0-20251222190033-383b50a9004e sigs.k8s.io/controller-runtime v0.22.4 ) require ( cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect @@ -45,57 +46,59 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.9.0 // indirect + golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.38.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.8 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/apiserver v0.34.1 // indirect k8s.io/component-base v0.34.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + k8s.io/utils v0.0.0-20251222190033-383b50a9004e // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect + sigs.k8s.io/gateway-api v1.4.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 1e7aad1d..2d0acf85 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,16 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cert-manager/cert-manager v1.19.2 h1:jSprN1h5pgNDSl7HClAmIzXuTxic/5FXJ32kbQHqjlM= +github.com/cert-manager/cert-manager v1.19.2/go.mod h1:e9NzLtOKxTw7y99qLyWGmPo6mrC1Nh0EKKcMkRfK+GE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -17,10 +19,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -42,12 +44,14 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -69,8 +73,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -89,8 +93,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -103,18 +107,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= -github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/openshift/api v0.0.0-20251214014457-bfa868a22401 h1:goMf6pBtRFSQaVElFk6K+GIAqnv7O84p7PJHH6pDz/E= -github.com/openshift/api v0.0.0-20251214014457-bfa868a22401/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= -github.com/openshift/api v0.0.0-20251223163548-3f584b29ee4a h1:lz22938uOBlzTHjGpobGeVWkcxGu6fDQ7oZWheClTHE= -github.com/openshift/api v0.0.0-20251223163548-3f584b29ee4a/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= github.com/openshift/api v0.0.0-20260126183958-606bd613f9f7 h1:96rhgJpWlWzKEslMd6aYFMixV9vQVY32M71JcO4Gzn0= github.com/openshift/api v0.0.0-20260126183958-606bd613f9f7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -122,24 +118,24 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -165,24 +161,24 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -196,8 +192,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= -golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= @@ -208,8 +204,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -226,8 +222,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= +golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -238,16 +234,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -276,10 +274,12 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251222190033-383b50a9004e h1:cdvXqyVPudW9BZL5+lPjMedlEHJDVMnE6lzvcQaC5UE= k8s.io/utils v0.0.0-20251222190033-383b50a9004e/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= +sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/apm/helper_test.go b/internal/apm/helper_test.go index f9f83bee..56d89423 100644 --- a/internal/apm/helper_test.go +++ b/internal/apm/helper_test.go @@ -2,6 +2,7 @@ package apm import ( "context" + "encoding/json" "strings" "testing" @@ -275,3 +276,155 @@ func TestGenerateContainerName(t *testing.T) { assert.Equal(t, "test-272f74c", generateContainerName("test"+strings.Repeat("-", 60))) assert.Equal(t, "test"+strings.Repeat("x", 51)+"-58def81", generateContainerName("test"+strings.Repeat("x", 60))) } + +func TestSetPodAnnotationFromInstrumentationVersion(t *testing.T) { + tests := []struct { + name string + pod corev1.Pod + instrumentation current.Instrumentation + expectedAnnotation string + expectedErrContains string + }{ + { + name: "pod with no annotations", + pod: corev1.Pod{}, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid-123", + Generation: 1, + }, + }, + expectedAnnotation: `{"default/test-inst":"test-uid-123/1"}`, + }, + { + name: "pod with empty annotations map", + pod: corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid-456", + Generation: 2, + }, + }, + expectedAnnotation: `{"default/test-inst":"test-uid-456/2"}`, + }, + { + name: "pod with existing annotation - adding new instrumentation", + pod: corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/existing-inst":"existing-uid/5"}`, + }, + }, + }, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-inst", + Namespace: "default", + UID: "new-uid-789", + Generation: 3, + }, + }, + expectedAnnotation: `{"default/existing-inst":"existing-uid/5","default/new-inst":"new-uid-789/3"}`, + }, + { + name: "pod with existing annotation - updating same instrumentation", + pod: corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"old-uid/1"}`, + }, + }, + }, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "new-uid-999", + Generation: 5, + }, + }, + expectedAnnotation: `{"default/test-inst":"new-uid-999/5"}`, + }, + { + name: "pod with multiple existing instrumentations from different namespaces", + pod: corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/inst1":"uid1/1","kube-system/inst2":"uid2/2"}`, + }, + }, + }, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "inst3", + Namespace: "monitoring", + UID: "uid3", + Generation: 3, + }, + }, + expectedAnnotation: `{"default/inst1":"uid1/1","kube-system/inst2":"uid2/2","monitoring/inst3":"uid3/3"}`, + }, + { + name: "pod with invalid JSON in annotation", + pod: corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `invalid json`, + }, + }, + }, + instrumentation: current.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 1, + }, + }, + expectedErrContains: "failed to unmarshal instrumentation version annotation", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := setPodAnnotationFromInstrumentationVersion(&test.pod, test.instrumentation) + + if test.expectedErrContains != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.expectedErrContains) + } + if !strings.Contains(err.Error(), test.expectedErrContains) { + t.Errorf("expected error containing %q, got %q", test.expectedErrContains, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + actualAnnotation := test.pod.Annotations[instrumentationVersionAnnotation] + + // Parse both JSON strings and compare as maps to handle different ordering + var expectedMap, actualMap map[string]string + if err := json.Unmarshal([]byte(test.expectedAnnotation), &expectedMap); err != nil { + t.Fatalf("failed to parse expected annotation: %v", err) + } + if err := json.Unmarshal([]byte(actualAnnotation), &actualMap); err != nil { + t.Fatalf("failed to parse actual annotation: %v", err) + } + + if diff := cmp.Diff(expectedMap, actualMap); diff != "" { + t.Errorf("annotation mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/instrumentation/health.go b/internal/instrumentation/health.go index 53b1d3fb..9bd0c6cc 100644 --- a/internal/instrumentation/health.go +++ b/internal/instrumentation/health.go @@ -78,18 +78,19 @@ type event struct { // instrumentationMetric contains a copy(pointer to the copy) of the instrumentation, a copy(shared pointer copy) of // each matching pod metric (pod + health) along with the aggregated summary of the health of all the pod metric health type instrumentationMetric struct { - instrumentationID string - instrumentation *current.Instrumentation - podMetrics []*podMetric - doneCh chan struct{} - podsMatching int64 - podsInjected int64 - podsNotReady int64 - podsOutdated int64 - podsHealthy int64 - podsUnhealthy int64 - unhealthyPods []current.UnhealthyPodError - entityGUIDs []string + instrumentationID string + instrumentation *current.Instrumentation + podMetrics []*podMetric + healthCheckEnabled bool // whether to perform health checks for this instrumentation + doneCh chan struct{} + podsMatching int64 + podsInjected int64 + podsNotReady int64 + podsOutdated int64 + podsHealthy int64 + podsUnhealthy int64 + unhealthyPods []current.UnhealthyPodError + entityGUIDs []string } // resolve marks the instrumentation metric done. anything waiting via `wait` will continue @@ -352,7 +353,7 @@ func (m *HealthMonitor) resourceQueueEvent(ctx context.Context, ev event) { } instrumentationMetrics := m.getInstrumentationMetrics(ctx, podMetrics) if len(instrumentationMetrics) == 0 { - logger.V(1).Info("triggered a health check, but there's nothing to report the health to. No instrumentations with a configured health agent") + logger.V(1).Info("triggered a health check, but there's nothing to report to. No instrumentations found") return } logger.V(1).Info("trigger health check") @@ -424,10 +425,15 @@ func (m *HealthMonitor) instrumentationMetricsQueueEvent(ctx context.Context, ev func (m *HealthMonitor) instrumentationMetricQueueEvent(ctx context.Context, event *instrumentationMetric) { for _, eventPodMetrics := range event.podMetrics { - // wait for pod metrics (health) to be collected - _ = eventPodMetrics.wait(ctx) + // Only wait for health checks if HealthAgent is configured + if event.healthCheckEnabled { + // wait for pod metrics (health) to be collected + _ = eventPodMetrics.wait(ctx) + } + event.podsMatching++ - if !m.isPodInstrumented(eventPodMetrics.pod) { + // Check if pod has been instrumented (regardless of health agent) + if !m.hasInstrumentationAnnotation(eventPodMetrics.pod) { continue } if m.isPodOutdated(eventPodMetrics.pod, event.instrumentation) { @@ -439,33 +445,47 @@ func (m *HealthMonitor) instrumentationMetricQueueEvent(ctx context.Context, eve continue } - var entityGUIDs []string - healthy := true - var unhealthyPods []current.UnhealthyPodError - for _, health := range eventPodMetrics.healths { - if health.EntityGUID != "" { - entityGUIDs = append(entityGUIDs, health.EntityGUID) - } - healthy = healthy && health.Healthy - if !health.Healthy { - unhealthyPods = append(unhealthyPods, current.UnhealthyPodError{ - Pod: eventPodMetrics.podID, - LastError: health.LastError, - }) - } - } - event.entityGUIDs = entityGUIDs - event.unhealthyPods = unhealthyPods - if healthy { - event.podsHealthy++ - } else { - event.podsUnhealthy++ + // Only process health check results if HealthAgent is configured + if !event.healthCheckEnabled { + continue } + m.processHealthCheckResults(event, eventPodMetrics) } // send our instrumentation metrics off to be persisted _ = m.instrumentationMetricPersistQueue.Add(ctx, event) } +func (m *HealthMonitor) processHealthCheckResults(event *instrumentationMetric, eventPodMetrics *podMetric) { + var entityGUIDs []string + healthy := true + var unhealthyPods []current.UnhealthyPodError + hasHealthData := len(eventPodMetrics.healths) > 0 + + for _, health := range eventPodMetrics.healths { + if health.EntityGUID != "" { + entityGUIDs = append(entityGUIDs, health.EntityGUID) + } + healthy = healthy && health.Healthy + if !health.Healthy { + unhealthyPods = append(unhealthyPods, current.UnhealthyPodError{ + Pod: eventPodMetrics.podID, + LastError: health.LastError, + }) + } + } + event.entityGUIDs = entityGUIDs + event.unhealthyPods = unhealthyPods + // Only count as healthy/unhealthy if we have health data + if !hasHealthData { + return + } + if healthy { + event.podsHealthy++ + } else { + event.podsUnhealthy++ + } +} + func (m *HealthMonitor) instrumentationMetricPersistQueueEvent(ctx context.Context, event *instrumentationMetric) { // mark instrumentation metrics done once the status has been persisted (if required) defer event.resolve() @@ -513,10 +533,8 @@ func (m *HealthMonitor) getInstrumentationMetrics(ctx context.Context, podMetric var instrumentationMetrics = make([]*instrumentationMetric, len(m.instrumentations)) i := 0 for _, instrumentation := range m.instrumentations { - // skip instrumentation without the health agent configuration, because it's extra work without any benefit - if instrumentation.Spec.HealthAgent.IsEmpty() { - continue - } + // Track all instrumentations, but only perform health checks if HealthAgent is configured + healthCheckEnabled := !instrumentation.Spec.HealthAgent.IsEmpty() podSelector, err := metav1.LabelSelectorAsSelector(&instrumentation.Spec.PodLabelSelector) if err != nil { @@ -549,10 +567,11 @@ func (m *HealthMonitor) getInstrumentationMetrics(ctx context.Context, podMetric } instrumentationMetrics[i] = &instrumentationMetric{ - instrumentationID: types.NamespacedName{Namespace: instrumentation.Namespace, Name: instrumentation.Name}.String(), - instrumentation: instrumentation, - podMetrics: instPodMetrics, - doneCh: make(chan struct{}), + instrumentationID: types.NamespacedName{Namespace: instrumentation.Namespace, Name: instrumentation.Name}.String(), + instrumentation: instrumentation, + podMetrics: instPodMetrics, + healthCheckEnabled: healthCheckEnabled, + doneCh: make(chan struct{}), } i++ } @@ -618,6 +637,16 @@ func (m *HealthMonitor) isPodOutdated(pod *corev1.Pod, inst *current.Instrumenta } // isPodInstrumented check if a pod has been instrumented with the health sidecar +// hasInstrumentationAnnotation checks if a pod has been instrumented (has the instrumentation-versions annotation) +func (m *HealthMonitor) hasInstrumentationAnnotation(pod *corev1.Pod) bool { + if pod.Annotations == nil { + return false + } + _, ok := pod.Annotations[instrumentationVersionAnnotation] + return ok +} + +// isPodInstrumented checks if a pod has health agent instrumentation func (m *HealthMonitor) isPodInstrumented(pod *corev1.Pod) bool { if pod.Annotations == nil { return false diff --git a/internal/instrumentation/health_helpers_test.go b/internal/instrumentation/health_helpers_test.go new file mode 100644 index 00000000..04b418ce --- /dev/null +++ b/internal/instrumentation/health_helpers_test.go @@ -0,0 +1,539 @@ +package instrumentation + +import ( + "context" + "testing" + + "github.com/newrelic/k8s-agents-operator/internal/apm" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetHealthUrlsFromPod(t *testing.T) { + restartPolicyAlways := corev1.ContainerRestartPolicyAlways + restartPolicyOnFailure := corev1.ContainerRestartPolicyOnFailure + + tests := []struct { + name string + pod *corev1.Pod + expectedLen int + wantErr bool + errContains string + }{ + { + name: "pod with health sidecar", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--container", + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8080, + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + }, + expectedLen: 1, + wantErr: false, + }, + { + name: "pod without health sidecar", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "regular-init-container", + RestartPolicy: &restartPolicyAlways, + }, + }, + }, + }, + wantErr: true, + errContains: "health sidecar not found", + }, + { + name: "pod with sidecar but wrong restart policy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--container", + RestartPolicy: &restartPolicyOnFailure, + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8080, + }, + }, + }, + }, + }, + }, + wantErr: true, + errContains: "health sidecar not found", + }, + { + name: "pod with health sidecar but no ports", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--container", + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{}, + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + }, + wantErr: true, + errContains: "health sidecar missing exposed ports", + }, + { + name: "pod with health sidecar but too many ports", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--container", + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + {ContainerPort: 8081}, + }, + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + }, + wantErr: true, + errContains: "health sidecar has too many exposed ports", + }, + { + name: "pod with multiple health sidecars", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--java", + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + }, + }, + { + Name: "nri-health--python", + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8081}, + }, + }, + }, + }, + Status: corev1.PodStatus{ + PodIP: "10.0.0.1", + }, + }, + expectedLen: 2, + wantErr: false, + }, + { + name: "pod with init container but no restart policy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health--container", + RestartPolicy: nil, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + }, + }, + }, + }, + }, + wantErr: true, + errContains: "health sidecar not found", + }, + { + name: "pod with short container name starting with nri-health", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "nri-health", // Only 10 chars, need 12+ + RestartPolicy: &restartPolicyAlways, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + }, + }, + }, + }, + }, + wantErr: true, + errContains: "health sidecar not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &HealthMonitor{} + urls, err := m.getHealthUrlsFromPod(tt.pod) + + // Check error expectations + if tt.wantErr && err == nil { + t.Errorf("expected error containing %q, got nil", tt.errContains) + return + } + if tt.wantErr && tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error containing %q, got %q", tt.errContains, err.Error()) + return + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + return + } + // Check URL count + if !tt.wantErr && len(urls) != tt.expectedLen { + t.Errorf("expected %d URLs, got %d", tt.expectedLen, len(urls)) + } + }) + } +} + +func TestIsPodReady(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + expected bool + }{ + { + name: "pod is running", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + expected: true, + }, + { + name: "pod is pending", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + expected: false, + }, + { + name: "pod is succeeded", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + }, + expected: false, + }, + { + name: "pod is failed", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + }, + expected: false, + }, + { + name: "pod is unknown", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodUnknown, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &HealthMonitor{} + result := m.isPodReady(tt.pod) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsPodInstrumented(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + expected bool + }{ + { + name: "pod with health instrumented annotation set to true", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "true", + }, + }, + }, + expected: true, + }, + { + name: "pod with health instrumented annotation set to false", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "false", + }, + }, + }, + expected: false, + }, + { + name: "pod without health instrumented annotation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + expected: false, + }, + { + name: "pod with nil annotations", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: nil, + }, + }, + expected: false, + }, + { + name: "pod with invalid boolean value", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "invalid", + }, + }, + }, + expected: false, + }, + { + name: "pod with health instrumented annotation set to 1", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "1", + }, + }, + }, + expected: true, + }, + { + name: "pod with health instrumented annotation set to 0", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "0", + }, + }, + }, + expected: false, + }, + { + name: "pod with health instrumented annotation set to TRUE (uppercase)", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + apm.HealthInstrumentedAnnotation: "TRUE", + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &HealthMonitor{} + result := m.isPodInstrumented(tt.pod) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGetPodMetrics(t *testing.T) { + tests := []struct { + name string + pods map[string]*corev1.Pod + expectedLen int + expectedIDs []string + }{ + { + name: "empty pods map", + pods: map[string]*corev1.Pod{}, + expectedLen: 0, + }, + { + name: "single pod", + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + }, + }, + }, + expectedLen: 1, + expectedIDs: []string{"default/pod1"}, + }, + { + name: "multiple pods", + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + }, + }, + "default/pod2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: "default", + }, + }, + "kube-system/pod3": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod3", + Namespace: "kube-system", + }, + }, + }, + expectedLen: 3, + expectedIDs: []string{"default/pod1", "default/pod2", "kube-system/pod3"}, + }, + { + name: "pod with special characters in name", + pods: map[string]*corev1.Pod{ + "my-namespace/my-pod-123-abc": { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-pod-123-abc", + Namespace: "my-namespace", + }, + }, + }, + expectedLen: 1, + expectedIDs: []string{"my-namespace/my-pod-123-abc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + m := &HealthMonitor{ + pods: tt.pods, + } + + podMetrics := m.getPodMetrics(ctx) + + // Verify length + if len(podMetrics) != tt.expectedLen { + t.Errorf("expected %d pod metrics, got %d", tt.expectedLen, len(podMetrics)) + } + + // Verify each pod metric + for _, pm := range podMetrics { + if pm.pod == nil { + t.Error("pod metric has nil pod") + } + if pm.podID == "" { + t.Error("pod metric has empty podID") + } + if pm.doneCh == nil { + t.Error("pod metric has nil doneCh") + } + + // Verify podID matches expected format + expectedID := pm.pod.Namespace + "/" + pm.pod.Name + if pm.podID != expectedID { + t.Errorf("expected podID %q, got %q", expectedID, pm.podID) + } + } + + // Verify all expected IDs are present + if len(tt.expectedIDs) > 0 { + foundIDs := make(map[string]bool) + for _, pm := range podMetrics { + foundIDs[pm.podID] = true + } + + for _, expectedID := range tt.expectedIDs { + if !foundIDs[expectedID] { + t.Errorf("expected to find podID %q but it was not present", expectedID) + } + } + } + }) + } +} + +// containsString is a helper to check if a string contains a substring +func containsString(s, substr string) bool { + if len(s) == 0 || len(substr) == 0 { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/instrumentation/health_option2_test.go b/internal/instrumentation/health_option2_test.go new file mode 100644 index 00000000..5bd6327f --- /dev/null +++ b/internal/instrumentation/health_option2_test.go @@ -0,0 +1,957 @@ +package instrumentation + +import ( + "context" + "testing" + + "github.com/newrelic/k8s-agents-operator/api/current" + "github.com/newrelic/k8s-agents-operator/internal/apm" + "github.com/newrelic/k8s-agents-operator/internal/instrumentation/util/worker" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// verifyMixedHealthConfigs is a helper to verify health check configs for multiple instrumentations +func verifyMixedHealthConfigs(t *testing.T, instMetrics []*instrumentationMetric) { + t.Helper() + for _, im := range instMetrics { + inst := im.instrumentation + switch inst.Name { + case "inst-no-health": + if im.healthCheckEnabled { + t.Errorf("instrumentation 'inst-no-health' should have healthCheckEnabled=false") + } + case "inst-with-health": + if !im.healthCheckEnabled { + t.Errorf("instrumentation 'inst-with-health' should have healthCheckEnabled=true") + } + } + } +} + +// TestGetInstrumentationMetrics_WithoutHealthAgent tests that instrumentations +// without HealthAgent configured are still tracked in metrics (Option 2 implementation) +func TestGetInstrumentationMetrics_WithoutHealthAgent(t *testing.T) { + tests := []struct { + name string + instrumentations map[string]*current.Instrumentation + namespaces map[string]*corev1.Namespace + pods map[string]*corev1.Pod + expectedCount int + expectedHealthCheckEnable bool + }{ + { + name: "instrumentation without health agent is tracked", + instrumentations: map[string]*current.Instrumentation{ + "default/inst-no-health": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst-no-health", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + // No HealthAgent configured + HealthAgent: current.HealthAgent{}, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 1, + expectedHealthCheckEnable: false, + }, + { + name: "instrumentation with health agent has healthCheckEnabled=true", + instrumentations: map[string]*current.Instrumentation{ + "default/inst-with-health": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst-with-health", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + HealthAgent: current.HealthAgent{ + Image: "health-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 1, + expectedHealthCheckEnable: true, + }, + { + name: "multiple instrumentations with mixed health agent configs", + instrumentations: map[string]*current.Instrumentation{ + "default/inst-no-health": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst-no-health", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "app1"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + // No HealthAgent + HealthAgent: current.HealthAgent{}, + }, + }, + "default/inst-with-health": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst-with-health", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "app2"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "python", + Image: "python-agent:latest", + }, + HealthAgent: current.HealthAgent{ + Image: "health-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "app1"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + "default/pod2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: "default", + Labels: map[string]string{"app": "app2"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 2, + // Will check each individually + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + m := &HealthMonitor{ + instrumentations: tt.instrumentations, + namespaces: tt.namespaces, + } + + // Create pod metrics + podMetrics := make([]*podMetric, 0, len(tt.pods)) + for _, pod := range tt.pods { + podMetrics = append(podMetrics, &podMetric{ + pod: pod, + }) + } + + // Get instrumentation metrics + instMetrics := m.getInstrumentationMetrics(ctx, podMetrics) + + // Verify count + if len(instMetrics) != tt.expectedCount { + t.Errorf("expected %d instrumentation metrics, got %d", tt.expectedCount, len(instMetrics)) + } + + // For single instrumentation tests, verify healthCheckEnabled + if tt.expectedCount == 1 { + if instMetrics[0].healthCheckEnabled != tt.expectedHealthCheckEnable { + t.Errorf("expected healthCheckEnabled=%v, got %v", tt.expectedHealthCheckEnable, instMetrics[0].healthCheckEnabled) + } + } + + // For multiple instrumentations test, verify each one + if tt.name == "multiple instrumentations with mixed health agent configs" { + verifyMixedHealthConfigs(t, instMetrics) + } + }) + } +} + +// TestInstrumentationMetricQueueEvent_HealthCheckEnabled tests that the health check +// flag properly controls whether health metrics are collected (Option 2 implementation) +func TestInstrumentationMetricQueueEvent_HealthCheckEnabled(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + healthCheckEnabled bool + podHasAnnotation bool + podReady bool + healths []Health + expectedPodsMatching int64 + expectedPodsInjected int64 + expectedPodsHealthy int64 + expectedPodsUnhealthy int64 + expectedPodsNotReady int64 + expectedPodsOutdated int64 + }{ + { + name: "pod tracked without health agent", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: false, + podHasAnnotation: true, + podReady: true, + healths: []Health{}, // No health checks + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, // Not tracked when healthCheckEnabled=false + expectedPodsUnhealthy: 0, + expectedPodsNotReady: 0, + expectedPodsOutdated: 0, + }, + { + name: "pod tracked with health agent - healthy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: true, + podHasAnnotation: true, + podReady: true, + healths: []Health{ + { + Healthy: true, + EntityGUID: "entity-guid-1", + }, + }, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 1, + expectedPodsUnhealthy: 0, + expectedPodsNotReady: 0, + expectedPodsOutdated: 0, + }, + { + name: "pod tracked with health agent - unhealthy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: true, + podHasAnnotation: true, + podReady: true, + healths: []Health{ + { + Healthy: false, + LastError: "health check failed", + }, + }, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 1, + expectedPodsNotReady: 0, + expectedPodsOutdated: 0, + }, + { + name: "pod not ready - no health metrics", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + healthCheckEnabled: true, + podHasAnnotation: true, + podReady: false, + healths: []Health{}, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, + expectedPodsNotReady: 1, + expectedPodsOutdated: 0, + }, + { + name: "pod without instrumentation annotation - not injected", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: false, + podHasAnnotation: false, + podReady: true, + healths: []Health{}, + expectedPodsMatching: 1, + expectedPodsInjected: 0, // Not injected + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, + expectedPodsNotReady: 0, + expectedPodsOutdated: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create instrumentation + inst := ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 1, + }, + } + + // Create pod metric + pm := &podMetric{ + pod: tt.pod, + healths: tt.healths, + doneCh: make(chan struct{}), + } + close(pm.doneCh) // Mark as done so wait() doesn't block + + // Create instrumentation metric + event := &instrumentationMetric{ + instrumentationID: "default/test-inst", + instrumentation: inst, + podMetrics: []*podMetric{pm}, + healthCheckEnabled: tt.healthCheckEnabled, + } + + // Create monitor with required queue + m := &HealthMonitor{ + instrumentationMetricPersistQueue: worker.NewManyWorkers(1, 0, func(ctx context.Context, data any) { + // No-op worker for test + }), + } + + // Process the event + m.instrumentationMetricQueueEvent(ctx, event) + + // Verify results + if event.podsMatching != tt.expectedPodsMatching { + t.Errorf("expected podsMatching=%d, got %d", tt.expectedPodsMatching, event.podsMatching) + } + if event.podsInjected != tt.expectedPodsInjected { + t.Errorf("expected podsInjected=%d, got %d", tt.expectedPodsInjected, event.podsInjected) + } + if event.podsHealthy != tt.expectedPodsHealthy { + t.Errorf("expected podsHealthy=%d, got %d", tt.expectedPodsHealthy, event.podsHealthy) + } + if event.podsUnhealthy != tt.expectedPodsUnhealthy { + t.Errorf("expected podsUnhealthy=%d, got %d", tt.expectedPodsUnhealthy, event.podsUnhealthy) + } + if event.podsNotReady != tt.expectedPodsNotReady { + t.Errorf("expected podsNotReady=%d, got %d", tt.expectedPodsNotReady, event.podsNotReady) + } + if event.podsOutdated != tt.expectedPodsOutdated { + t.Errorf("expected podsOutdated=%d, got %d", tt.expectedPodsOutdated, event.podsOutdated) + } + }) + } +} + +// TestGetInstrumentationMetrics_EdgeCases tests edge cases in instrumentation metrics collection +func TestGetInstrumentationMetrics_EdgeCases(t *testing.T) { + tests := []struct { + name string + instrumentations map[string]*current.Instrumentation + namespaces map[string]*corev1.Namespace + pods map[string]*corev1.Pod + expectedCount int + expectedMatchingCounts map[string]int // instrumentation name -> expected matching pods + }{ + { + name: "multiple instrumentations matching same pod", + instrumentations: map[string]*current.Instrumentation{ + "default/inst1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst1", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + }, + }, + "default/inst2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst2", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, // Same selector + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "python", + Image: "python-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 2, // Both instrumentations should have metrics + expectedMatchingCounts: map[string]int{ + "inst1": 1, + "inst2": 1, + }, + }, + { + name: "pod in namespace not in monitor's namespace map", + instrumentations: map[string]*current.Instrumentation{ + "default/inst1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst1", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + // "other-ns" is not in the map + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + "other-ns/pod2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: "other-ns", // Namespace not in monitor + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 1, + expectedMatchingCounts: map[string]int{ + "inst1": 1, // Only pod1 from default namespace should match + }, + }, + { + name: "namespace selector filters pods", + instrumentations: map[string]*current.Instrumentation{ + "default/inst1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst1", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "prod-ns": { + ObjectMeta: metav1.ObjectMeta{ + Name: "prod-ns", + Labels: map[string]string{"env": "prod"}, + }, + }, + "dev-ns": { + ObjectMeta: metav1.ObjectMeta{ + Name: "dev-ns", + Labels: map[string]string{"env": "dev"}, + }, + }, + }, + pods: map[string]*corev1.Pod{ + "prod-ns/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "prod-ns", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + "dev-ns/pod2": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + Namespace: "dev-ns", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 1, + expectedMatchingCounts: map[string]int{ + "inst1": 1, // Only pod1 from prod-ns should match + }, + }, + { + name: "no pods match instrumentation selectors", + instrumentations: map[string]*current.Instrumentation{ + "default/inst1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "inst1", + Namespace: "default", + }, + Spec: current.InstrumentationSpec{ + PodLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "nonexistent"}, + }, + NamespaceLabelSelector: metav1.LabelSelector{}, + Agent: current.Agent{ + Language: "java", + Image: "java-agent:latest", + }, + }, + }, + }, + namespaces: map[string]*corev1.Namespace{ + "default": { + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + }, + }, + pods: map[string]*corev1.Pod{ + "default/pod1": { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + }, + expectedCount: 1, + expectedMatchingCounts: map[string]int{ + "inst1": 0, // No pods should match + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + m := &HealthMonitor{ + instrumentations: tt.instrumentations, + namespaces: tt.namespaces, + } + + // Create pod metrics + podMetrics := make([]*podMetric, 0, len(tt.pods)) + for _, pod := range tt.pods { + podMetrics = append(podMetrics, &podMetric{ + pod: pod, + }) + } + + // Get instrumentation metrics + instMetrics := m.getInstrumentationMetrics(ctx, podMetrics) + + // Verify count + if len(instMetrics) != tt.expectedCount { + t.Errorf("expected %d instrumentation metrics, got %d", tt.expectedCount, len(instMetrics)) + } + + // Verify matching counts + for _, im := range instMetrics { + instName := im.instrumentation.Name + expectedCount, ok := tt.expectedMatchingCounts[instName] + if !ok { + continue + } + actualCount := len(im.podMetrics) + if actualCount != expectedCount { + t.Errorf("instrumentation %q: expected %d matching pods, got %d", instName, expectedCount, actualCount) + } + } + }) + } +} + +// TestInstrumentationMetricQueueEvent_EdgeCases tests edge cases in metric queue event processing +func TestInstrumentationMetricQueueEvent_EdgeCases(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + healthCheckEnabled bool + healths []Health + expectedPodsMatching int64 + expectedPodsInjected int64 + expectedPodsHealthy int64 + expectedPodsUnhealthy int64 + expectedPodsOutdated int64 + }{ + { + name: "pod with outdated instrumentation version", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"old-uid/1"}`, // Different UID + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: false, + healths: []Health{}, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, + expectedPodsOutdated: 1, // Should be marked as outdated + }, + { + name: "pod with outdated generation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/0"}`, // Old generation + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: false, + healths: []Health{}, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, + expectedPodsOutdated: 1, // Should be marked as outdated + }, + { + name: "pod with current version", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, // Current + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: false, + healths: []Health{}, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, + expectedPodsOutdated: 0, // Should NOT be marked as outdated + }, + { + name: "pod with multiple health URLs - all healthy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: true, + healths: []Health{ + {Healthy: true, EntityGUID: "entity-1"}, + {Healthy: true, EntityGUID: "entity-2"}, + }, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 1, // All healthy + expectedPodsUnhealthy: 0, + expectedPodsOutdated: 0, + }, + { + name: "pod with multiple health URLs - one unhealthy", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: true, + healths: []Health{ + {Healthy: true, EntityGUID: "entity-1"}, + {Healthy: false, LastError: "check failed"}, + }, + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 1, // One unhealthy + expectedPodsOutdated: 0, + }, + { + name: "pod with empty health results", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/1"}`, + apm.HealthInstrumentedAnnotation: "true", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + healthCheckEnabled: true, + healths: []Health{}, // Empty - no health data yet + expectedPodsMatching: 1, + expectedPodsInjected: 1, + expectedPodsHealthy: 0, + expectedPodsUnhealthy: 0, // Not counted as unhealthy, just no data + expectedPodsOutdated: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + // Create instrumentation + inst := ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 1, + }, + } + + // Create pod metric + pm := &podMetric{ + pod: tt.pod, + healths: tt.healths, + doneCh: make(chan struct{}), + } + close(pm.doneCh) // Mark as done so wait() doesn't block + + // Create instrumentation metric + event := &instrumentationMetric{ + instrumentationID: "default/test-inst", + instrumentation: inst, + podMetrics: []*podMetric{pm}, + healthCheckEnabled: tt.healthCheckEnabled, + doneCh: make(chan struct{}), + } + + // Create monitor with required queue + m := &HealthMonitor{ + instrumentationMetricPersistQueue: worker.NewManyWorkers(1, 0, func(ctx context.Context, data any) { + // No-op worker for test + }), + } + + // Process the event + m.instrumentationMetricQueueEvent(ctx, event) + + // Verify results + if event.podsMatching != tt.expectedPodsMatching { + t.Errorf("expected podsMatching=%d, got %d", tt.expectedPodsMatching, event.podsMatching) + } + if event.podsInjected != tt.expectedPodsInjected { + t.Errorf("expected podsInjected=%d, got %d", tt.expectedPodsInjected, event.podsInjected) + } + if event.podsHealthy != tt.expectedPodsHealthy { + t.Errorf("expected podsHealthy=%d, got %d", tt.expectedPodsHealthy, event.podsHealthy) + } + if event.podsUnhealthy != tt.expectedPodsUnhealthy { + t.Errorf("expected podsUnhealthy=%d, got %d", tt.expectedPodsUnhealthy, event.podsUnhealthy) + } + if event.podsOutdated != tt.expectedPodsOutdated { + t.Errorf("expected podsOutdated=%d, got %d", tt.expectedPodsOutdated, event.podsOutdated) + } + }) + } +} diff --git a/internal/instrumentation/health_test.go b/internal/instrumentation/health_test.go index 33ab45aa..6290c6c9 100644 --- a/internal/instrumentation/health_test.go +++ b/internal/instrumentation/health_test.go @@ -121,11 +121,14 @@ func TestHealthMonitor(t *testing.T) { "newrelic": {ObjectMeta: metav1.ObjectMeta{Name: "newrelic"}}, }, pods: map[string]*corev1.Pod{ - "default/pod0": {ObjectMeta: metav1.ObjectMeta{Name: "pod0", Namespace: "default", Annotations: map[string]string{"newrelic.com/apm-health": "true"}}}, + "default/pod0": {ObjectMeta: metav1.ObjectMeta{Name: "pod0", Namespace: "default", Annotations: map[string]string{ + "newrelic.com/apm-health": "true", + instrumentationVersionAnnotation: `{"newrelic/instrumentation0":"old-uid/1"}`, // Outdated UID + }}}, }, instrumentations: map[string]*current.Instrumentation{ "newrelic/instrumentation0": { - ObjectMeta: metav1.ObjectMeta{Name: "instrumentation0", Namespace: "newrelic"}, + ObjectMeta: metav1.ObjectMeta{Name: "instrumentation0", Namespace: "newrelic", UID: "01234567-89ab-cdef-0123-456789abcdef", Generation: 55}, Spec: current.InstrumentationSpec{HealthAgent: current.HealthAgent{Image: "health"}}, }, }, @@ -559,3 +562,263 @@ func TestIsDiff(t *testing.T) { }) } } + +func TestHasInstrumentationAnnotation(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + expected bool + }{ + { + name: "pod with no annotations", + pod: &corev1.Pod{}, + expected: false, + }, + { + name: "pod with empty annotations map", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + expected: false, + }, + { + name: "pod with instrumentation version annotation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"uid/1"}`, + }, + }, + }, + expected: true, + }, + { + name: "pod with other annotations but not instrumentation version", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "some-other-annotation": "value", + }, + }, + }, + expected: false, + }, + { + name: "pod with instrumentation version and other annotations", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test":"uid/1"}`, + "other-annotation": "value", + }, + }, + }, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + m := &HealthMonitor{} + actual := m.hasInstrumentationAnnotation(test.pod) + if actual != test.expected { + t.Errorf("expected %v, got %v", test.expected, actual) + } + }) + } +} + +func TestIsPodOutdated(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + instrumentation *current.Instrumentation + expected bool + }{ + { + name: "pod with no annotations", + pod: &corev1.Pod{}, + instrumentation: ¤t.Instrumentation{}, + expected: true, + }, + { + name: "pod with empty annotations map", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + instrumentation: ¤t.Instrumentation{}, + expected: true, + }, + { + name: "pod without instrumentation version annotation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "other-annotation": "value", + }, + }, + }, + instrumentation: ¤t.Instrumentation{}, + expected: true, + }, + { + name: "pod with invalid JSON in version annotation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `invalid json`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{}, + expected: true, + }, + { + name: "pod missing specific instrumentation key", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/other-inst":"uid/1"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 1, + }, + }, + expected: true, + }, + { + name: "pod with matching UID and Generation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/5"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 5, + }, + }, + expected: false, + }, + { + name: "pod with outdated UID", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"old-uid/5"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "new-uid", + Generation: 5, + }, + }, + expected: true, + }, + { + name: "pod with outdated Generation", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/3"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 5, + }, + }, + expected: true, + }, + { + name: "pod with newer Generation (shouldn't happen but handle gracefully)", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/test-inst":"test-uid/10"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 5, + }, + }, + expected: true, + }, + { + name: "pod with multiple instrumentations, one matching", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"default/inst1":"uid1/1","default/test-inst":"test-uid/3","default/inst2":"uid2/2"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 3, + }, + }, + expected: false, + }, + { + name: "pod with instrumentation from different namespace", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + instrumentationVersionAnnotation: `{"kube-system/test-inst":"test-uid/5"}`, + }, + }, + }, + instrumentation: ¤t.Instrumentation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inst", + Namespace: "default", + UID: "test-uid", + Generation: 5, + }, + }, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + m := &HealthMonitor{} + actual := m.isPodOutdated(test.pod, test.instrumentation) + if actual != test.expected { + t.Errorf("expected %v, got %v", test.expected, actual) + } + }) + } +} diff --git a/internal/webhook/podmutationhandler_suite_test.go b/internal/webhook/podmutationhandler_suite_test.go index feda0982..8f50cc19 100644 --- a/internal/webhook/podmutationhandler_suite_test.go +++ b/internal/webhook/podmutationhandler_suite_test.go @@ -92,7 +92,7 @@ func TestMain(m *testing.M) { // Note that you must have the required binaries setup under the bin directory to perform // the tests directly. When we run make test it will be setup and used automatically. BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", - fmt.Sprintf("1.34.1-%s-%s", stdruntime.GOOS, stdruntime.GOARCH)), + fmt.Sprintf("1.35.0-%s-%s", stdruntime.GOOS, stdruntime.GOARCH)), WebhookInstallOptions: envtest.WebhookInstallOptions{ Paths: []string{filepath.Join("..", "..", "config", "webhook")}, diff --git a/tests/e2e/common-functions.sh b/tests/e2e/common-functions.sh new file mode 100644 index 00000000..c2bb6c71 --- /dev/null +++ b/tests/e2e/common-functions.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Common functions for e2e tests +# Source this file from individual test scripts + +# Wait for operator to be fully ready +function wait_for_operator_ready() { + local namespace=$1 + + echo "🔄 Waiting for operator pod to be ready" + # Wait for pod to be running + kubectl wait --timeout=60s --for=jsonpath='{.status.phase}'=Running \ + -n ${namespace} -l="app.kubernetes.io/instance=k8s-agents-operator" pod + + # Wait for pod to be fully ready (all containers ready) + kubectl wait --timeout=120s --for=condition=Ready \ + -n ${namespace} -l="app.kubernetes.io/instance=k8s-agents-operator" pod + + echo "🔄 Waiting for webhook service endpoints to be ready" + # Wait for endpointslices to exist + until kubectl get endpointslices -n ${namespace} \ + -l kubernetes.io/service-name=k8s-agents-operator-webhook-service 2>/dev/null | grep -q k8s-agents-operator; do + echo " Waiting for endpointslices to be created..." + sleep 2 + done + + # Wait for endpoints to be ready + kubectl wait -n ${namespace} \ + --selector=kubernetes.io/service-name=k8s-agents-operator-webhook-service \ + endpointslices --timeout=60s --for=jsonpath='{.endpoints[].conditions.ready}=true' + + # Wait for endpoints to be serving + kubectl wait -n ${namespace} \ + --selector=kubernetes.io/service-name=k8s-agents-operator-webhook-service \ + endpointslices --timeout=60s --for=jsonpath='{.endpoints[].conditions.serving}=true' + + # Verify webhook configurations are properly configured with CA bundles + echo "🔄 Verifying webhook configurations" + until kubectl get validatingwebhookconfigurations k8s-agents-operator-validation \ + -o jsonpath='{.webhooks[0].clientConfig.caBundle}' 2>/dev/null | grep -q '.'; do + echo " Waiting for validating webhook CA bundle to be injected..." + sleep 2 + done + + until kubectl get mutatingwebhookconfigurations k8s-agents-operator-mutation \ + -o jsonpath='{.webhooks[0].clientConfig.caBundle}' 2>/dev/null | grep -q '.'; do + echo " Waiting for mutating webhook CA bundle to be injected..." + sleep 2 + done + + echo "✅ Operator is ready" +} + +# Wait for instrumentations to be created and established +function wait_for_instrumentations() { + local operator_namespace=$1 + local instrumentation_files_pattern=$2 + + echo "🔄 Waiting for instrumentations to be established" + # Give the operator time to process the CRDs and set up watches + sleep 3 + + # Verify instrumentations were created successfully + for i in $(find $(dirname "$instrumentation_files_pattern") -maxdepth 1 -type f -name "$(basename "$instrumentation_files_pattern")"); do + inst_name=$(yq '.metadata.name' $i 2>/dev/null || grep 'name:' $i | head -1 | awk '{print $2}') + if [ -n "$inst_name" ]; then + echo " Verifying instrumentation: $inst_name" + until kubectl get instrumentation -n ${operator_namespace} $inst_name 2>/dev/null; do + echo " Waiting for instrumentation $inst_name to be created..." + sleep 1 + done + fi + done + + echo "✅ Instrumentations are established" +} + +# Wait for deployments and pods to be ready +function wait_for_apps_ready() { + local app_namespace=$1 + local apps_path=$2 + + echo "🔄 Waiting for app deployments to be available" + for deployment in $(find ${apps_path} -type f -name '*.yaml' -exec yq '. | select(.kind == "Deployment") | .metadata.name' {} \; 2>/dev/null); do + if [ -n "$deployment" ]; then + echo " Waiting for deployment: $deployment" + kubectl wait --timeout=300s --for=condition=Available \ + --namespace ${app_namespace} deployment/$deployment + fi + done + + echo "🔄 Waiting for all app pods to be ready" + for label in $(find ${apps_path} -type f -name '*.yaml' -exec yq '. | select(.kind == "Deployment") | .metadata.name' {} \; 2>/dev/null); do + if [ -n "$label" ]; then + echo " Waiting for pods with label app=$label" + # Wait for pod to exist first + local retries=30 + until kubectl get pod -n ${app_namespace} -l="app=$label" 2>/dev/null | grep -q "$label"; do + echo " Waiting for pod with label app=$label to be created..." + sleep 2 + retries=$((retries-1)) + if [ $retries -le 0 ]; then + echo " ❌ Timeout waiting for pod with label app=$label" + return 1 + fi + done + + # Wait for pod to be ready (not just running, but all containers ready) + kubectl wait --timeout=600s --for=condition=Ready \ + --namespace ${app_namespace} -l="app=$label" pod + + # Verify pod has init containers (instrumentation was applied) + local pod_name=$(kubectl get pod -n ${app_namespace} -l="app=$label" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -n "$pod_name" ]; then + local init_count=$(kubectl get pod -n ${app_namespace} $pod_name -o jsonpath='{.spec.initContainers}' 2>/dev/null | grep -o 'name' | wc -l | tr -d ' ') + echo " Pod $pod_name has $init_count init container(s)" + fi + fi + done + + echo "✅ All apps are ready and instrumented" +} diff --git a/tests/e2e/v1alpha2/apps/ruby_deployment.yaml b/tests/e2e/v1alpha2/apps/ruby_deployment.yaml index 2d238ecf..67875fa6 100644 --- a/tests/e2e/v1alpha2/apps/ruby_deployment.yaml +++ b/tests/e2e/v1alpha2/apps/ruby_deployment.yaml @@ -4,29 +4,86 @@ kind: ConfigMap metadata: name: rubyapp-cfgmap data: + Gemfile: "" + Gemfile.lock: | + GEM + specs: + + PLATFORMS + ruby + + DEPENDENCIES + + BUNDLED WITH + 1.17.2 main.rb: | require 'socket' + require 'logger' + + # Load New Relic agent if available (injected by operator) + begin + require 'newrelic_rpm' + rescue LoadError + puts "New Relic agent not available (not injected yet)" + end + + logger = Logger.new(STDOUT) + logger.level = Logger::DEBUG + class HttpServer - def initialize(port) - @server = TCPServer.new('127.0.0.1', port) + include NewRelic::Agent::Instrumentation::ControllerInstrumentation if defined?(NewRelic) + + def initialize(logger, port) + @server = TCPServer.new('0.0.0.0', port) + @logger = logger end + def accept_connection while session = @server.accept - request = session.readpartial(1024) - puts "peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected" - verb,path,proto = request.lines[0].split - scheme,ver = proto.split('/') - puts "requested #{path}" - session.write("HTTP/1.0 200 OK\r\n") - session.write("Host: rubyapp\r\n") - session.write("Connection: close\r\n") - session.write("\r\n") - session.write("hello world from ruby\n") - session.close + handle_request(session) + end + end + + def handle_request(session) + request = session.readpartial(1024) + @logger.info("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") + STDERR.puts("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") + + verb, path, proto = request.lines[0].split + scheme, ver = proto.split('/') + @logger.info("requested #{verb} #{path} #{scheme} #{ver}") + STDERR.puts("requested #{verb} #{path} #{scheme} #{ver}") + + # Set transaction name for New Relic + if defined?(NewRelic) + NewRelic::Agent.set_transaction_name("HttpServer/#{verb}#{path}") end + + session.write("HTTP/1.0 200 OK\r\n") + session.write("Host: rubyapp\r\n") + session.write("Connection: close\r\n") + session.write("\r\n") + session.write("hello world from ruby\n") + session.write("peer: #{session.peeraddr[3]}:#{session.peeraddr[1]}\n") + session.write("server: #{session.addr[3]}:#{session.addr[1]}\n") + session.close + rescue => e + @logger.error("Error handling request: #{e.message}") + STDERR.puts("Error handling request: #{e.message}") + session.close rescue nil + end + + # Instrument the handle_request method as a web transaction + if defined?(NewRelic) + add_transaction_tracer :handle_request, + category: :controller, + name: 'handle_request' end end - server = HttpServer.new(8080) + + logger.info("starting server") + STDERR.puts("starting server") + server = HttpServer.new(logger, 8080) server.accept_connection --- apiVersion: apps/v1 @@ -48,9 +105,16 @@ spec: - name: rubyapp image: ruby:3.3-alpine workingDir: /app + env: + - name: RUBYOPT + value: -W2 command: - - ruby - - main.rb + - sh + - -c + - | + gem install bundler + bundle install + ruby main.rb ports: - containerPort: 8080 volumeMounts: diff --git a/tests/e2e/v1alpha2/e2e-tests.sh b/tests/e2e/v1alpha2/e2e-tests.sh index e0b28af5..9b516890 100755 --- a/tests/e2e/v1alpha2/e2e-tests.sh +++ b/tests/e2e/v1alpha2/e2e-tests.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../common-functions.sh" + # Test cluster CLUSTER_NAME="" K8S_VERSION="" @@ -15,6 +19,9 @@ RUN_TESTS="" SCRIPT_PATH=$(dirname $0) REPO_ROOT=$(realpath $SCRIPT_PATH/../../..) +OPERATOR_NAMESPACE=k8s-agents-operator +APP_NAMESPACE=e2e-namespace + function main() { parse_args "$@" create_cluster @@ -90,42 +97,44 @@ function create_cluster() { minikube image load e2e/k8s-agents-operator:e2e --profile ${CLUSTER_NAME} > /dev/null echo "🔄 Adding Helm repositories" - helm repo add newrelic https://helm-charts.newrelic.com > /dev/null + helm repo add newrelic https://newrelic.github.io/helm-charts/ > /dev/null helm repo update > /dev/null helm dependency update ${REPO_ROOT}/charts/k8s-agents-operator > /dev/null echo "🔄 Installing operator" helm upgrade --install k8s-agents-operator ${REPO_ROOT}/charts/k8s-agents-operator \ - --namespace k8s-agents-operator \ + --namespace ${OPERATOR_NAMESPACE} \ --create-namespace \ --set controllerManager.manager.image.version=e2e,controllerManager.manager.image.pullPolicy=Never,controllerManager.manager.image.repository=e2e/k8s-agents-operator \ --set licenseKey=${LICENSE_KEY} - echo "🔄 Waiting for operator to settle" - sleep 15 - kubectl wait --timeout=30s --for=jsonpath='{.status.phase}'=Running -n k8s-agents-operator -l="app.kubernetes.io/instance=k8s-agents-operator" pod - sleep 15 + # Use common wait function + wait_for_operator_ready ${OPERATOR_NAMESPACE} echo "🔄 Creating E2E namespace" - kubectl create namespace e2e-namespace + if ! kubectl get ns ${APP_NAMESPACE} > /dev/null 2>&1; then + kubectl create namespace ${APP_NAMESPACE} + fi - echo "🔄 Installing instrumentation" + echo "🔄 Installing instrumentations" for i in $(find ${SCRIPT_PATH} -maxdepth 1 -type f -name 'e2e-instrumentation-*.yml'); do - kubectl apply --namespace k8s-agents-operator --filename $i + echo " Applying $(basename $i)" + kubectl apply --namespace ${OPERATOR_NAMESPACE} --filename $i done + # Use common wait function for instrumentations + wait_for_instrumentations ${OPERATOR_NAMESPACE} "${SCRIPT_PATH}/e2e-instrumentation-*.yml" + echo "🔄 Installing apps" - kubectl apply --namespace e2e-namespace --filename ${SCRIPT_PATH}/apps/ + kubectl apply --namespace ${APP_NAMESPACE} --filename ${SCRIPT_PATH}/apps/ - echo "🔄 Waiting for apps to settle" - for label in $(find ${SCRIPT_PATH}/apps -type f -name '*.yaml' -exec yq '. | select(.kind == "Deployment") | .metadata.name' {} \;); do - kubectl wait --timeout=600s --for=jsonpath='{.status.phase}'=Running --namespace e2e-namespace -l="app=$label" pod - done + # Use common wait function for apps + wait_for_apps_ready ${APP_NAMESPACE} "${SCRIPT_PATH}/apps" } function run_tests() { echo "🔄 Starting E2E tests" - initContainers=$(kubectl get pods --namespace e2e-namespace --output yaml | yq '.items[].spec.initContainers[].name' | wc -l) + initContainers=$(kubectl get pods --namespace ${APP_NAMESPACE} --output yaml | yq '.items[].spec.initContainers[].name' | wc -l) local expected=$(ls ${SCRIPT_PATH}/apps | wc -l) if [[ ${initContainers} -lt $expected ]]; then echo "❌ Error: not all apps were instrumented. Expected $expected, got ${initContainers}" diff --git a/tests/e2e/v1beta1/e2e-tests.sh b/tests/e2e/v1beta1/e2e-tests.sh index b5d46447..870ead50 100755 --- a/tests/e2e/v1beta1/e2e-tests.sh +++ b/tests/e2e/v1beta1/e2e-tests.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../common-functions.sh" + # Test cluster CLUSTER_NAME="" K8S_VERSION="" @@ -16,9 +20,8 @@ SCRIPT_PATH=$(dirname $0) REPO_ROOT=$(realpath $SCRIPT_PATH/../../..) -operator_ns=k8s-agents-operator -operator_name=k8s-agents-operator -app_ns=e2e-namespace +OPERATOR_NAMESPACE=k8s-agents-operator +APP_NAMESPACE=e2e-namespace function main() { @@ -108,53 +111,45 @@ function create_cluster() { minikube image load e2e/k8s-agents-operator:e2e --profile ${CLUSTER_NAME} > /dev/null echo "🔄 Adding Helm repositories" - helm repo add newrelic https://helm-charts.newrelic.com > /dev/null + helm repo add newrelic https://newrelic.github.io/helm-charts/ > /dev/null helm repo update > /dev/null helm dependency update ${REPO_ROOT}/charts/k8s-agents-operator > /dev/null echo "🔄 Installing operator" helm upgrade --install k8s-agents-operator ${REPO_ROOT}/charts/k8s-agents-operator \ - --namespace $operator_ns \ + --namespace ${OPERATOR_NAMESPACE} \ --create-namespace \ --set controllerManager.manager.image.version=e2e,controllerManager.manager.image.pullPolicy=Never,controllerManager.manager.image.repository=e2e/k8s-agents-operator \ --set licenseKey=${LICENSE_KEY} - echo "🔄 Waiting for operator namespace" - until_ready 15 "kubectl get ns/$operator_ns" - - echo "🔄 Waiting for operator deployment" - until_ready 15 "kubectl get deploy/$operator_name -n $operator_ns" - - echo "🔄 Waiting for operator to settle" - kubectl wait --timeout=30s --for=jsonpath='{.status.phase}'=Running -n $operator_ns -l="app.kubernetes.io/instance=k8s-agents-operator" pod - - echo "🔄 Waiting for operator to obtain lease" - until_ready 30 "kubectl logs -n $operator_ns deploy/$operator_name | grep 'successfully acquired lease'" - - echo "🔄 Waiting for operator to handle admission webhooks" - until_ready 30 "kubectl apply -f ${SCRIPT_PATH}/e2e-instrumentation-test.yml -n $operator_ns" + # Use common wait function + wait_for_operator_ready ${OPERATOR_NAMESPACE} echo "🔄 Creating E2E namespace" - kubectl create namespace $app_ns + if ! kubectl get ns ${APP_NAMESPACE} > /dev/null 2>&1; then + kubectl create namespace ${APP_NAMESPACE} + fi - echo "🔄 Installing instrumentation" + echo "🔄 Installing instrumentations" for i in $(find ${SCRIPT_PATH} -maxdepth 1 -type f -name 'e2e-instrumentation-*.yml'); do - kubectl apply --namespace $operator_ns --filename $i + echo " Applying $(basename $i)" + kubectl apply --namespace ${OPERATOR_NAMESPACE} --filename $i done + # Use common wait function for instrumentations + wait_for_instrumentations ${OPERATOR_NAMESPACE} "${SCRIPT_PATH}/e2e-instrumentation-*.yml" + echo "🔄 Installing apps" - kubectl apply --namespace $app_ns --filename ${SCRIPT_PATH}/apps/ + kubectl apply --namespace ${APP_NAMESPACE} --filename ${SCRIPT_PATH}/apps/ - echo "🔄 Waiting for apps to settle" - for label in $(find ${SCRIPT_PATH}/apps -type f -name '*.yaml' -exec yq '. | select(.kind == "Deployment") | .metadata.name' {} \;); do - kubectl wait --timeout=600s --for=jsonpath='{.status.phase}'=Running --namespace $app_ns -l="app=$label" pod - done + # Use common wait function for apps + wait_for_apps_ready ${APP_NAMESPACE} "${SCRIPT_PATH}/apps" } function run_tests() { echo "🔄 Starting E2E tests" - initContainers=$(kubectl get pods --namespace $app_ns --output yaml | yq '.items[].spec.initContainers[].name' | wc -l) + initContainers=$(kubectl get pods --namespace ${APP_NAMESPACE} --output yaml | yq '.items[].spec.initContainers[].name' | wc -l) local expected=$(ls ${SCRIPT_PATH}/apps | wc -l) if [[ ${initContainers} -lt $expected ]]; then echo "❌ Error: not all apps were instrumented. Expected $expected, got ${initContainers}" @@ -174,7 +169,7 @@ function run_tests() { # ensure that the resources get assigned to the pod init container function test_dotnet() { - local dotnet_resources_mem=$(kubectl get pods -l app=dotnetapp -n $app_ns -o yaml | yq '.items[] | .spec.initContainers[] | select(.name == "nri-dotnet--dotnetapp") | .resources.requests.memory') + local dotnet_resources_mem=$(kubectl get pods -l app=dotnetapp -n ${APP_NAMESPACE} -o yaml | yq '.items[] | .spec.initContainers[] | select(.name == "nri-dotnet--dotnetapp") | .resources.requests.memory') if test "$dotnet_resources_mem" != "512Mi" ; then echo "❌ Error: expected resource with request.memory" exit 1 @@ -185,8 +180,8 @@ function test_dotnet() { # ensure that the configmap gets mounted function test_java() { - local java_cfg_vol_name=$(kubectl get pods -l app=javaapp -n $app_ns -o yaml | yq '.items[] | .spec.volumes[] | select(.name == "nri-cfg--javaapp") | .name') - local java_cfg_mount_name=$(kubectl get pods -l app=javaapp -n $app_ns -o yaml | yq '.items[] | .spec.containers[].volumeMounts[] | select(.name == "nri-cfg--javaapp") | .name') + local java_cfg_vol_name=$(kubectl get pods -l app=javaapp -n ${APP_NAMESPACE} -o yaml | yq '.items[] | .spec.volumes[] | select(.name == "nri-cfg--javaapp") | .name') + local java_cfg_mount_name=$(kubectl get pods -l app=javaapp -n ${APP_NAMESPACE} -o yaml | yq '.items[] | .spec.containers[].volumeMounts[] | select(.name == "nri-cfg--javaapp") | .name') if test "$java_cfg_vol_name" != "nri-cfg--javaapp" || test "$java_cfg_mount_name" != "nri-cfg--javaapp"; then echo "❌ Error: expected volume and volume mount with agent config map" exit 1 diff --git a/tests/e2e/v1beta2/apps/dotnet_deployment.yaml b/tests/e2e/v1beta2/apps/dotnet_deployment.yaml index e999b16d..4d32fddf 100644 --- a/tests/e2e/v1beta2/apps/dotnet_deployment.yaml +++ b/tests/e2e/v1beta2/apps/dotnet_deployment.yaml @@ -14,6 +14,10 @@ spec: app: dotnetapp app.newrelic.instrumentation: newrelic-dotnet-agent spec: + securityContext: + runAsNonRoot: true + runAsUser: 5678 + runAsGroup: 5678 containers: - name: alpine image: alpine @@ -30,6 +34,16 @@ spec: value: 'http://+:8080' ports: - containerPort: 8080 + + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsUser: 5678 + runAsGroup: 5678 + runAsNonRoot: true + capabilities: + drop: + - ALL --- apiVersion: v1 kind: Service diff --git a/tests/e2e/v1beta2/apps/ruby_deployment.yaml b/tests/e2e/v1beta2/apps/ruby_deployment.yaml index a71dfc88..67875fa6 100644 --- a/tests/e2e/v1beta2/apps/ruby_deployment.yaml +++ b/tests/e2e/v1beta2/apps/ruby_deployment.yaml @@ -17,37 +17,70 @@ data: BUNDLED WITH 1.17.2 main.rb: | - require 'bundler/setup' - Bundler::require(:default) require 'socket' require 'logger' + + # Load New Relic agent if available (injected by operator) + begin + require 'newrelic_rpm' + rescue LoadError + puts "New Relic agent not available (not injected yet)" + end + logger = Logger.new(STDOUT) logger.level = Logger::DEBUG + class HttpServer + include NewRelic::Agent::Instrumentation::ControllerInstrumentation if defined?(NewRelic) + def initialize(logger, port) @server = TCPServer.new('0.0.0.0', port) @logger = logger end + def accept_connection while session = @server.accept - request = session.readpartial(1024) - @logger.info("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") - STDERR.puts("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") - verb,path,proto = request.lines[0].split - scheme,ver = proto.split('/') - @logger.info("requested #{verb} #{path} #{scheme} #{ver}") - STDERR.puts("requested #{verb} #{path} #{scheme} #{ver}") - session.write("HTTP/1.0 200 OK\r\n") - session.write("Host: rubyapp\r\n") - session.write("Connection: close\r\n") - session.write("\r\n") - session.write("hello world from ruby\n") - session.write("peer: #{session.peeraddr[3]}:#{session.peeraddr[1]}\n") - session.write("server: #{session.addr[3]}:#{session.addr[1]}\n") - session.close + handle_request(session) end end + + def handle_request(session) + request = session.readpartial(1024) + @logger.info("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") + STDERR.puts("peer #{session.peeraddr[3]}:#{session.peeraddr[1]} connected") + + verb, path, proto = request.lines[0].split + scheme, ver = proto.split('/') + @logger.info("requested #{verb} #{path} #{scheme} #{ver}") + STDERR.puts("requested #{verb} #{path} #{scheme} #{ver}") + + # Set transaction name for New Relic + if defined?(NewRelic) + NewRelic::Agent.set_transaction_name("HttpServer/#{verb}#{path}") + end + + session.write("HTTP/1.0 200 OK\r\n") + session.write("Host: rubyapp\r\n") + session.write("Connection: close\r\n") + session.write("\r\n") + session.write("hello world from ruby\n") + session.write("peer: #{session.peeraddr[3]}:#{session.peeraddr[1]}\n") + session.write("server: #{session.addr[3]}:#{session.addr[1]}\n") + session.close + rescue => e + @logger.error("Error handling request: #{e.message}") + STDERR.puts("Error handling request: #{e.message}") + session.close rescue nil + end + + # Instrument the handle_request method as a web transaction + if defined?(NewRelic) + add_transaction_tracer :handle_request, + category: :controller, + name: 'handle_request' + end end + logger.info("starting server") STDERR.puts("starting server") server = HttpServer.new(logger, 8080) @@ -76,8 +109,12 @@ spec: - name: RUBYOPT value: -W2 command: - - ruby - - main.rb + - sh + - -c + - | + gem install bundler + bundle install + ruby main.rb ports: - containerPort: 8080 volumeMounts: diff --git a/tests/e2e/v1beta2/e2e-instrumentation-dotnet.yml b/tests/e2e/v1beta2/e2e-instrumentation-dotnet.yml index 629b0146..8ff1065a 100644 --- a/tests/e2e/v1beta2/e2e-instrumentation-dotnet.yml +++ b/tests/e2e/v1beta2/e2e-instrumentation-dotnet.yml @@ -29,6 +29,9 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsUser: 1234 + capabilities: + drop: + - ALL imagePullPolicy: Never healthAgent: image: newrelic/k8s-apm-agent-health-sidecar:latest @@ -43,4 +46,7 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsUser: 5678 + capabilities: + drop: + - ALL imagePullPolicy: Always diff --git a/tests/e2e/v1beta2/e2e-tests.sh b/tests/e2e/v1beta2/e2e-tests.sh index e4d89aaa..a88672ff 100755 --- a/tests/e2e/v1beta2/e2e-tests.sh +++ b/tests/e2e/v1beta2/e2e-tests.sh @@ -1,16 +1,28 @@ #!/usr/bin/env bash set -euo pipefail +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../common-functions.sh" + # Test cluster CLUSTER_NAME="" -K8S_VERSION="" +K8S_VERSION="v1.35.0" # New Relic account (production) details -LICENSE_KEY="" +LICENSE_KEY="abc123" + +OPERATOR_NAMESPACE=k8s-agents-operator +APP_NAMESPACE=e2e-namespace # Unset if you only want to setup a test cluster with E2E specifications # Set to true if you additionally want to run tests -RUN_TESTS="" +RUN_TESTS=false +RUN_TEARDOWN=false +RUN_CREATE=true +RUN_PRE_TEARDOWN=true +RUN_LOAD_AUX_IMAGES=true + SCRIPT_PATH=$(dirname $0) REPO_ROOT=$(realpath $SCRIPT_PATH/../../..) @@ -19,15 +31,23 @@ function main() { parse_args "$@" if test $(echo $K8S_VERSION | awk -F. '{print $1}') == "v1" && test $(echo $K8S_VERSION | awk -F. '{print $2}') -le "28"; then - echo "⚠️ cluster skipped because the version $K8S_VERSION does not have native sidecars support, which is required; introduced as a feature flag in v1.28.0 disabled by default, and enabled by default in v1.29.0 or greater" - exit 0 + echo "⚠️ cluster skipped because the version $K8S_VERSION does not have native sidecars support, which is required; introduced as a feature flag in v1.28.0 disabled by default, and enabled by default in v1.29.0 or greater" + exit 0 fi - create_cluster - if [[ "$RUN_TESTS" == "true" ]]; then - run_tests - teardown + test -z "$CLUSTER_NAME" && set_clustername + $RUN_PRE_TEARDOWN && pre_teardown + $RUN_CREATE && create_cluster + $RUN_LOAD_AUX_IMAGES && load_aux_images + if ${RUN_TESTS}; then + build_images + load_images + install_operator + install_tests + #run_tests fi + + $RUN_TEARDOWN teardown } function parse_args() { @@ -50,6 +70,22 @@ function parse_args() { K8S_VERSION="${1#*=}" ;; + --set_namespace|--set-namespace) + shift + OPERATOR_NAMESPACE="$1" + ;; + --set_namespace=*|--set-namespace=*) + OPERATOR_NAMESPACE="${1#*=}" + ;; + + --set_clustername|--set-clustername) + shift + CLUSTER_NAME="$1" + ;; + --set_clustername=*|--set-clustername=*) + CLUSTER_NAME="${1#*=}" + ;; + --license_key|--license-key) shift LICENSE_KEY="$1" @@ -59,8 +95,21 @@ function parse_args() { ;; --run_tests|--run-tests) - RUN_TESTS="true" + RUN_TESTS=true + ;; + + --disable_teardown|--disable-teardown) + RUN_TEARDOWN=false + ;; + + --disable_pre_teardown|--disable-pre-teardown) + RUN_PRE_TEARDOWN=false ;; + + --enable_aux_images|--enable-aux-images) + RUN_LOAD_AUX_IMAGES=true + ;; + -*|--*|*) echo "Unknown field: $1" exit 1 @@ -69,7 +118,7 @@ function parse_args() { shift done - if [[ totalArgs -lt 4 ]]; then + if [[ totalArgs -lt 2 ]]; then help exit 0 fi @@ -89,59 +138,82 @@ function help() { END } -function create_cluster() { - echo "🔄 Setup" +function pre_teardown() { + echo "🔄 Tearing down all previous minikube instances if they exist" minikube delete --all > /dev/null +} + +function set_clustername() { now=$( date "+%Y-%m-%d-%H-%M-%S" ) CLUSTER_NAME=${now}-e2e-tests +} +function create_cluster() { echo "🔄 Creating cluster ${CLUSTER_NAME}" minikube start --container-runtime=containerd --kubernetes-version=${K8S_VERSION} --profile ${CLUSTER_NAME} > /dev/null +} +function build_images() { echo "🔄 Building Docker image" DOCKER_BUILDKIT=1 docker build --tag e2e/k8s-agents-operator:e2e ${REPO_ROOT} --quiet > /dev/null +} +function load_images() { echo "🔄 Loading operator image into cluster" minikube image load e2e/k8s-agents-operator:e2e --profile ${CLUSTER_NAME} > /dev/null +} - echo "🔄 Loading dotnet init image into cluster" - minikube image load --profile ${CLUSTER_NAME} newrelic/newrelic-dotnet-init:latest +function load_aux_image() { + echo "🔄 Pulling image $1 onto local machine" + docker pull $1 + echo "🔄 Loading image $1 into cluster ${CLUSTER_NAME}" + minikube image load --profile ${CLUSTER_NAME} $1 +} - echo "🔄 Loading health agent sidecar image into cluster" - minikube image load --profile ${CLUSTER_NAME} newrelic/k8s-apm-agent-health-sidecar:latest +function load_aux_images() { + export -f load_aux_image + export CLUSTER_NAME + find ${SCRIPT_PATH} -type f \( -name '*.yaml' -or -name '*.yml' \) -exec awk '/image:/{print $2}' {} \; \ + | sort | uniq | xargs -I {} bash -c "set -euo pipefail; load_aux_image {}" +} +function install_operator() { echo "🔄 Adding Helm repositories" - helm repo add newrelic https://helm-charts.newrelic.com > /dev/null + helm repo add newrelic https://newrelic.github.io/helm-charts/ > /dev/null helm repo update > /dev/null helm dependency update ${REPO_ROOT}/charts/k8s-agents-operator > /dev/null echo "🔄 Installing operator" helm upgrade --install k8s-agents-operator ${REPO_ROOT}/charts/k8s-agents-operator \ - --namespace k8s-agents-operator \ + --namespace ${OPERATOR_NAMESPACE} \ --create-namespace \ --set controllerManager.manager.image.version=e2e,controllerManager.manager.image.pullPolicy=Never,controllerManager.manager.image.repository=e2e/k8s-agents-operator \ --set licenseKey=${LICENSE_KEY} - echo "🔄 Waiting for operator to settle" - sleep 15 - kubectl wait --timeout=30s --for=jsonpath='{.status.phase}'=Running -n k8s-agents-operator -l="app.kubernetes.io/instance=k8s-agents-operator" pod - sleep 15 + # Use common wait function + wait_for_operator_ready ${OPERATOR_NAMESPACE} +} +function install_tests() { echo "🔄 Creating E2E namespace" - kubectl create namespace e2e-namespace + if ! kubectl get ns ${APP_NAMESPACE} > /dev/null 2>&1; then + kubectl create namespace ${APP_NAMESPACE} + fi - echo "🔄 Installing instrumentation" + echo "🔄 Installing instrumentations" for i in $(find ${SCRIPT_PATH} -maxdepth 1 -type f -name 'e2e-instrumentation-*.yml'); do - kubectl apply --namespace k8s-agents-operator --filename $i + echo " Applying $(basename $i)" + kubectl apply --namespace ${OPERATOR_NAMESPACE} --filename $i done + # Use common wait function for instrumentations + wait_for_instrumentations ${OPERATOR_NAMESPACE} "${SCRIPT_PATH}/e2e-instrumentation-*.yml" + echo "🔄 Installing apps" - kubectl apply --namespace e2e-namespace --filename ${SCRIPT_PATH}/apps/ + kubectl apply --namespace ${APP_NAMESPACE} --filename ${SCRIPT_PATH}/apps/ - echo "🔄 Waiting for apps to settle" - for label in $(find ${SCRIPT_PATH}/apps -type f -name '*.yaml' -exec yq '. | select(.kind == "Deployment") | .metadata.name' {} \;); do - kubectl wait --timeout=600s --for=jsonpath='{.status.phase}'=Running --namespace e2e-namespace -l="app=$label" pod - done + # Use common wait function for apps + wait_for_apps_ready ${APP_NAMESPACE} "${SCRIPT_PATH}/apps" } function run_tests() { diff --git a/tests/e2e/v1beta3/deployment.yml b/tests/e2e/v1beta3/deployment.yml new file mode 100644 index 00000000..36ce67c3 --- /dev/null +++ b/tests/e2e/v1beta3/deployment.yml @@ -0,0 +1,154 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: php-cfgmap +data: + index.php: | + 'ready', + 'healthy' => true, + 'last_error' => '', + 'status_time_unix_nano' => $now, + 'start_time_unix_nano' => $started_at*1000000000, + 'agent_run_id' => $agent_id, + 'entity_guid' => $entity_id, + ]); + + if (!extension_loaded('newrelic')) { + $o->status = 'not ready'; + $o->last_error = 'extension not loaded'; + } + + $r = ''; + foreach ($o as $k => $v) { + if (preg_match('@^[0-9]|[^a-zA-Z0-9_]@', $k) === true) { + $r .= '"'.str_replace(["'", '"', "\\", "\n", "\t", "\r"], ["\\'", '\\"', "\\\\", "\\n", "\\t", "\\r"], $k).'": '; + } else { + $r .= "$k: "; + } + $t = gettype($v); + switch (gettype($v)) { + case 'string': $r .= '"'.str_replace(['\\',"'",'"',"\n","\r","\t"],['\\\\',"\\'",'\\"',"\\n","\\r","\\t"],$v).'"'; break; + case 'boolean': $r .= $v?'true':'false'; break; + case 'integer': $r .= number_format($v, 0, "", ""); break; + default: $r .= $v; break; + } + $r .= "\n"; + } + $r .= "\n"; + + echo $r; + if (getenv('NEW_RELIC_FLEET_CONTROL_HEALTH_FILE') !== '' && getenv('NEW_RELIC_FLEET_CONTROL_HEALTH_FILE') !== false) { + file_put_contents(getenv('NEW_RELIC_FLEET_CONTROL_HEALTH_FILE'), $r); + } + if (getenv('NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION') !== '' && getenv('NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION') !== false) { + $location = rtrim(getenv('NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION'),'/').'/health-main.yaml'; + if (substr($location, 0, 7) === "file://") { + $location = substr($location, 7); + } + file_put_contents(rtrim(getenv('NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION'),'/').'/health-main.yaml', $r); + } + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: php-deployment + name: php-deployment +spec: + replicas: 1 + selector: + matchLabels: + pod: php-pod + template: + metadata: + labels: + pod: php-pod + old-newrelic.com/inject-apm-health: 'true' + newrelic.com/inject-apm-php: '8.4' + spec: + initContainers: + - name: started + image: alpine + command: + - ash + args: + - -c + - 'date -u +"%Y-%m-%dT%H:%M:%SZ" > /data/started_at; cat /proc/sys/kernel/random/uuid > /data/agent_id; cat /proc/sys/kernel/random/uuid > /data/entity_id' + volumeMounts: + - mountPath: /data/ + name: tmp + - name: started2 + image: alpine + command: + - ash + args: + - -c + - 'date -u +"%Y-%m-%dT%H:%M:%SZ" > /data/started_at; cat /proc/sys/kernel/random/uuid > /data/agent_id; cat /proc/sys/kernel/random/uuid > /data/entity_id' + volumeMounts: + - mountPath: /data/ + name: tmp2 + - name: requester + image: alpine + command: + - ash + args: + - -c + - 'while true; do wget -O - 127.0.0.1:8000; sleep 1; done' + restartPolicy: Always + - name: requester2 + image: alpine + command: + - ash + args: + - -c + - 'while true; do wget -O - 127.0.0.1:8001; sleep 1; done' + restartPolicy: Always + containers: + - name: a + image: php:8.4-alpine + workingDir: /app + command: + - php + - -S + - 0.0.0.0:8000 + - index.php + ports: + - containerPort: 8000 + volumeMounts: + - mountPath: /app/ + name: code + - mountPath: /data/ + name: tmp + - name: b + image: php:8.4-alpine + workingDir: /app + command: + - php + - -S + - 0.0.0.0:8001 + - index.php + ports: + - containerPort: 8001 + volumeMounts: + - mountPath: /app/ + name: code + - mountPath: /data/ + name: tmp2 + volumes: + - name: code + configMap: + name: php-cfgmap + - name: tmp + emptyDir: {} + - name: tmp2 + emptyDir: {} \ No newline at end of file diff --git a/tests/e2e/v1beta3/deployment2.yml b/tests/e2e/v1beta3/deployment2.yml new file mode 100644 index 00000000..223376d7 --- /dev/null +++ b/tests/e2e/v1beta3/deployment2.yml @@ -0,0 +1,29 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: php-deployment + name: both-deployment +spec: + replicas: 1 + selector: + matchLabels: + pod: both-pod + template: + metadata: + labels: + pod: both-pod + newrelic.com/inject-apm-both: '8.4' + spec: + containers: + - name: a + image: ghcr.io/open-telemetry/opentelemetry-operator/e2e-test-app-nodejs:main + ports: + - containerPort: 3000 + env: + - name: NODE_PATH + value: /usr/local/lib/node_modules + - name: b + image: andrewlozoya/flask-hello-world:latest + ports: + - containerPort: 5000 \ No newline at end of file diff --git a/tests/e2e/v1beta3/e2e-tests.sh b/tests/e2e/v1beta3/e2e-tests.sh new file mode 100755 index 00000000..5dd80129 --- /dev/null +++ b/tests/e2e/v1beta3/e2e-tests.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../common-functions.sh" + +# Test cluster +CLUSTER_NAME="" +K8S_VERSION="" + +# New Relic account (production) details +LICENSE_KEY="" + +# Unset if you only want to setup a test cluster with E2E specifications +# Set to true if you additionally want to run tests +RUN_TESTS="" + +SCRIPT_PATH=$(dirname $0) +REPO_ROOT=$(realpath $SCRIPT_PATH/../../..) + +OPERATOR_NAMESPACE=k8s-agents-operator +APP_NAMESPACE=e2e-namespace + +function main() { + parse_args "$@" + + if test $(echo $K8S_VERSION | awk -F. '{print $1}') == "v1" && test $(echo $K8S_VERSION | awk -F. '{print $2}') -le "28"; then + echo "⚠️ cluster skipped because the version $K8S_VERSION does not have native sidecars support, which is required; introduced as a feature flag in v1.28.0 disabled by default, and enabled by default in v1.29.0 or greater" + exit 0 + fi + + create_cluster + if [[ "$RUN_TESTS" == "true" ]]; then + teardown + fi +} + +function parse_args() { + totalArgs=$# + + # Arguments are passed by value, so other functions + # are not affected by destructive processing + while [[ $# -gt 0 ]]; do + case $1 in + --help) + help + exit 0 + ;; + + --k8s_version|--k8s-version) + shift + K8S_VERSION="$1" + ;; + --k8s_version=*|--k8s-version=*) + K8S_VERSION="${1#*=}" + ;; + + --license_key|--license-key) + shift + LICENSE_KEY="$1" + ;; + --license_key=*|--license-key=*) + LICENSE_KEY="${1#*=}" + ;; + + --run_tests|--run-tests) + RUN_TESTS="true" + ;; + -*|--*|*) + echo "Unknown field: $1" + exit 1 + ;; + esac + shift + done + + if [[ totalArgs -lt 4 ]]; then + help + exit 0 + fi +} + +function help() { + cat < + --license_key + [--run_tests] + + --k8s_version: valid Kubernetes cluster version. It is highly recommended to use same versions as E2E tests + --license_key: key type 'INGEST - LICENSE' + --run_tests: if unset, create a cluster with specifications matching E2E tests + otherwise run tests in addition to setting up cluster +END +} + +function create_cluster() { + echo "🔄 Setup" + #minikube delete --all > /dev/null + now=$( date "+%Y-%m-%d-%H-%M-%S" ) + CLUSTER_NAME=${now}-e2e-tests + + CLUSTER_NAME=2025-11-14-16-27-48-e2e-tests + + echo "🔄 Creating cluster ${CLUSTER_NAME}" + #minikube start --container-runtime=containerd --kubernetes-version=${K8S_VERSION} --profile ${CLUSTER_NAME} > /dev/null + + echo "🔄 Building Docker image" + DOCKER_BUILDKIT=1 docker build --tag e2e/k8s-agents-operator:e2e ${REPO_ROOT} --quiet > /dev/null + + echo "🔄 Loading operator image into cluster" + minikube image load e2e/k8s-agents-operator:e2e --profile ${CLUSTER_NAME} > /dev/null + + echo "🔄 Loading health agent sidecar image into cluster" + minikube image load --profile ${CLUSTER_NAME} newrelic/k8s-apm-agent-health-sidecar:latest + + echo "🔄 Adding Helm repositories" + helm repo add newrelic https://newrelic.github.io/helm-charts/ > /dev/null + helm repo update > /dev/null + helm dependency update ${REPO_ROOT}/charts/k8s-agents-operator > /dev/null + + echo "🔄 Installing operator" + helm upgrade --install k8s-agents-operator ${REPO_ROOT}/charts/k8s-agents-operator \ + --namespace ${OPERATOR_NAMESPACE} \ + --create-namespace \ + --set controllerManager.manager.image.version=e2e,controllerManager.manager.image.pullPolicy=Never,controllerManager.manager.image.repository=e2e/k8s-agents-operator \ + --set licenseKey=${LICENSE_KEY} + + # Use common wait function + wait_for_operator_ready ${OPERATOR_NAMESPACE} + + echo "🔄 Creating E2E namespace" + if ! kubectl get ns ${APP_NAMESPACE} > /dev/null 2>&1; then + kubectl create namespace ${APP_NAMESPACE} + fi + + echo "🔄 Installing instrumentations" + echo " Applying instrumentation.yml" + kubectl apply --namespace ${OPERATOR_NAMESPACE} --filename ${SCRIPT_PATH}/instrumentation.yml + + # Wait for instrumentation to be established + echo "🔄 Waiting for instrumentations to be established" + sleep 3 + inst_name=$(yq '.metadata.name' ${SCRIPT_PATH}/instrumentation.yml 2>/dev/null || grep 'name:' ${SCRIPT_PATH}/instrumentation.yml | head -1 | awk '{print $2}') + if [ -n "$inst_name" ]; then + echo " Verifying instrumentation: $inst_name" + until kubectl get instrumentation -n ${OPERATOR_NAMESPACE} $inst_name 2>/dev/null; do + echo " Waiting for instrumentation $inst_name to be created..." + sleep 1 + done + fi + echo "✅ Instrumentations are established" + + echo "🔄 Installing apps" + kubectl apply --namespace ${APP_NAMESPACE} --filename ${SCRIPT_PATH}/deployment.yml + + echo "🔄 Waiting for app pods to be ready" + # Wait for pod to exist first + until kubectl get pod -n ${APP_NAMESPACE} -l="pod=php-pod" 2>/dev/null | grep -q "php-pod"; do + echo " Waiting for pod with label pod=php-pod to be created..." + sleep 2 + done + + # Wait for pod to be ready (not just running) + kubectl wait --timeout=600s --for=condition=Ready \ + --namespace ${APP_NAMESPACE} -l="pod=php-pod" pod + + echo "✅ All apps are ready and instrumented" +} + +function teardown() { + echo "🔄 Teardown" + #minikube delete --all > /dev/null +} + +main "$@" diff --git a/tests/e2e/v1beta3/instrumentation.yml b/tests/e2e/v1beta3/instrumentation.yml new file mode 100644 index 00000000..12a55b38 --- /dev/null +++ b/tests/e2e/v1beta3/instrumentation.yml @@ -0,0 +1,26 @@ +apiVersion: newrelic.com/v1beta3 +kind: Instrumentation +metadata: + name: php + namespace: k8s-agents-operator +spec: + containerSelector: + nameSelector: + matchExpressions: + - key: container + operator: In + values: [ "a", "b" ] + podLabelSelector: + matchExpressions: + - key: "newrelic.com/inject-apm-php" + operator: "In" + values: ["8.4"] + agent: + language: php-8.4 + image: newrelic/newrelic-php-init:musl + healthAgent: + #image: e2e/newrelic-health-sidecar-container:e2e2 + image: newrelic/k8s-apm-agent-health-sidecar + #env: + # - name: NEW_RELIC_FLEET_CONTROL_HEALTH_PATH + # value: /health/ \ No newline at end of file diff --git a/tests/e2e/v1beta3/instrumentation2.yml b/tests/e2e/v1beta3/instrumentation2.yml new file mode 100644 index 00000000..1754045d --- /dev/null +++ b/tests/e2e/v1beta3/instrumentation2.yml @@ -0,0 +1,21 @@ +apiVersion: newrelic.com/v1beta3 +kind: Instrumentation +metadata: + name: both-1 + namespace: k8s-agents-operator +spec: + containerSelector: + nameSelector: + matchExpressions: + - key: container + operator: In + values: [ "a" ] + podLabelSelector: + matchExpressions: + - key: "newrelic.com/inject-apm-both" + operator: "Exists" + agent: + language: nodejs + image: newrelic/newrelic-node-init:latest + healthAgent: + image: newrelic/k8s-apm-agent-health-sidecar \ No newline at end of file diff --git a/tests/e2e/v1beta3/instrumentation3.yml b/tests/e2e/v1beta3/instrumentation3.yml new file mode 100644 index 00000000..fce8d09e --- /dev/null +++ b/tests/e2e/v1beta3/instrumentation3.yml @@ -0,0 +1,21 @@ +apiVersion: newrelic.com/v1beta3 +kind: Instrumentation +metadata: + name: both-2 + namespace: k8s-agents-operator +spec: + containerSelector: + nameSelector: + matchExpressions: + - key: container + operator: In + values: [ "b" ] + podLabelSelector: + matchExpressions: + - key: "newrelic.com/inject-apm-both" + operator: "Exists" + agent: + language: python + image: newrelic/newrelic-python-init:latest + healthAgent: + image: newrelic/k8s-apm-agent-health-sidecar \ No newline at end of file diff --git a/tools/go.mod b/tools/go.mod index 6904731b..9a1b44c4 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -133,7 +133,6 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/godoc-lint/godoc-lint v0.11.1 // indirect github.com/gofrs/flock v0.13.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect @@ -227,7 +226,6 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index 35ea85b3..f8ad4ca3 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -34,8 +34,6 @@ github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS8 github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -56,10 +54,6 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= -github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A= -github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg= -github.com/MirrexOne/unqueryvet v1.3.0 h1:5slWSomgqpYU4zFuZ3NNOfOUxVPlXFDBPAVasZOGlAY= -github.com/MirrexOne/unqueryvet v1.3.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg= github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ= github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= @@ -68,18 +62,14 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= -github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= -github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE= github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= @@ -126,8 +116,6 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0= -github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= @@ -238,12 +226,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q= -github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4= github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo= github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= -github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo= -github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= @@ -319,16 +303,10 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA= -github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw= -github.com/godoc-lint/godoc-lint v0.10.2 h1:dksNgK+zebnVlj4Fx83CRnCmPO0qRat/9xfFsir1nfg= -github.com/godoc-lint/godoc-lint v0.10.2/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw= github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA= github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= @@ -338,14 +316,8 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI= -github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE= -github.com/golangci/golangci-lint/v2 v2.7.2 h1:AhBC+YeEueec4AGlIbvPym5C70Thx0JykIqXbdIXWx0= -github.com/golangci/golangci-lint/v2 v2.7.2/go.mod h1:pDijleoBu7e8sejMqyZ3L5n6geqe+cVvOAz2QImqqVc= github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA= github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI= -github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8= -github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ= github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc= github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ= github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c= @@ -416,8 +388,6 @@ github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= @@ -453,10 +423,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -480,8 +448,6 @@ github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0 github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= -github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE= -github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8= github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= @@ -524,8 +490,6 @@ github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebG github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU= -github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8= github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM= github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= @@ -579,8 +543,8 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042 github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -601,20 +565,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= -github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= @@ -660,10 +618,6 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= -github.com/securego/gosec/v2 v2.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg= -github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w= -github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7 h1:rZg6IGn0ySYZwCX8LHwZoYm03JhG/cVAJJ3O+u3Vclo= -github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7/go.mod h1:9sr22NZO5Kfh7unW/xZxkGYTmj2484/fCiE54gw7UTY= github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg= github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -687,8 +641,6 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -699,8 +651,6 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= -github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= @@ -737,8 +687,6 @@ github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= -github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= @@ -776,7 +724,6 @@ github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+ github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -858,10 +805,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= @@ -879,8 +822,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -897,8 +838,6 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= @@ -913,8 +852,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -939,12 +876,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= -golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -953,8 +886,6 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -965,8 +896,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= @@ -974,9 +903,7 @@ golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -985,8 +912,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= @@ -1028,46 +953,26 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k= helm.sh/helm/v3 v3.19.0/go.mod h1:Lk/SfzN0w3a3C3o+TdAKrLwJ0wcZ//t1/SDXAvfgDdc= -helm.sh/helm/v4 v4.0.1 h1:WAfnyQEaEnTgaIPJssN+sPx0k1pkKldro8tE4q3c51A= -helm.sh/helm/v4 v4.0.1/go.mod h1:G1Y5AE+lJPQSAjh7nbXnhZrtGtxo+I6POSu9DruYiGI= -helm.sh/helm/v4 v4.0.2 h1:yepsAylC26bsPGf3NutSWSDc+iVmi1CijXjZd5WnHbk= -helm.sh/helm/v4 v4.0.2/go.mod h1:G1Y5AE+lJPQSAjh7nbXnhZrtGtxo+I6POSu9DruYiGI= helm.sh/helm/v4 v4.0.4 h1:5Lokr7XxCe6IW/NMtdECuAFW/0bTs/2831deUrlKqP8= helm.sh/helm/v4 v4.0.4/go.mod h1:fMyG9onvVK6HOBjjkzhhHORAsgEWlRMqDY84lvX7GvY= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0-beta.0 h1:vVoDiASLwUEv5yZceZCBRPXBc1f9wUOZs7ZbEbGr5sY= -k8s.io/apimachinery v0.35.0-beta.0/go.mod h1:dR9KPaf5L0t2p9jZg/wCGB4b3ma2sXZ2zdNqILs+Sak= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= -k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -k8s.io/code-generator v0.34.1 h1:WpphT26E+j7tEgIUfFr5WfbJrktCGzB3JoJH9149xYc= -k8s.io/code-generator v0.34.1/go.mod h1:DeWjekbDnJWRwpw3s0Jat87c+e0TgkxoR4ar608yqvg= k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= -k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= -k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= -k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q= -k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= @@ -1088,20 +993,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUo sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251126220622-4b46eb04d57f h1:OaMxUWPAn0+23o6D3lceImXSjvd66ueu2gzQ8IimmMw= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251126220622-4b46eb04d57f/go.mod h1:XEQ2ifTbKOhWb9nVVmN7/5i7y5j3h9C2LnDaoNw2br8= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251210110131-607e772f5d95 h1:+EshvVREWPMu0Ve+v2a6En43FSu9HdDSjrzYVpyW1mc= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251210110131-607e772f5d95/go.mod h1:XEQ2ifTbKOhWb9nVVmN7/5i7y5j3h9C2LnDaoNw2br8= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251215132145-e115f87c99be h1:KwxqNiM2xGU209MbQXQGS5Tv6qZlJfbi5OHCHfbni0g= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251215132145-e115f87c99be/go.mod h1:XEQ2ifTbKOhWb9nVVmN7/5i7y5j3h9C2LnDaoNw2br8= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251218122918-9816e2fec9a1 h1:FdZapdx8o2NwHNCO19Q828BzfxN52JvytlocxOZwGkY= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251218122918-9816e2fec9a1/go.mod h1:/BwOHkjE31BJ0eqwWNH+XizfhDZv+GqzOGcJFN9iWvw= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251222141034-adb64659bb1f h1:pr9MUkoWKrzSFza+hRJO/94FfluFFQX00n7m3DnGKRM= -sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20251222141034-adb64659bb1f/go.mod h1:/BwOHkjE31BJ0eqwWNH+XizfhDZv+GqzOGcJFN9iWvw= sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20260126224948-cf2e741fe0fd h1:6kQ6SgaObZDUjUPStDtvZxMZOo408w95FS/jM9WvzqA= sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20260126224948-cf2e741fe0fd/go.mod h1:/BwOHkjE31BJ0eqwWNH+XizfhDZv+GqzOGcJFN9iWvw= -sigs.k8s.io/controller-tools v0.19.0 h1:OU7jrPPiZusryu6YK0jYSjPqg8Vhf8cAzluP9XGI5uk= -sigs.k8s.io/controller-tools v0.19.0/go.mod h1:y5HY/iNDFkmFla2CfQoVb2AQXMsBk4ad84iR1PLANB0= sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8= sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=