From f735c65f36b9527ce5e1124b13a18120e5a0cf17 Mon Sep 17 00:00:00 2001 From: Ishwar Kanse Date: Wed, 3 Jun 2026 16:08:27 +0530 Subject: [PATCH 1/2] fix: add metadata.name length and format validation to ObservabilityInstaller CRD Assisted by Claude Code --- ....openshift.io_observabilityinstallers.yaml | 6 + ....openshift.io_observabilityinstallers.yaml | 6 + pkg/apis/observability/v1alpha1/types.go | 2 + pkg/apis/observability/v1alpha1/types_test.go | 218 ++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 pkg/apis/observability/v1alpha1/types_test.go diff --git a/bundle/manifests/observability.openshift.io_observabilityinstallers.yaml b/bundle/manifests/observability.openshift.io_observabilityinstallers.yaml index 3d1307c71..60a7ba34d 100644 --- a/bundle/manifests/observability.openshift.io_observabilityinstallers.yaml +++ b/bundle/manifests/observability.openshift.io_observabilityinstallers.yaml @@ -441,6 +441,12 @@ spec: type: string type: object type: object + x-kubernetes-validations: + - message: ObservabilityInstaller name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: ObservabilityInstaller name must consist of lowercase alphanumeric + characters or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/deploy/crds/common/observability.openshift.io_observabilityinstallers.yaml b/deploy/crds/common/observability.openshift.io_observabilityinstallers.yaml index b67ebc63e..2783ab389 100644 --- a/deploy/crds/common/observability.openshift.io_observabilityinstallers.yaml +++ b/deploy/crds/common/observability.openshift.io_observabilityinstallers.yaml @@ -441,6 +441,12 @@ spec: type: string type: object type: object + x-kubernetes-validations: + - message: ObservabilityInstaller name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: ObservabilityInstaller name must consist of lowercase alphanumeric + characters or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/pkg/apis/observability/v1alpha1/types.go b/pkg/apis/observability/v1alpha1/types.go index a5a551a4b..cfc7c00e2 100644 --- a/pkg/apis/observability/v1alpha1/types.go +++ b/pkg/apis/observability/v1alpha1/types.go @@ -18,6 +18,8 @@ import ( // +operator-sdk:csv:customresourcedefinitions:displayName="Observability Installer" // +operator-sdk:csv:customresourcedefinitions:description="Provides end-to-end observability capabilities with minimal configuration. Simplifies deployment and management of observability components such as tracing." // +kubebuilder:metadata:annotations="observability.openshift.io/api-support=TechPreview" +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 63",message="ObservabilityInstaller name must be no more than 63 characters" +// +kubebuilder:validation:XValidation:rule="self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="ObservabilityInstaller name must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character" type ObservabilityInstaller struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/pkg/apis/observability/v1alpha1/types_test.go b/pkg/apis/observability/v1alpha1/types_test.go new file mode 100644 index 000000000..b5368dda9 --- /dev/null +++ b/pkg/apis/observability/v1alpha1/types_test.go @@ -0,0 +1,218 @@ +package v1alpha1 + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// extractObservabilityInstallerValidationRules reads the XValidation rules from the +// ObservabilityInstaller struct comment block in types.go. +func extractObservabilityInstallerValidationRules() (nameLengthRule, nameFormatRule string, err error) { + data, err := os.ReadFile("types.go") + if err != nil { + return "", "", fmt.Errorf("failed to read source file: %v", err) + } + + content := string(data) + + // Capture the comment block + declaration line for ObservabilityInstaller + blockPattern := regexp.MustCompile(`(?s)ObservabilityInstaller defines.*?type ObservabilityInstaller struct`) + blockMatch := blockPattern.FindString(content) + if blockMatch == "" { + return "", "", fmt.Errorf("ObservabilityInstaller struct block not found in source") + } + + rulePattern := regexp.MustCompile(`\+kubebuilder:validation:XValidation:rule="([^"]+)"`) + matches := rulePattern.FindAllStringSubmatch(blockMatch, -1) + if len(matches) < 2 { + return "", "", fmt.Errorf("expected at least 2 XValidation rules on ObservabilityInstaller, found %d", len(matches)) + } + + return matches[0][1], matches[1][1], nil +} + +// newCELEnvForMetadata creates a CEL environment where self is a nested map that +// includes metadata.name, matching the shape Kubernetes presents to root-level rules. +func newCELEnvForMetadata() (*cel.Env, error) { + return cel.NewEnv(cel.Variable("self", cel.MapType(cel.StringType, cel.DynType))) +} + +func makeMetaSelf(name string) map[string]interface{} { + return map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": name, + }, + } +} + +func TestObservabilityInstallerNameLengthValidation(t *testing.T) { + lengthRule, _, err := extractObservabilityInstallerValidationRules() + require.NoError(t, err, "Failed to extract name length rule from source annotation") + + env, err := newCELEnvForMetadata() + require.NoError(t, err) + + ast, issues := env.Compile(lengthRule) + require.Empty(t, issues) + + program, err := env.Program(ast) + require.NoError(t, err) + + tests := []struct { + name string + instName string + expectValid bool + }{ + { + name: "short valid name", + instName: "my-installer", + expectValid: true, + }, + { + name: "single character name", + instName: "a", + expectValid: true, + }, + { + name: "exactly 63 characters", + instName: strings.Repeat("a", 63), + expectValid: true, + }, + { + name: "64 characters - too long", + instName: strings.Repeat("a", 64), + expectValid: false, + }, + { + // Exact name from the bug report - 108 characters + name: "108 character name from bug report", + instName: "test-very-long-name-that-exceeds-normal-kubernetes-resource-name-limits-and-should-be-validated-properly", + expectValid: false, + }, + { + name: "253 character name", + instName: strings.Repeat("a", 253), + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, _, err := program.Eval(map[string]interface{}{ + "self": makeMetaSelf(tt.instName), + }) + require.NoError(t, err) + + result := out.Value().(bool) + if tt.expectValid { + assert.True(t, result, "Expected name %q to be valid (length %d)", tt.instName, len(tt.instName)) + } else { + assert.False(t, result, "Expected name %q to be invalid (length %d)", tt.instName, len(tt.instName)) + } + }) + } +} + +func TestObservabilityInstallerNameFormatValidation(t *testing.T) { + _, formatRule, err := extractObservabilityInstallerValidationRules() + require.NoError(t, err, "Failed to extract name format rule from source annotation") + + env, err := newCELEnvForMetadata() + require.NoError(t, err) + + ast, issues := env.Compile(formatRule) + require.Empty(t, issues) + + program, err := env.Program(ast) + require.NoError(t, err) + + tests := []struct { + name string + instName string + expectValid bool + }{ + { + name: "single lowercase letter", + instName: "a", + expectValid: true, + }, + { + name: "lowercase letters and dashes", + instName: "my-installer", + expectValid: true, + }, + { + name: "lowercase letters and numbers", + instName: "installer123", + expectValid: true, + }, + { + name: "letters numbers and dashes mixed", + instName: "obs-installer-1", + expectValid: true, + }, + { + name: "starts with digit", + instName: "1installer", + expectValid: true, + }, + { + name: "ends with digit", + instName: "installer1", + expectValid: true, + }, + { + name: "uppercase letter - invalid", + instName: "MyInstaller", + expectValid: false, + }, + { + name: "starts with dash - invalid", + instName: "-installer", + expectValid: false, + }, + { + name: "ends with dash - invalid", + instName: "installer-", + expectValid: false, + }, + { + name: "underscore - invalid", + instName: "my_installer", + expectValid: false, + }, + { + name: "dot separator - invalid", + instName: "my.installer", + expectValid: false, + }, + { + name: "108 character name from bug report", + instName: "test-very-long-name-that-exceeds-normal-kubernetes-resource-name-limits-and-should-be-validated-properly", + expectValid: true, // valid format, but fails length check + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, _, err := program.Eval(map[string]interface{}{ + "self": makeMetaSelf(tt.instName), + }) + require.NoError(t, err) + + result := out.Value().(bool) + if tt.expectValid { + assert.True(t, result, "Expected name %q to match format", tt.instName) + } else { + assert.False(t, result, "Expected name %q to not match format", tt.instName) + } + }) + } +} From e1635c029c36267cb1cfac81951b1291b9439a6d Mon Sep 17 00:00:00 2001 From: Ishwar Kanse Date: Fri, 5 Jun 2026 14:02:53 +0530 Subject: [PATCH 2/2] fix: add metadata.name length and format validation to all CRDs --- .../monitoring.rhobs_monitoringstacks.yaml | 6 + .../monitoring.rhobs_thanosqueriers.yaml | 6 + .../monitoring.rhobs_monitoringstacks.yaml | 6 + .../monitoring.rhobs_thanosqueriers.yaml | 6 + pkg/apis/monitoring/v1alpha1/types.go | 4 + pkg/apis/monitoring/v1alpha1/types_test.go | 159 ++++++++++++++++++ .../monitoring/monitoring-stack/components.go | 59 +++---- .../monitoring/monitoring-stack/controller.go | 5 +- .../monitoring/thanos-querier/components.go | 16 +- .../monitoring/thanos-querier/controller.go | 5 +- pkg/controllers/observability/reconcilers.go | 30 +++- pkg/controllers/operator/components.go | 12 +- pkg/controllers/operator/controller.go | 5 +- pkg/controllers/uiplugin/components.go | 82 ++++----- pkg/controllers/uiplugin/controller.go | 17 +- pkg/controllers/util/common.go | 13 +- pkg/controllers/util/common_test.go | 104 ++++++++++++ pkg/reconciler/create_update_reconciler.go | 10 +- pkg/reconciler/reconciler.go | 56 ++++-- 19 files changed, 483 insertions(+), 118 deletions(-) create mode 100644 pkg/apis/monitoring/v1alpha1/types_test.go create mode 100644 pkg/controllers/util/common_test.go diff --git a/bundle/manifests/monitoring.rhobs_monitoringstacks.yaml b/bundle/manifests/monitoring.rhobs_monitoringstacks.yaml index 184dbb5b2..8a9442b00 100644 --- a/bundle/manifests/monitoring.rhobs_monitoringstacks.yaml +++ b/bundle/manifests/monitoring.rhobs_monitoringstacks.yaml @@ -1997,6 +1997,12 @@ spec: - conditions type: object type: object + x-kubernetes-validations: + - message: MonitoringStack name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: MonitoringStack name must consist of lowercase alphanumeric characters + or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/bundle/manifests/monitoring.rhobs_thanosqueriers.yaml b/bundle/manifests/monitoring.rhobs_thanosqueriers.yaml index 1bdf463f0..a9b41b6c0 100644 --- a/bundle/manifests/monitoring.rhobs_thanosqueriers.yaml +++ b/bundle/manifests/monitoring.rhobs_thanosqueriers.yaml @@ -194,6 +194,12 @@ spec: It should always be reconstructable from the state of the cluster and/or outside world. type: object type: object + x-kubernetes-validations: + - message: ThanosQuerier name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: ThanosQuerier name must consist of lowercase alphanumeric characters + or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/deploy/crds/common/monitoring.rhobs_monitoringstacks.yaml b/deploy/crds/common/monitoring.rhobs_monitoringstacks.yaml index 37cc9a42f..8bc64ceb3 100644 --- a/deploy/crds/common/monitoring.rhobs_monitoringstacks.yaml +++ b/deploy/crds/common/monitoring.rhobs_monitoringstacks.yaml @@ -1997,6 +1997,12 @@ spec: - conditions type: object type: object + x-kubernetes-validations: + - message: MonitoringStack name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: MonitoringStack name must consist of lowercase alphanumeric characters + or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/deploy/crds/common/monitoring.rhobs_thanosqueriers.yaml b/deploy/crds/common/monitoring.rhobs_thanosqueriers.yaml index dee2cd6c8..d5691f053 100644 --- a/deploy/crds/common/monitoring.rhobs_thanosqueriers.yaml +++ b/deploy/crds/common/monitoring.rhobs_thanosqueriers.yaml @@ -194,6 +194,12 @@ spec: It should always be reconstructable from the state of the cluster and/or outside world. type: object type: object + x-kubernetes-validations: + - message: ThanosQuerier name must be no more than 63 characters + rule: size(self.metadata.name) <= 63 + - message: ThanosQuerier name must consist of lowercase alphanumeric characters + or '-', and must start and end with an alphanumeric character + rule: self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') served: true storage: true subresources: diff --git a/pkg/apis/monitoring/v1alpha1/types.go b/pkg/apis/monitoring/v1alpha1/types.go index 8b32c6a44..dca634f4a 100644 --- a/pkg/apis/monitoring/v1alpha1/types.go +++ b/pkg/apis/monitoring/v1alpha1/types.go @@ -17,6 +17,8 @@ import ( // +kubebuilder:resource // +kubebuilder:subresource:status // +kubebuilder:metadata:annotations="observability.openshift.io/api-support=GeneralAvailability" +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 63",message="MonitoringStack name must be no more than 63 characters" +// +kubebuilder:validation:XValidation:rule="self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="MonitoringStack name must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character" type MonitoringStack struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -334,6 +336,8 @@ type NamespaceSelector struct { // +kubebuilder:resource // +kubebuilder:subresource:status // +kubebuilder:metadata:annotations="observability.openshift.io/api-support=TechPreview" +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 63",message="ThanosQuerier name must be no more than 63 characters" +// +kubebuilder:validation:XValidation:rule="self.metadata.name.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="ThanosQuerier name must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character" type ThanosQuerier struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/pkg/apis/monitoring/v1alpha1/types_test.go b/pkg/apis/monitoring/v1alpha1/types_test.go new file mode 100644 index 000000000..ff1c3c8a5 --- /dev/null +++ b/pkg/apis/monitoring/v1alpha1/types_test.go @@ -0,0 +1,159 @@ +package v1alpha1 + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// extractValidationRules extracts the two XValidation rules from the comment block +// immediately before the named struct in types.go. +func extractValidationRules(structName string) (nameLengthRule, nameFormatRule string, err error) { + data, err := os.ReadFile("types.go") + if err != nil { + return "", "", fmt.Errorf("failed to read source file: %v", err) + } + + content := string(data) + pattern := regexp.MustCompile(`(?s)// ` + structName + ` [^\n]+(?:\n//[^\n]*)*\ntype ` + structName + ` struct`) + blockMatch := pattern.FindString(content) + if blockMatch == "" { + return "", "", fmt.Errorf("%s struct block not found in source", structName) + } + + rulePattern := regexp.MustCompile(`\+kubebuilder:validation:XValidation:rule="([^"]+)"`) + matches := rulePattern.FindAllStringSubmatch(blockMatch, -1) + if len(matches) < 2 { + return "", "", fmt.Errorf("expected at least 2 XValidation rules on %s, found %d", structName, len(matches)) + } + + return matches[0][1], matches[1][1], nil +} + +func newCELEnvForMetadata() (*cel.Env, error) { + return cel.NewEnv(cel.Variable("self", cel.MapType(cel.StringType, cel.DynType))) +} + +func makeMetaSelf(name string) map[string]interface{} { + return map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": name, + }, + } +} + +func runNameValidationTests(t *testing.T, rule string, tests []struct { + name string + instName string + expectValid bool +}) { + t.Helper() + env, err := newCELEnvForMetadata() + require.NoError(t, err) + + ast, issues := env.Compile(rule) + require.Empty(t, issues) + + program, err := env.Program(ast) + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, _, err := program.Eval(map[string]interface{}{ + "self": makeMetaSelf(tt.instName), + }) + require.NoError(t, err) + + result := out.Value().(bool) + if tt.expectValid { + assert.True(t, result, "Expected name %q to be valid (length %d)", tt.instName, len(tt.instName)) + } else { + assert.False(t, result, "Expected name %q to be invalid (length %d)", tt.instName, len(tt.instName)) + } + }) + } +} + +func TestMonitoringStackNameLengthValidation(t *testing.T) { + lengthRule, _, err := extractValidationRules("MonitoringStack") + require.NoError(t, err, "failed to extract name length rule from source annotation") + + runNameValidationTests(t, lengthRule, []struct { + name string + instName string + expectValid bool + }{ + {"short valid name", "my-stack", true}, + {"single character", "a", true}, + {"exactly 63 characters", strings.Repeat("a", 63), true}, + {"64 characters - too long", strings.Repeat("a", 64), false}, + {"253 character name", strings.Repeat("a", 253), false}, + }) +} + +func TestMonitoringStackNameFormatValidation(t *testing.T) { + _, formatRule, err := extractValidationRules("MonitoringStack") + require.NoError(t, err, "failed to extract name format rule from source annotation") + + runNameValidationTests(t, formatRule, []struct { + name string + instName string + expectValid bool + }{ + {"single lowercase letter", "a", true}, + {"lowercase letters and dashes", "my-stack", true}, + {"letters numbers and dashes", "stack-1", true}, + {"starts with digit", "1stack", true}, + {"ends with digit", "stack1", true}, + {"uppercase letter - invalid", "MyStack", false}, + {"starts with dash - invalid", "-stack", false}, + {"ends with dash - invalid", "stack-", false}, + {"underscore - invalid", "my_stack", false}, + {"dot separator - invalid", "my.stack", false}, + }) +} + +func TestThanosQuerierNameLengthValidation(t *testing.T) { + lengthRule, _, err := extractValidationRules("ThanosQuerier") + require.NoError(t, err, "failed to extract name length rule from source annotation") + + runNameValidationTests(t, lengthRule, []struct { + name string + instName string + expectValid bool + }{ + {"short valid name", "my-querier", true}, + {"single character", "a", true}, + {"exactly 63 characters", strings.Repeat("a", 63), true}, + {"64 characters - too long", strings.Repeat("a", 64), false}, + {"253 character name", strings.Repeat("a", 253), false}, + }) +} + +func TestThanosQuerierNameFormatValidation(t *testing.T) { + _, formatRule, err := extractValidationRules("ThanosQuerier") + require.NoError(t, err, "failed to extract name format rule from source annotation") + + runNameValidationTests(t, formatRule, []struct { + name string + instName string + expectValid bool + }{ + {"single lowercase letter", "q", true}, + {"lowercase letters and dashes", "my-querier", true}, + {"letters and numbers", "querier1", true}, + {"starts with digit", "1querier", true}, + {"ends with digit", "querier1", true}, + {"uppercase letter - invalid", "MyQuerier", false}, + {"starts with dash - invalid", "-querier", false}, + {"ends with dash - invalid", "querier-", false}, + {"underscore - invalid", "my_querier", false}, + {"dot separator - invalid", "my.querier", false}, + }) +} diff --git a/pkg/controllers/monitoring/monitoring-stack/components.go b/pkg/controllers/monitoring/monitoring-stack/components.go index 35929dea6..4bc967c30 100644 --- a/pkg/controllers/monitoring/monitoring-stack/components.go +++ b/pkg/controllers/monitoring/monitoring-stack/components.go @@ -47,7 +47,7 @@ func stackComponentReconcilers( thanos ThanosConfiguration, prometheus PrometheusConfiguration, alertmanager AlertmanagerConfiguration, -) []reconciler.Reconciler { +) ([]reconciler.Reconciler, error) { prometheusName := ms.Name + "-prometheus" alertmanagerName := ms.Name + "-alertmanager" additionalScrapeConfigsSecretName := ms.Name + "-self-scrape" @@ -55,36 +55,33 @@ func stackComponentReconcilers( createCRB := hasNsSelector && ms.Spec.CreateClusterRoleBindings == stack.CreateClusterRoleBindings deployAlertmanager := !ms.Spec.AlertmanagerConfig.Disabled - return []reconciler.Reconciler{ - // Create RBAC - reconciler.NewUpdater(newServiceAccount(prometheusName, ms.Namespace), ms), - reconciler.NewOptionalUpdater(newServiceAccount(alertmanagerName, ms.Namespace), ms, deployAlertmanager), - - reconciler.NewUpdater(newPrometheusClusterRole(prometheusName, rbacVerbs), ms), - // create clusterrolebinding if nsSelector's present otherwise a rolebinding - reconciler.NewOptionalUpdater(newClusterRoleBinding(ms, prometheusName), ms, createCRB), - reconciler.NewOptionalUpdater(newRoleBindingForClusterRole(ms, prometheusName), ms, !createCRB), - - reconciler.NewOptionalUpdater(newAlertManagerClusterRole(alertmanagerName, rbacVerbs), ms, deployAlertmanager), - // create clusterrolebinding if alertmanager is enabled and namespace selector is also present in MonitoringStack - reconciler.NewOptionalUpdater(newClusterRoleBinding(ms, alertmanagerName), ms, deployAlertmanager && createCRB), - reconciler.NewOptionalUpdater(newRoleBindingForClusterRole(ms, alertmanagerName), ms, deployAlertmanager && !createCRB), - - // Prometheus Deployment - reconciler.NewUpdater(newPrometheus(ms, prometheusName, - additionalScrapeConfigsSecretName, - thanos, prometheus), ms), - reconciler.NewUpdater(newPrometheusService(ms), ms), - reconciler.NewUpdater(newThanosSidecarService(ms), ms), - reconciler.NewUpdater(newAdditionalScrapeConfigsSecret(ms, additionalScrapeConfigsSecretName), ms), - reconciler.NewOptionalUpdater(newPrometheusPDB(ms), ms, - *ms.Spec.PrometheusConfig.Replicas > 1), - - // Alertmanager Deployment - reconciler.NewOptionalUpdater(newAlertmanager(ms, alertmanagerName, alertmanager), ms, deployAlertmanager), - reconciler.NewOptionalUpdater(newAlertmanagerService(ms), ms, deployAlertmanager), - reconciler.NewOptionalUpdater(newAlertmanagerPDB(ms), ms, deployAlertmanager && *ms.Spec.AlertmanagerConfig.Replicas > 1), - } + b := &reconciler.ReconcilerBuilder{} + // Create RBAC + b.Add(reconciler.NewUpdater(newServiceAccount(prometheusName, ms.Namespace), ms)) + b.Add(reconciler.NewOptionalUpdater(newServiceAccount(alertmanagerName, ms.Namespace), ms, deployAlertmanager)) + + b.Add(reconciler.NewUpdater(newPrometheusClusterRole(prometheusName, rbacVerbs), ms)) + // create clusterrolebinding if nsSelector's present otherwise a rolebinding + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(ms, prometheusName), ms, createCRB)) + b.Add(reconciler.NewOptionalUpdater(newRoleBindingForClusterRole(ms, prometheusName), ms, !createCRB)) + + b.Add(reconciler.NewOptionalUpdater(newAlertManagerClusterRole(alertmanagerName, rbacVerbs), ms, deployAlertmanager)) + // create clusterrolebinding if alertmanager is enabled and namespace selector is also present in MonitoringStack + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(ms, alertmanagerName), ms, deployAlertmanager && createCRB)) + b.Add(reconciler.NewOptionalUpdater(newRoleBindingForClusterRole(ms, alertmanagerName), ms, deployAlertmanager && !createCRB)) + + // Prometheus Deployment + b.Add(reconciler.NewUpdater(newPrometheus(ms, prometheusName, additionalScrapeConfigsSecretName, thanos, prometheus), ms)) + b.Add(reconciler.NewUpdater(newPrometheusService(ms), ms)) + b.Add(reconciler.NewUpdater(newThanosSidecarService(ms), ms)) + b.Add(reconciler.NewUpdater(newAdditionalScrapeConfigsSecret(ms, additionalScrapeConfigsSecretName), ms)) + b.Add(reconciler.NewOptionalUpdater(newPrometheusPDB(ms), ms, *ms.Spec.PrometheusConfig.Replicas > 1)) + + // Alertmanager Deployment + b.Add(reconciler.NewOptionalUpdater(newAlertmanager(ms, alertmanagerName, alertmanager), ms, deployAlertmanager)) + b.Add(reconciler.NewOptionalUpdater(newAlertmanagerService(ms), ms, deployAlertmanager)) + b.Add(reconciler.NewOptionalUpdater(newAlertmanagerPDB(ms), ms, deployAlertmanager && *ms.Spec.AlertmanagerConfig.Replicas > 1)) + return b.Build() } func newPrometheusClusterRole(rbacResourceName string, rbacVerbs []string) *rbacv1.ClusterRole { diff --git a/pkg/controllers/monitoring/monitoring-stack/controller.go b/pkg/controllers/monitoring/monitoring-stack/controller.go index 1bbc4a5d4..044554259 100644 --- a/pkg/controllers/monitoring/monitoring-stack/controller.go +++ b/pkg/controllers/monitoring/monitoring-stack/controller.go @@ -176,11 +176,14 @@ func (rm resourceManager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl } } - reconcilers := stackComponentReconcilers(ms, + reconcilers, err := stackComponentReconcilers(ms, rm.thanos, rm.prometheus, rm.alertmanager, ) + if err != nil { + return ctrl.Result{}, err + } for _, reconciler := range reconcilers { err := reconciler.Reconcile(ctx, rm.k8sClient, rm.scheme) // handle create / update errors that can happen due to a stale cache by diff --git a/pkg/controllers/monitoring/thanos-querier/components.go b/pkg/controllers/monitoring/thanos-querier/components.go index dd4725968..ed215b59f 100644 --- a/pkg/controllers/monitoring/thanos-querier/components.go +++ b/pkg/controllers/monitoring/thanos-querier/components.go @@ -18,15 +18,15 @@ func thanosComponentReconcilers( sidecarUrls []string, thanosCfg ThanosConfiguration, tlsHashes map[string]string, -) []reconciler.Reconciler { +) ([]reconciler.Reconciler, error) { name := "thanos-querier-" + thanos.Name - return []reconciler.Reconciler{ - reconciler.NewUpdater(newServiceAccount(name, thanos.Namespace), thanos), - reconciler.NewUpdater(newThanosQuerierDeployment(name, thanos, sidecarUrls, thanosCfg, tlsHashes), thanos), - reconciler.NewUpdater(newService(name, thanos.Namespace), thanos), - reconciler.NewUpdater(newServiceMonitor(name, thanos.Namespace, thanos), thanos), - reconciler.NewOptionalUpdater(newHttpConfConfigMap(name, thanos), thanos, thanos.Spec.WebTLSConfig != nil), - } + b := &reconciler.ReconcilerBuilder{} + b.Add(reconciler.NewUpdater(newServiceAccount(name, thanos.Namespace), thanos)) + b.Add(reconciler.NewUpdater(newThanosQuerierDeployment(name, thanos, sidecarUrls, thanosCfg, tlsHashes), thanos)) + b.Add(reconciler.NewUpdater(newService(name, thanos.Namespace), thanos)) + b.Add(reconciler.NewUpdater(newServiceMonitor(name, thanos.Namespace, thanos), thanos)) + b.Add(reconciler.NewOptionalUpdater(newHttpConfConfigMap(name, thanos), thanos, thanos.Spec.WebTLSConfig != nil)) + return b.Build() } func newHttpConfConfigMap(name string, thanos *msoapi.ThanosQuerier) *corev1.ConfigMap { diff --git a/pkg/controllers/monitoring/thanos-querier/controller.go b/pkg/controllers/monitoring/thanos-querier/controller.go index 5e679d907..83b3e1a25 100644 --- a/pkg/controllers/monitoring/thanos-querier/controller.go +++ b/pkg/controllers/monitoring/thanos-querier/controller.go @@ -178,7 +178,10 @@ func (rm resourceManager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl } } - reconcilers := thanosComponentReconcilers(querier, sidecarServices, rm.thanos, tlsHashes) + reconcilers, err := thanosComponentReconcilers(querier, sidecarServices, rm.thanos, tlsHashes) + if err != nil { + return ctrl.Result{}, err + } for _, reconciler := range reconcilers { err := reconciler.Reconcile(ctx, rm, rm.scheme) // handle creation / updation errors that can happen due to a stale cache by diff --git a/pkg/controllers/observability/reconcilers.go b/pkg/controllers/observability/reconcilers.go index 4a6e3ef73..392410832 100644 --- a/pkg/controllers/observability/reconcilers.go +++ b/pkg/controllers/observability/reconcilers.go @@ -120,15 +120,27 @@ func getReconcilers(ctx context.Context, k8sClient client.Client, k8sReader clie if tracing := instance.Spec.GetCapabilities().GetTracing(); tracing != nil && tracing.Enabled { // install operators and instances if operatorsStatus.ShouldInstall("opentelemetry") { - reconcilers = append(reconcilers, reconciler.NewCreateUpdateReconciler(otelSubs, instance)) + r, err := reconciler.NewCreateUpdateReconciler(otelSubs, instance) + if err != nil { + return nil, err + } + reconcilers = append(reconcilers, r) installedObjects[gvkNameIdentifier(otelSubs)] = otelSubs } if operatorsStatus.ShouldInstall("tempo") { - reconcilers = append(reconcilers, reconciler.NewCreateUpdateReconciler(tempoSubs, instance)) + r, err := reconciler.NewCreateUpdateReconciler(tempoSubs, instance) + if err != nil { + return nil, err + } + reconcilers = append(reconcilers, r) installedObjects[gvkNameIdentifier(tempoSubs)] = tempoSubs } for _, obj := range instanceObjects { - reconcilers = append(reconcilers, reconciler.NewUpdater(obj, instance)) + r, err := reconciler.NewUpdater(obj, instance) + if err != nil { + return nil, err + } + reconcilers = append(reconcilers, r) installedObjects[gvkNameIdentifier(obj)] = obj } } @@ -138,11 +150,19 @@ func getReconcilers(ctx context.Context, k8sClient client.Client, k8sReader clie (tracing.GetOperators().Install != nil && *tracing.GetOperators().Install) { // install operators only if operatorsStatus.ShouldInstall("opentelemetry") { - reconcilers = append(reconcilers, reconciler.NewCreateUpdateReconciler(otelSubs, instance)) + r, err := reconciler.NewCreateUpdateReconciler(otelSubs, instance) + if err != nil { + return nil, err + } + reconcilers = append(reconcilers, r) installedObjects[gvkNameIdentifier(otelSubs)] = otelSubs } if operatorsStatus.ShouldInstall("tempo") { - reconcilers = append(reconcilers, reconciler.NewCreateUpdateReconciler(tempoSubs, instance)) + r, err := reconciler.NewCreateUpdateReconciler(tempoSubs, instance) + if err != nil { + return nil, err + } + reconcilers = append(reconcilers, r) installedObjects[gvkNameIdentifier(tempoSubs)] = tempoSubs } } diff --git a/pkg/controllers/operator/components.go b/pkg/controllers/operator/components.go index 890380132..800189952 100644 --- a/pkg/controllers/operator/components.go +++ b/pkg/controllers/operator/components.go @@ -16,12 +16,12 @@ const ( name = "observability-operator" ) -func operatorComponentReconcilers(owner metav1.Object, namespace string) []reconciler.Reconciler { - return []reconciler.Reconciler{ - reconciler.NewUpdater(newServiceMonitor(namespace), owner), - reconciler.NewUpdater(newPrometheusRole(namespace), owner), - reconciler.NewUpdater(newRoleBindingForPrometheusRole(namespace), owner), - } +func operatorComponentReconcilers(owner metav1.Object, namespace string) ([]reconciler.Reconciler, error) { + b := &reconciler.ReconcilerBuilder{} + b.Add(reconciler.NewUpdater(newServiceMonitor(namespace), owner)) + b.Add(reconciler.NewUpdater(newPrometheusRole(namespace), owner)) + b.Add(reconciler.NewUpdater(newRoleBindingForPrometheusRole(namespace), owner)) + return b.Build() } func newServiceMonitor(namespace string) *monv1.ServiceMonitor { diff --git a/pkg/controllers/operator/controller.go b/pkg/controllers/operator/controller.go index 96a505769..5e7d08107 100644 --- a/pkg/controllers/operator/controller.go +++ b/pkg/controllers/operator/controller.go @@ -93,7 +93,10 @@ func (rm resourceManager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl return ctrl.Result{}, err } - reconcilers := operatorComponentReconcilers(op, rm.namespace) + reconcilers, err := operatorComponentReconcilers(op, rm.namespace) + if err != nil { + return ctrl.Result{}, err + } for _, reconciler := range reconcilers { err := reconciler.Reconcile(ctx, rm.k8sClient, rm.scheme) // handle create / update errors that can happen due to a stale cache by diff --git a/pkg/controllers/uiplugin/components.go b/pkg/controllers/uiplugin/components.go index a89b56890..295ac0167 100644 --- a/pkg/controllers/uiplugin/components.go +++ b/pkg/controllers/uiplugin/components.go @@ -65,53 +65,52 @@ func IsVersionAheadOrEqual(currentVersion, version string) bool { return semver.Compare(currentVersion, canonicalMinVersion) >= 0 } -func pluginComponentReconcilers(plugin *uiv1alpha1.UIPlugin, pluginInfo UIPluginInfo, clusterVersion string, logger logr.Logger) []reconciler.Reconciler { +func pluginComponentReconcilers(plugin *uiv1alpha1.UIPlugin, pluginInfo UIPluginInfo, clusterVersion string, logger logr.Logger) ([]reconciler.Reconciler, error) { namespace := pluginInfo.ResourceNamespace + b := &reconciler.ReconcilerBuilder{} - components := []reconciler.Reconciler{ - reconciler.NewUpdater(newServiceAccount(pluginInfo.Name, namespace), plugin), - reconciler.NewUpdater(newDeployment(pluginInfo, namespace, plugin.Spec.Deployment), plugin), - reconciler.NewUpdater(newService(pluginInfo, namespace), plugin), - } + b.Add(reconciler.NewUpdater(newServiceAccount(pluginInfo.Name, namespace), plugin)) + b.Add(reconciler.NewUpdater(newDeployment(pluginInfo, namespace, plugin.Spec.Deployment), plugin)) + b.Add(reconciler.NewUpdater(newService(pluginInfo, namespace), plugin)) if IsVersionAheadOrEqual(clusterVersion, "v4.19") { - components = append(components, reconciler.NewUpdater(newConsolePlugin(pluginInfo, namespace), plugin)) + b.Add(reconciler.NewUpdater(newConsolePlugin(pluginInfo, namespace), plugin)) } else if IsVersionAheadOrEqual(clusterVersion, "v4.17") { - components = append(components, reconciler.NewUpdater(newRhobsConsolePlugin(pluginInfo, namespace), plugin)) + b.Add(reconciler.NewUpdater(newRhobsConsolePlugin(pluginInfo, namespace), plugin)) } else { - components = append(components, reconciler.NewUpdater(newLegacyConsolePlugin(pluginInfo, namespace), plugin)) + b.Add(reconciler.NewUpdater(newLegacyConsolePlugin(pluginInfo, namespace), plugin)) } if pluginInfo.Role != nil { - components = append(components, reconciler.NewUpdater(newRole(pluginInfo), plugin)) + b.Add(reconciler.NewUpdater(newRole(pluginInfo), plugin)) } if pluginInfo.RoleBinding != nil { - components = append(components, reconciler.NewUpdater(newRoleBinding(pluginInfo), plugin)) + b.Add(reconciler.NewUpdater(newRoleBinding(pluginInfo), plugin)) } if pluginInfo.ConfigMap != nil { - components = append(components, reconciler.NewUpdater(pluginInfo.ConfigMap, plugin)) + b.Add(reconciler.NewUpdater(pluginInfo.ConfigMap, plugin)) } for _, role := range pluginInfo.ClusterRoles { if role != nil { - components = append(components, reconciler.NewUpdater(role, plugin)) + b.Add(reconciler.NewUpdater(role, plugin)) } } for _, roleBinding := range pluginInfo.ClusterRoleBindings { if roleBinding != nil { - components = append(components, reconciler.NewUpdater(roleBinding, plugin)) + b.Add(reconciler.NewUpdater(roleBinding, plugin)) } } if plugin.Spec.Type == uiv1alpha1.TypeTroubleshootingPanel && pluginInfo.Korrel8rImage != "" { - components = append(components, reconciler.NewUpdater(newKorrel8rService(korrel8rName, namespace), plugin)) + b.Add(reconciler.NewUpdater(newKorrel8rService(korrel8rName, namespace), plugin)) korrel8rCm, err := newKorrel8rConfigMap(korrel8rName, namespace, pluginInfo) if err == nil && korrel8rCm != nil { - components = append(components, reconciler.NewUpdater(korrel8rCm, plugin)) - components = append(components, reconciler.NewUpdater(newKorrel8rDeployment(korrel8rName, namespace, pluginInfo), plugin)) + b.Add(reconciler.NewUpdater(korrel8rCm, plugin)) + b.Add(reconciler.NewUpdater(newKorrel8rDeployment(korrel8rName, namespace, pluginInfo), plugin)) } } @@ -132,51 +131,44 @@ func pluginComponentReconcilers(plugin *uiv1alpha1.UIPlugin, pluginInfo UIPlugin deployHealthAnalyzer := incidentsEnabled || healthAnalyzerEnabled - components = append(components, - reconciler.NewOptionalUpdater(componentsHealthClusterRole("components-health-view"), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "components-health-view", plugin.Name+"-"+"components-health-view"), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newComponentHealthConfig(namespace), plugin, deployHealthAnalyzer), - ) - - components = append(components, - reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "cluster-monitoring-view", plugin.Name+"cluster-monitoring-view"), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "system:auth-delegator", serviceAccountName+"-system-auth-delegator"), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newAlertManagerViewRoleBinding(serviceAccountName, namespace), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newHealthAnalyzerPrometheusRole(namespace), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newHealthAnalyzerPrometheusRoleBinding(namespace), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newHealthAnalyzerService(namespace), plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newHealthAnalyzerDeployment(namespace, serviceAccountName, pluginInfo), - plugin, deployHealthAnalyzer), - reconciler.NewOptionalUpdater(newHealthAnalyzerServiceMonitor(namespace), plugin, deployHealthAnalyzer), - ) + b.Add(reconciler.NewOptionalUpdater(componentsHealthClusterRole("components-health-view"), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "components-health-view", plugin.Name+"-"+"components-health-view"), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newComponentHealthConfig(namespace), plugin, deployHealthAnalyzer)) + + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "cluster-monitoring-view", plugin.Name+"-cluster-monitoring-view"), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, serviceAccountName, "system:auth-delegator", serviceAccountName+"-system-auth-delegator"), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newAlertManagerViewRoleBinding(serviceAccountName, namespace), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newHealthAnalyzerPrometheusRole(namespace), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newHealthAnalyzerPrometheusRoleBinding(namespace), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newHealthAnalyzerService(namespace), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newHealthAnalyzerDeployment(namespace, serviceAccountName, pluginInfo), plugin, deployHealthAnalyzer)) + b.Add(reconciler.NewOptionalUpdater(newHealthAnalyzerServiceMonitor(namespace), plugin, deployHealthAnalyzer)) persesServiceAccountName := "perses" + serviceAccountSuffix persesEnabled := monitoringConfig != nil && monitoringConfig.Perses != nil && monitoringConfig.Perses.Enabled - components = append(components, - reconciler.NewOptionalUpdater(newServiceAccount("perses", namespace), plugin, persesEnabled), - reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, persesServiceAccountName, "system:auth-delegator", persesServiceAccountName+"-system-auth-delegator"), plugin, persesEnabled), - reconciler.NewOptionalUpdater(newPersesClusterRole(), plugin, persesEnabled), - reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, persesServiceAccountName, "perses-cr", persesServiceAccountName+"-perses-cr"), plugin, persesEnabled), - reconciler.NewOptionalUpdater(newPerses(namespace, pluginInfo.PersesImage), plugin, persesEnabled), - reconciler.NewOptionalUpdater(newAcceleratorsDatasource(namespace), plugin, persesEnabled), - ) + b.Add(reconciler.NewOptionalUpdater(newServiceAccount("perses", namespace), plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, persesServiceAccountName, "system:auth-delegator", persesServiceAccountName+"-system-auth-delegator"), plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(newPersesClusterRole(), plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(newClusterRoleBinding(namespace, persesServiceAccountName, "perses-cr", persesServiceAccountName+"-perses-cr"), plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(newPerses(namespace, pluginInfo.PersesImage), plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(newAcceleratorsDatasource(namespace), plugin, persesEnabled)) acceleratorsDashboard, err := newAcceleratorsDashboard(namespace) if err != nil { logger.Error(err, "Cannot build Accelerators dashboard") } else { - components = append(components, reconciler.NewOptionalUpdater(acceleratorsDashboard, plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(acceleratorsDashboard, plugin, persesEnabled)) } apmDashboard, err := newAPMDashboard(namespace) if err != nil { logger.Error(err, "Cannot build APM dashboard") } else { - components = append(components, reconciler.NewOptionalUpdater(apmDashboard, plugin, persesEnabled)) + b.Add(reconciler.NewOptionalUpdater(apmDashboard, plugin, persesEnabled)) } } - return components + return b.Build() } func newClusterRoleBinding(namespace string, serviceAccountName string, roleName string, name string) *rbacv1.ClusterRoleBinding { diff --git a/pkg/controllers/uiplugin/controller.go b/pkg/controllers/uiplugin/controller.go index 47bb9901c..235e55397 100644 --- a/pkg/controllers/uiplugin/controller.go +++ b/pkg/controllers/uiplugin/controller.go @@ -256,7 +256,10 @@ func (rm resourceManager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl pluginInfo, pluginInfoErr := PluginInfoBuilder(ctx, rm.k8sClient, rm.k8sDynamicClient, plugin, rm.pluginConf, compatibilityInfo, rm.clusterVersion, rm.logger) if pluginInfo != nil { - reconcilers := pluginComponentReconcilers(plugin, *pluginInfo, rm.clusterVersion, rm.logger) + reconcilers, err := pluginComponentReconcilers(plugin, *pluginInfo, rm.clusterVersion, rm.logger) + if err != nil { + return rm.updateStatus(ctx, req, plugin, err), err + } for _, reconciler := range reconcilers { err := reconciler.Reconcile(ctx, rm.k8sClient, rm.scheme) // handle creation / updation errors that can happen due to a stale cache by @@ -367,7 +370,11 @@ func (rm resourceManager) registerPluginWithConsole(ctx context.Context, pluginI // Register the plugin with the console cluster.Spec.Plugins = clusterPlugins - if err := reconciler.NewMerger(cluster, pluginInfo.ConsoleName).Reconcile(ctx, rm.k8sClient, rm.scheme); err != nil { + merger, err := reconciler.NewMerger(cluster, pluginInfo.ConsoleName) + if err != nil { + return err + } + if err := merger.Reconcile(ctx, rm.k8sClient, rm.scheme); err != nil { return err } @@ -391,7 +398,11 @@ func (rm resourceManager) deregisterPluginFromConsole(ctx context.Context, plugi // Deregister the plugin from the console cluster.Spec.Plugins = clusterPlugins - if err := reconciler.NewMerger(cluster, pluginConsoleName).Reconcile(ctx, rm.k8sClient, rm.scheme); err != nil { + merger, err := reconciler.NewMerger(cluster, pluginConsoleName) + if err != nil { + return err + } + if err := merger.Reconcile(ctx, rm.k8sClient, rm.scheme); err != nil { return err } diff --git a/pkg/controllers/util/common.go b/pkg/controllers/util/common.go index 01a9385a7..0877b3617 100644 --- a/pkg/controllers/util/common.go +++ b/pkg/controllers/util/common.go @@ -1,6 +1,8 @@ package util import ( + "fmt" + "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -9,7 +11,12 @@ const ( OpName = "observability-operator" ) -func AddCommonLabels(obj client.Object, name string) client.Object { +// AddCommonLabels sets standard observability-operator labels on obj. +// It returns an error if name exceeds 63 characters, the Kubernetes label value limit. +func AddCommonLabels(obj client.Object, name string) (client.Object, error) { + if len(name) > 63 { + return nil, fmt.Errorf("resource name %q exceeds the 63-character limit for Kubernetes label values", name) + } labels := obj.GetLabels() want := map[string]string{ "app.kubernetes.io/part-of": name, @@ -18,12 +25,12 @@ func AddCommonLabels(obj client.Object, name string) client.Object { } if labels == nil { obj.SetLabels(want) - return obj + return obj, nil } for name, val := range want { if _, ok := labels[name]; !ok { labels[name] = val } } - return obj + return obj, nil } diff --git a/pkg/controllers/util/common_test.go b/pkg/controllers/util/common_test.go new file mode 100644 index 000000000..86c1cc55d --- /dev/null +++ b/pkg/controllers/util/common_test.go @@ -0,0 +1,104 @@ +package util + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddCommonLabels(t *testing.T) { + tests := []struct { + name string + ownerName string + expectError bool + }{ + { + name: "short valid name", + ownerName: "my-stack", + expectError: false, + }, + { + name: "exactly 63 characters", + ownerName: strings.Repeat("a", 63), + expectError: false, + }, + { + name: "64 characters - too long", + ownerName: strings.Repeat("a", 64), + expectError: true, + }, + { + name: "108 character name from bug report", + ownerName: "test-very-long-name-that-exceeds-normal-kubernetes-resource-name-limits-and-should-be-validated-properly", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-resource", + Namespace: "default", + }, + } + + result, err := AddCommonLabels(obj, tt.ownerName) + if tt.expectError { + require.Error(t, err) + assert.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + labels := result.GetLabels() + assert.Equal(t, tt.ownerName, labels["app.kubernetes.io/part-of"]) + assert.Equal(t, "test-resource", labels["app.kubernetes.io/name"]) + assert.Equal(t, OpName, labels[ResourceLabel]) + } + }) + } +} + +func TestAddCommonLabels_PreservesExistingLabels(t *testing.T) { + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{ + "existing-label": "existing-value", + "app.kubernetes.io/part-of": "already-set", + }, + }, + } + + result, err := AddCommonLabels(obj, "new-owner") + require.NoError(t, err) + + labels := result.GetLabels() + assert.Equal(t, "existing-value", labels["existing-label"], "existing label must be preserved") + assert.Equal(t, "already-set", labels["app.kubernetes.io/part-of"], "pre-set part-of must not be overwritten") + assert.Equal(t, "test", labels["app.kubernetes.io/name"]) + assert.Equal(t, OpName, labels[ResourceLabel]) +} + +func TestAddCommonLabels_NilLabels(t *testing.T) { + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + + result, err := AddCommonLabels(obj, "my-owner") + require.NoError(t, err) + + labels := result.GetLabels() + assert.Equal(t, "my-owner", labels["app.kubernetes.io/part-of"]) + assert.Equal(t, "test", labels["app.kubernetes.io/name"]) + assert.Equal(t, OpName, labels[ResourceLabel]) +} diff --git a/pkg/reconciler/create_update_reconciler.go b/pkg/reconciler/create_update_reconciler.go index 2891a9f2f..cb838566f 100644 --- a/pkg/reconciler/create_update_reconciler.go +++ b/pkg/reconciler/create_update_reconciler.go @@ -33,9 +33,13 @@ func (r createUpdateReconciler) Reconcile(ctx context.Context, c client.Client, return err } -func NewCreateUpdateReconciler(resource client.Object, owner metav1.Object) Reconciler { +func NewCreateUpdateReconciler(resource client.Object, owner metav1.Object) (Reconciler, error) { + labeled, err := util.AddCommonLabels(resource, owner.GetName()) + if err != nil { + return nil, err + } return createUpdateReconciler{ resourceOwner: owner, - resource: util.AddCommonLabels(resource, owner.GetName()), - } + resource: labeled, + }, nil } diff --git a/pkg/reconciler/reconciler.go b/pkg/reconciler/reconciler.go index 8a2ca832d..250266d42 100644 --- a/pkg/reconciler/reconciler.go +++ b/pkg/reconciler/reconciler.go @@ -58,21 +58,25 @@ func (r Updater) Reconcile(ctx context.Context, c client.Client, scheme *runtime return nil } -func NewUpdater(resource client.Object, owner metav1.Object) Updater { +func NewUpdater(resource client.Object, owner metav1.Object) (Updater, error) { return newUpdater(resource, owner, false) } // NewUnmanagedUpdater creates an Updater that does not set a controller reference. -func NewUnmanagedUpdater(resource client.Object, owner metav1.Object) Updater { +func NewUnmanagedUpdater(resource client.Object, owner metav1.Object) (Updater, error) { return newUpdater(resource, owner, true) } -func newUpdater(resource client.Object, owner metav1.Object, bypassOwnerRef bool) Updater { +func newUpdater(resource client.Object, owner metav1.Object, bypassOwnerRef bool) (Updater, error) { + labeled, err := util.AddCommonLabels(resource, owner.GetName()) + if err != nil { + return Updater{}, err + } return Updater{ resourceOwner: owner, - resource: util.AddCommonLabels(resource, owner.GetName()), + resource: labeled, shouldBypassSetCtrlRef: bypassOwnerRef, - } + }, nil } // Deleter deletes a resource and ignores NotFound errors. @@ -97,8 +101,12 @@ type Merger struct { resource client.Object } -func NewMerger(r client.Object, owner string) Merger { - return Merger{resource: util.AddCommonLabels(r, owner)} +func NewMerger(r client.Object, owner string) (Merger, error) { + labeled, err := util.AddCommonLabels(r, owner) + if err != nil { + return Merger{}, err + } + return Merger{resource: labeled}, nil } func (r Merger) Reconcile(ctx context.Context, c client.Client, scheme *runtime.Scheme) error { @@ -111,16 +119,42 @@ func (r Merger) Reconcile(ctx context.Context, c client.Client, scheme *runtime. } // NewOptionalUpdater ensures that a resource is present or absent depending on the `cond` value (true: present). -func NewOptionalUpdater(r client.Object, c metav1.Object, cond bool) Reconciler { +func NewOptionalUpdater(r client.Object, c metav1.Object, cond bool) (Reconciler, error) { if cond { return NewUpdater(r, c) } - return NewDeleter(r) + return NewDeleter(r), nil } -func NewOptionalUnmanagedUpdater(r client.Object, c metav1.Object, cond bool) Reconciler { +func NewOptionalUnmanagedUpdater(r client.Object, c metav1.Object, cond bool) (Reconciler, error) { if cond { return NewUnmanagedUpdater(r, c) } - return NewDeleter(r) + return NewDeleter(r), nil +} + +// ReconcilerBuilder accumulates Reconciler instances and the first error encountered. +// It allows constructing a list of reconcilers without verbose per-call error handling. +type ReconcilerBuilder struct { + recs []Reconciler + err error +} + +// Add appends r to the builder if no prior error was recorded. If err is non-nil it is +// stored and subsequent Add calls are no-ops. +func (b *ReconcilerBuilder) Add(r Reconciler, err error) *ReconcilerBuilder { + if b.err != nil { + return b + } + if err != nil { + b.err = err + return b + } + b.recs = append(b.recs, r) + return b +} + +// Build returns the accumulated reconcilers and the first error encountered, if any. +func (b *ReconcilerBuilder) Build() ([]Reconciler, error) { + return b.recs, b.err }