Skip to content

ESO-281:Adds the Implementation Logic for Annotations #97

Open
siddhibhor-56 wants to merge 6 commits intoopenshift:mainfrom
siddhibhor-56:eso-annotations
Open

ESO-281:Adds the Implementation Logic for Annotations #97
siddhibhor-56 wants to merge 6 commits intoopenshift:mainfrom
siddhibhor-56:eso-annotations

Conversation

@siddhibhor-56
Copy link
Contributor

@siddhibhor-56 siddhibhor-56 commented Jan 8, 2026

This PR adds support for custom annotations in ExternalSecretsConfig, allowing users to add custom metadata annotations to all resources created by the external-secrets-operator.

@coderabbitai
Copy link

coderabbitai bot commented Jan 8, 2026

Walkthrough

Converted controller annotations from an array type to a map, added annotation validation and filtering for reserved domains, introduced utilities to merge/filter annotations, and propagated validated annotations across many reconciled resources with accompanying unit and e2e tests updated.

Changes

Cohort / File(s) Summary
API & CRD schema
api/v1alpha1/external_secrets_config_types.go, config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml, bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml, api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml
Replaced Annotation array/type with map-based annotations (keys:string, values:string); changed validation from prefix-based to domain-based restrictions (kubernetes.io, openshift.io, cert-manager.io, k8s.io) and updated CRD validation/messages and tests.
Docs
docs/api_reference.md
Removed standalone Annotation documentation and updated ApplicationConfig.annotations / ControllerConfig.annotations signatures to object map with MaxProperties/MinProperties and updated descriptions.
Common utilities
pkg/controller/common/utils.go, pkg/controller/common/utils_test.go
Added UpdateResourceAnnotations() and FilterReservedAnnotations(); adjusted metadata comparison to ignore reserved/system annotations and treat nil vs empty maps equivalently; updated HasObjectChanged behavior and added tests.
Deployment & PodTemplate handling
pkg/controller/external_secrets/deployments.go, pkg/controller/common/...
Added validateAndFilterAnnotations() and updatePodTemplateAnnotations(); apply validated annotations to Deployment metadata and PodTemplate annotations.
RBAC resources
pkg/controller/external_secrets/rbacs.go, tests pkg/controller/external_secrets/rbacs_test.go
Extended RBAC getters to accept esc and apply validated ControllerConfig annotations to ClusterRole, ClusterRoleBinding, Role, RoleBinding; added tests for annotation application and filtering.
Namespace / Installer changes
pkg/controller/external_secrets/install_external_secrets.go
Applied resource labels and validated/filtered ControllerConfig annotations to created Namespace object.
Certificates & Cert-related logic
pkg/controller/external_secrets/certificate.go, pkg/controller/external_secrets/certificate_test.go
Apply validated ControllerConfig annotations to Certificate resources; added tests ensuring annotations applied and reserved prefixes filtered.
Secrets, Services, ServiceAccounts, ConfigMaps, NetworkPolicies, Webhooks
pkg/controller/external_secrets/secret.go, .../services.go, .../serviceaccounts.go, .../configmap.go, .../networkpolicy.go, .../validatingwebhook.go, related tests (*_test.go)
Validate/filter ControllerConfig annotations and apply to desired resources before create/update; updated ConfigMap merge behavior on updates; added unit tests per resource to assert annotation propagation and reserved-domain filtering.
Installer RBAC & service creation flows
pkg/controller/external_secrets/... (createOrApply flows)
Integrated annotation application into create/update flows across many createOrApply functions (deployments, services, secrets, RBAC, webhooks, networkpolicies, certs).
E2E tests and testdata
test/e2e/e2e_test.go, test/e2e/testdata/external_secret.yaml
Added runtime client setup and an "Annotations" e2e context to validate annotation propagation and reserved-domain validation; minor YAML whitespace changes.
Kustomize manager config
config/manager/kustomization.yaml
Removed explicit image newTag: latest and deleted trusted-ca-bundle configMapGenerator block.
CSV metadata
bundle/manifests/external-secrets-operator.clusterserviceversion.yaml
Updated ClusterServiceVersion createdAt timestamp.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.5.0)

Error: build linters: unable to load custom analyzer "kubeapilinter": bin/kube-api-linter.so, plugin: not implemented
The command is terminated due to an error: build linters: unable to load custom analyzer "kubeapilinter": bin/kube-api-linter.so, plugin: not implemented


Comment @coderabbitai help to get the list of available commands and usage tips.

@siddhibhor-56 siddhibhor-56 changed the title Adds the implementation changes for annotations Adds the Implementation Logic for Annotations Jan 8, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/controller/external_secrets/networkpolicy.go (1)

180-207: Custom network policies are missing annotation application.

The buildNetworkPolicyFromConfig function applies resourceLabels (line 195) but does not apply annotations from ControllerConfig.Annotations. This creates an inconsistency where static network policies receive annotations but custom/user-defined network policies do not.

🔧 Suggested fix
 func (r *Reconciler) buildNetworkPolicyFromConfig(esc *operatorv1alpha1.ExternalSecretsConfig, npConfig operatorv1alpha1.NetworkPolicy, resourceLabels map[string]string) (*networkingv1.NetworkPolicy, error) {
 	namespace := getNamespace(esc)

 	// Determine pod selector based on component name
 	podSelector, err := r.getPodSelectorForComponent(npConfig.ComponentName)
 	if err != nil {
 		return nil, fmt.Errorf("failed to determine pod selector for network policy %s: %w", npConfig.Name, err)
 	}

+	// Build annotations map
+	var annotations map[string]string
+	if len(esc.Spec.ControllerConfig.Annotations) > 0 {
+		annotations = convertAnnotationsToMap(esc.Spec.ControllerConfig.Annotations, r.log)
+	}
+
 	// Build the NetworkPolicy object
 	networkPolicy := &networkingv1.NetworkPolicy{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      npConfig.Name,
 			Namespace: namespace,
 			Labels:    resourceLabels,
+			Annotations: annotations,
 		},
 		Spec: networkingv1.NetworkPolicySpec{
 			PodSelector: podSelector,
 			PolicyTypes: []networkingv1.PolicyType{
 				networkingv1.PolicyTypeEgress,
 			},
 			Egress: npConfig.Egress,
 		},
 	}

 	return networkPolicy, nil
 }
🤖 Fix all issues with AI agents
In @bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml:
- Around line 1177-1182: Update the description text in the manifest so it
exactly matches the validation rule that forbids annotation key prefixes with
trailing slashes: change the listed forbidden prefixes to include the trailing
slashes and correct the app.kubernetes entry to app.kubernetes.io/; ensure the
description now states that keys with prefixes `kubernetes.io/`,
`app.kubernetes.io/`, `openshift.io/`, or `k8s.io/` are not allowed to match the
validation rule used elsewhere in this resource.

In @docs/api_reference.md:
- Line 201: The documentation for the `annotations` field incorrectly lists
prefix `app.kubernetes` and omits trailing slashes; update the description for
`annotations` (the `annotations` _[Annotation](#annotation) array_ entry) so
each disallowed prefix matches the validation rule including trailing slashes:
use `kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, and `k8s.io/`.
Ensure the sentence and example text reflect the trailing slash format for all
listed prefixes.
🧹 Nitpick comments (2)
pkg/controller/external_secrets/deployments.go (1)

173-196: Consider using strings.HasPrefix and centralizing the helper.

The manual prefix checking is correct but verbose. Additionally, this function appears to be duplicated across multiple files.

♻️ Optional improvements

1. Use strings.HasPrefix for clarity:

+import "strings"
+
 func convertAnnotationsToMap(annotations []operatorv1alpha1.Annotation, logger logr.Logger) map[string]string {
   result := make(map[string]string, len(annotations))
   
   reservedPrefixes := []string{"kubernetes.io/", "app.kubernetes.io/", "openshift.io/", "k8s.io/"}
   
   for _, ann := range annotations {
     isReserved := false
     for _, prefix := range reservedPrefixes {
-      if hasPrefix := len(ann.Key) >= len(prefix) && ann.Key[:len(prefix)] == prefix; hasPrefix {
+      if strings.HasPrefix(ann.Key, prefix) {
         isReserved = true
         logger.V(1).Info("skipping annotation with reserved prefix",
           "key", ann.Key, "prefix", prefix)
         break
       }
     }

2. Move to common package to eliminate duplication:

Since this function appears in multiple controller files with identical logic, consider moving it to pkg/controller/common/utils.go alongside UpdateResourceAnnotations.

pkg/controller/external_secrets/install_external_secrets.go (1)

126-132: Consider using common.UpdateResourceAnnotations for consistency.

While direct assignment works correctly for newly created namespaces, using common.UpdateResourceAnnotations (like other resources in this PR) would maintain a consistent pattern across the codebase.

♻️ Optional refactor for consistency
   // Apply annotations from ControllerConfig
   if len(esc.Spec.ControllerConfig.Annotations) > 0 {
     annotationsMap := convertAnnotationsToMap(esc.Spec.ControllerConfig.Annotations, r.log)
     if len(annotationsMap) > 0 {
-      obj.Annotations = annotationsMap
+      common.UpdateResourceAnnotations(obj, annotationsMap)
     }
   }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between d29a39d and 0f2c8f3.

📒 Files selected for processing (16)
  • api/v1alpha1/external_secrets_config_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml
  • config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml
  • docs/api_reference.md
  • pkg/controller/common/utils.go
  • pkg/controller/external_secrets/certificate.go
  • pkg/controller/external_secrets/deployments.go
  • pkg/controller/external_secrets/deployments_test.go
  • pkg/controller/external_secrets/install_external_secrets.go
  • pkg/controller/external_secrets/networkpolicy.go
  • pkg/controller/external_secrets/rbacs.go
  • pkg/controller/external_secrets/secret.go
  • pkg/controller/external_secrets/serviceaccounts.go
  • pkg/controller/external_secrets/services.go
  • pkg/controller/external_secrets/validatingwebhook.go
🧰 Additional context used
📓 Path-based instructions (1)
**

⚙️ CodeRabbit configuration file

-Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity.

Files:

  • pkg/controller/external_secrets/secret.go
  • pkg/controller/external_secrets/services.go
  • pkg/controller/external_secrets/serviceaccounts.go
  • pkg/controller/common/utils.go
  • pkg/controller/external_secrets/validatingwebhook.go
  • pkg/controller/external_secrets/certificate.go
  • docs/api_reference.md
  • pkg/controller/external_secrets/deployments.go
  • config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml
  • api/v1alpha1/zz_generated.deepcopy.go
  • pkg/controller/external_secrets/install_external_secrets.go
  • bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml
  • api/v1alpha1/external_secrets_config_types.go
  • pkg/controller/external_secrets/networkpolicy.go
  • pkg/controller/external_secrets/deployments_test.go
  • pkg/controller/external_secrets/rbacs.go
🧬 Code graph analysis (10)
pkg/controller/external_secrets/secret.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/services.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/serviceaccounts.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/validatingwebhook.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/certificate.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/deployments.go (2)
api/v1alpha1/external_secrets_config_types.go (2)
  • ControllerConfig (103-142)
  • Annotation (153-156)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
api/v1alpha1/zz_generated.deepcopy.go (1)
api/v1alpha1/external_secrets_config_types.go (2)
  • Annotation (153-156)
  • KVPair (145-149)
pkg/controller/external_secrets/install_external_secrets.go (1)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/external_secrets/networkpolicy.go (2)
api/v1alpha1/external_secrets_config_types.go (1)
  • ControllerConfig (103-142)
pkg/controller/common/utils.go (1)
  • UpdateResourceAnnotations (76-85)
pkg/controller/external_secrets/deployments_test.go (4)
pkg/controller/external_secrets/controller.go (1)
  • Reconciler (85-94)
pkg/controller/crd_annotator/controller.go (1)
  • Reconciler (65-69)
pkg/controller/client/fakes/fake_ctrl_client.go (1)
  • FakeCtrlClient (12-135)
api/v1alpha1/external_secrets_config_types.go (4)
  • ExternalSecretsConfig (43-55)
  • ControllerConfig (103-142)
  • Annotation (153-156)
  • KVPair (145-149)
🔇 Additional comments (21)
api/v1alpha1/zz_generated.deepcopy.go (1)

30-44: LGTM - Auto-generated deep copy implementations are correct.

The generated code properly handles the new Annotation and KVPair types. Since both types contain only string fields (value types), the shallow copy approach (*out = *in, copy()) is appropriate and correct.

Also applies to: 233-237, 495-508

config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml (1)

1176-1204: LGTM - CRD schema for annotations is well-defined.

The annotations field schema correctly:

  • Uses x-kubernetes-list-type: map with key as the map key for proper merge semantics
  • Enforces maxItems: 20 limit
  • Includes CEL validation to block reserved Kubernetes/OpenShift prefixes
  • Documents merge behavior and precedence clearly
pkg/controller/external_secrets/certificate.go (1)

93-99: LGTM - Annotation application follows the established pattern.

The annotation application logic correctly:

  • Checks for non-empty annotations before processing
  • Converts annotations to map (which filters reserved prefixes based on the PR context)
  • Only applies if the resulting map is non-empty
  • Uses the shared UpdateResourceAnnotations utility
pkg/controller/external_secrets/networkpolicy.go (1)

140-146: LGTM for static network policies.

Annotation application for static network policies follows the consistent pattern used across other resources.

pkg/controller/external_secrets/services.go (1)

56-62: LGTM - Annotation application follows the established pattern.

The Service annotation logic is consistent with other resource controllers in this PR.

pkg/controller/external_secrets/secret.go (1)

64-71: LGTM - Annotation application follows the established pattern.

The Secret annotation logic is consistent with other resource controllers in this PR.

pkg/controller/external_secrets/serviceaccounts.go (1)

47-53: LGTM - Annotation application follows the established pattern.

The ServiceAccount annotation logic is consistent with other resource controllers in this PR.

pkg/controller/external_secrets/validatingwebhook.go (1)

62-69: LGTM! Consistent annotation handling.

The annotation application logic correctly converts and filters user-provided annotations before applying them to the ValidatingWebhook. The ordering is appropriate—user annotations are set first, followed by operator-managed cert-manager annotations.

pkg/controller/external_secrets/deployments.go (3)

5-5: LGTM! Appropriate import for logging.

The logr import is correctly added to support structured logging within the annotation conversion helper.


107-114: LGTM! Dual annotation application.

Correctly applies annotations to both the Deployment resource and its PodTemplate, ensuring consistency between the deployment metadata and the pods it creates.


198-208: LGTM! Consistent with label handling pattern.

The function correctly mirrors the approach used for updatePodTemplateLabels, ensuring annotations are properly merged into the pod template specification.

pkg/controller/external_secrets/deployments_test.go (2)

565-630: LGTM! Comprehensive annotation test coverage.

The test case properly validates that custom annotations are applied to both the Deployment resource and its PodTemplate, covering multiple annotation patterns including namespace-prefixed keys.


632-688: LGTM! Good coverage of reserved prefix filtering.

The test correctly validates that user-provided annotations with reserved prefixes are filtered out. The handling of potentially existing default annotations is appropriate.

api/v1alpha1/external_secrets_config_types.go (3)

116-123: LGTM! Well-defined validation rules.

The Annotations field is properly constrained with a maximum of 20 items and includes CRD-level validation to prevent reserved Kubernetes prefixes, providing defense in depth alongside runtime filtering.


144-149: LGTM! Clean, reusable structure.

The KVPair type provides a simple, generic key-value pair structure that can be reused across different configuration contexts.


151-156: LGTM! Appropriate type distinction.

The Annotation type correctly embeds KVPair inline, providing semantic distinction while reusing the underlying structure.

pkg/controller/common/utils.go (3)

74-85: LGTM!

The UpdateResourceAnnotations function correctly mirrors the pattern of UpdateResourceLabels, properly merging annotations with user-specified values taking precedence.


209-217: LGTM!

Correctly extends metadata change detection to include annotations in addition to labels.


244-247: LGTM!

Appropriately checks pod template annotations for deployment modifications.

pkg/controller/external_secrets/rbacs.go (2)

196-203: LGTM!

Consistent annotation application across ClusterRoleBinding, Role, and RoleBinding objects.

Also applies to: 247-254, 299-306


141-149: Annotation filtering and consistent application across RBAC objects is solid.

The convertAnnotationsToMap function properly filters reserved prefixes (kubernetes.io/, app.kubernetes.io/, openshift.io/, k8s.io/) and logs skipped annotations. The pattern is applied consistently across all RBAC and other resource getters.

@siddhibhor-56 siddhibhor-56 changed the title Adds the Implementation Logic for Annotations ESO-281:Adds the Implementation Logic for Annotations Jan 8, 2026
@openshift-ci-robot openshift-ci-robot added the jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. label Jan 8, 2026
@openshift-ci-robot
Copy link

openshift-ci-robot commented Jan 8, 2026

@siddhibhor-56: This pull request references ESO-281 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the story to target the "4.22.0" version, but no target version was set.

Details

In response to this:

This PR adds support for custom annotations in ExternalSecretsConfig, allowing users to add custom metadata annotations to all resources created by the external-secrets-operator.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.


// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
annotationsMap := convertAnnotationsToMap(esc.Spec.ControllerConfig.Annotations, r.log)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annotations validation is handled in CRD, but it would be good have a validation here as well.
k8s.io/apimachinery/pkg/api/validation.ValidateAnnotations()


// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
annotationsMap := convertAnnotationsToMap(esc.Spec.ControllerConfig.Annotations, r.log)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are calling convertAnnotationsToMap() for every managed resource. Instead it should be at one place and passed around like resourcelabels. Either we can add a new arg to each func, or modify the existing resourcelabels arg to accommodate both labels and annotations.

// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
annotationsMap := convertAnnotationsToMap(esc.Spec.ControllerConfig.Annotations, r.log)
if len(annotationsMap) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if condition should be moved to UpdateResourceAnnotations(), instead of checking it in every caller.

}

// convertAnnotationsToMap converts []Annotation to map[string]string with reserved prefix filtering.
func convertAnnotationsToMap(annotations []operatorv1alpha1.Annotation, logger logr.Logger) map[string]string {
Copy link
Contributor

@bharath-b-rh bharath-b-rh Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to have it in the common utils.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@config/manager/kustomization.yaml`:
- Around line 6-7: The kustomization change replaces the controller image
registry (name: controller, newName:
quay.io/rh-ee-sbhor/external-secrets-operator) but leaves the tag unpinned,
resulting in a mutable :latest image; update the kustomization to include an
explicit newTag or newDigest for the controller image (referencing the same
image name used in the diff) so deployments use an immutable, reproducible image
reference — add newTag: <version> or newDigest: sha256:<digest> alongside name:
controller and newName to pin the controller image.

In `@pkg/controller/external_secrets/configmap.go`:
- Around line 69-88: ObjectMetadataModified is comparing the full annotation
maps so CNO-injected openshift.io/* annotations (which are removed from
desiredConfigMap by validateAndFilterAnnotations) cause the controller to always
think metadata changed; fix by comparing only the managed/filtered annotations
when deciding to update: create filtered copies of desiredConfigMap.Annotations
and existingConfigMap.Annotations using the same validateAndFilterAnnotations
logic (or a helper like filterManagedAnnotations) and then call
common.ObjectMetadataModified (or a new comparator) using
desiredConfigMap.Labels and the filtered annotation map versus
existingConfigMap.Labels and its filtered annotation map so external/unmanaged
annotations do not trigger a reconcile loop for ConfigMap (refer to
ObjectMetadataModified, desiredConfigMap, existingConfigMap,
validateAndFilterAnnotations, configMapName).

In `@pkg/controller/external_secrets/rbacs_test.go`:
- Around line 307-315: The test currently skips verification when annotations
are nil; update the block that inspects obj.(*rbacv1.ClusterRole) (variable cr)
so it explicitly fails if cr.Annotations is nil (e.g., t.Fatalf or t.Errorf +
return) and only then asserts cr.Annotations["rbac/managed-by"] == "operator";
apply the same explicit-nil-fails change to the other annotation checks that
inspect ClusterRole and Role/RoleBinding annotations (the blocks checking
"rbac/managed-by" and related keys at the other locations).

In `@pkg/controller/external_secrets/validatingwebhook_test.go`:
- Around line 164-170: Indentation mismatch: align the block that sets
r.CtrlClient = mock, initializes externalSecretsForValidateWebhook, applies
tt.updateExternalSecretsConfig, and calls
r.createOrApplyValidatingWebhookConfiguration to use the same double-tab
indentation as the surrounding test code (i.e., match the indentation style used
in the lines around the test). Locate the statements around r.CtrlClient = mock
and adjust their leading tabs so they are consistent with the surrounding block
containing the test setup and call to
createOrApplyValidatingWebhookConfiguration.
🧹 Nitpick comments (2)
pkg/controller/external_secrets/deployments.go (1)

174-207: Consider using strings.HasPrefix for cleaner prefix matching.

The manual prefix check at line 193 could be simplified using the standard library function for better readability.

♻️ Suggested improvement
+import "strings"
+
 // validateAndFilterAnnotations validates annotations using Kubernetes validation and filters out reserved prefixes.
 func validateAndFilterAnnotations(annotations map[string]string, logger logr.Logger) map[string]string {
 	if len(annotations) == 0 {
 		return annotations
 	}

 	// Validate annotations using Kubernetes built-in validation
 	if errs := apivalidation.ValidateAnnotations(annotations, field.NewPath("annotations")); len(errs) > 0 {
 		logger.Error(errs.ToAggregate(), "invalid annotations detected, skipping all annotations")
 		return make(map[string]string)
 	}

 	// Filter reserved prefixes
 	result := make(map[string]string, len(annotations))
 	reservedPrefixes := []string{"kubernetes.io/", "app.kubernetes.io/", "openshift.io/", "k8s.io/"}

 	for key, value := range annotations {
 		isReserved := false
 		for _, prefix := range reservedPrefixes {
-			if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
+			if strings.HasPrefix(key, prefix) {
 				isReserved = true
 				logger.V(1).Info("skipping annotation with reserved prefix",
 					"key", key, "prefix", prefix)
 				break
 			}
 		}

 		if !isReserved {
 			result[key] = value
 		}
 	}

 	return result
 }
pkg/controller/external_secrets/rbacs.go (1)

141-148: Consider extracting repeated annotation handling into a helper function.

The same annotation application pattern is repeated across getClusterRoleObject, getClusterRoleBindingObject, getRoleObject, and getRoleBindingObject. This could be extracted to reduce duplication and improve maintainability.

♻️ Suggested helper function
// applyControllerAnnotations applies validated annotations from ControllerConfig to the given object.
func (r *Reconciler) applyControllerAnnotations(obj client.Object, esc *operatorv1alpha1.ExternalSecretsConfig) {
	if len(esc.Spec.ControllerConfig.Annotations) > 0 {
		annotationsMap := validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
		if len(annotationsMap) > 0 {
			common.UpdateResourceAnnotations(obj, annotationsMap)
		}
	}
}

Then each getter simplifies to:

 func (r *Reconciler) getClusterRoleObject(esc *operatorv1alpha1.ExternalSecretsConfig, assetName string, resourceLabels map[string]string) *rbacv1.ClusterRole {
 	clusterRole := common.DecodeClusterRoleObjBytes(assets.MustAsset(assetName))
 	common.UpdateResourceLabels(clusterRole, resourceLabels)
-
-	// Apply annotations from ControllerConfig
-	if len(esc.Spec.ControllerConfig.Annotations) > 0 {
-		annotationsMap := validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
-		if len(annotationsMap) > 0 {
-			common.UpdateResourceAnnotations(clusterRole, annotationsMap)
-		}
-	}
-
+	r.applyControllerAnnotations(clusterRole, esc)
 	return clusterRole
 }

Also applies to: 195-202, 246-253, 298-305

Comment on lines 6 to 7
- name: controller
newName: openshift.io/external-secrets-operator
newTag: latest
newName: quay.io/rh-ee-sbhor/external-secrets-operator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect controller image references and tags/digests
rg -n --type=yaml -C2 'image:' config/manager
echo "---"
rg -n --type=yaml -C2 'images:' config/manager/kustomization.yaml

Repository: openshift/external-secrets-operator

Length of output: 541


🏁 Script executed:

#!/bin/bash
# Check for other kustomization files that might patch the image
find config -name 'kustomization.yaml' -o -name 'kustomization.yml'
echo "---"
# Search for any newTag or digest references to controller
rg -n 'newTag|digest' config/ --type=yaml

Repository: openshift/external-secrets-operator

Length of output: 429


🏁 Script executed:

#!/bin/bash
# Check config/default/kustomization.yaml which is typically the production overlay
cat config/default/kustomization.yaml | grep -A5 -B5 'images\|controller' || echo "No image references found"

Repository: openshift/external-secrets-operator

Length of output: 759


🏁 Script executed:

#!/bin/bash
# Check the full config/default/kustomization.yaml structure
cat config/default/kustomization.yaml

Repository: openshift/external-secrets-operator

Length of output: 5310


Pin the controller image tag/digest after changing registries.

Line 7 changes the registry to quay.io/rh-ee-sbhor/external-secrets-operator but leaves the tag resolution to the base image (controller:latest from config/manager/manager.yaml), which results in the unpinned mutable tag quay.io/rh-ee-sbhor/external-secrets-operator:latest. This introduces non-reproducible and potentially unsafe deployments. Add an explicit newTag or digest.

🔧 Suggested fix (example)
 images:
 - name: controller
   newName: quay.io/rh-ee-sbhor/external-secrets-operator
+  newTag: vX.Y.Z
+  # alternatively:
+  # digest: sha256:...
🤖 Prompt for AI Agents
In `@config/manager/kustomization.yaml` around lines 6 - 7, The kustomization
change replaces the controller image registry (name: controller, newName:
quay.io/rh-ee-sbhor/external-secrets-operator) but leaves the tag unpinned,
resulting in a mutable :latest image; update the kustomization to include an
explicit newTag or newDigest for the controller image (referencing the same
image name used in the diff) so deployments use an immutable, reproducible image
reference — add newTag: <version> or newDigest: sha256:<digest> alongside name:
controller and newName to pin the controller image.

Comment on lines +69 to +88
// ConfigMap exists, ensure it has the correct labels and annotations
// Do not update the data of the ConfigMap since it is managed by CNO
// Check if metadata (labels) has been modified.
// NOTE: Currently ObjectMetadataModified only checks labels, but if it's extended
// in the future to check annotations as well, CNO may race with this update since
// CNO adds `openshift.io/owning-component: Networking / cluster-network-operator` annotations on this ConfigMap.
// Check if metadata (labels/annotations) has been modified.
// NOTE: CNO adds `openshift.io/owning-component: Networking / cluster-network-operator` annotations on this ConfigMap.
// validateAndFilterAnnotations function filters out reserved prefixes like openshift.io/, so we won't overwrite CNO's annotations.
if exist && common.ObjectMetadataModified(desiredConfigMap, existingConfigMap) {
r.log.V(1).Info("trusted CA bundle ConfigMap has been modified, updating to desired state", "name", configMapName)
// Update the labels since
// Update the labels
existingConfigMap.Labels = desiredConfigMap.Labels

// Merge annotations - preserve existing annotations that are not managed by us
if desiredConfigMap.Annotations != nil {
if existingConfigMap.Annotations == nil {
existingConfigMap.Annotations = make(map[string]string)
}
for k, v := range desiredConfigMap.Annotations {
existingConfigMap.Annotations[k] = v
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid a reconcile loop caused by CNO-added annotations.

ObjectMetadataModified now compares full annotation maps. Since CNO injects openshift.io/* and validateAndFilterAnnotations strips those from desiredConfigMap, Line 74 will stay true and this will update on every reconcile even when managed annotations haven’t changed.

Consider comparing only managed (desired) annotations so extra external annotations don’t trigger updates.

💡 Suggested fix (compare only managed annotations)
@@
-import (
-	"fmt"
+import (
+	"fmt"
+	"reflect"
@@
-	// Check if metadata (labels/annotations) has been modified.
-	// NOTE: CNO adds `openshift.io/owning-component: Networking / cluster-network-operator` annotations on this ConfigMap.
-	// validateAndFilterAnnotations function filters out reserved prefixes like openshift.io/, so we won't overwrite CNO's annotations.
-	if exist && common.ObjectMetadataModified(desiredConfigMap, existingConfigMap) {
+	// Check if metadata (labels/managed annotations) has been modified.
+	labelsChanged := !reflect.DeepEqual(desiredConfigMap.Labels, existingConfigMap.Labels)
+	annotationsChanged := false
+	if desiredConfigMap.Annotations != nil {
+		for k, v := range desiredConfigMap.Annotations {
+			if existingConfigMap.Annotations == nil || existingConfigMap.Annotations[k] != v {
+				annotationsChanged = true
+				break
+			}
+		}
+	}
+	if exist && (labelsChanged || annotationsChanged) {
🤖 Prompt for AI Agents
In `@pkg/controller/external_secrets/configmap.go` around lines 69 - 88,
ObjectMetadataModified is comparing the full annotation maps so CNO-injected
openshift.io/* annotations (which are removed from desiredConfigMap by
validateAndFilterAnnotations) cause the controller to always think metadata
changed; fix by comparing only the managed/filtered annotations when deciding to
update: create filtered copies of desiredConfigMap.Annotations and
existingConfigMap.Annotations using the same validateAndFilterAnnotations logic
(or a helper like filterManagedAnnotations) and then call
common.ObjectMetadataModified (or a new comparator) using
desiredConfigMap.Labels and the filtered annotation map versus
existingConfigMap.Labels and its filtered annotation map so external/unmanaged
annotations do not trigger a reconcile loop for ConfigMap (refer to
ObjectMetadataModified, desiredConfigMap, existingConfigMap,
validateAndFilterAnnotations, configMapName).

Comment on lines +307 to +315
if cr, ok := obj.(*rbacv1.ClusterRole); ok {
// Verify annotations are applied
if cr.Annotations != nil {
if cr.Annotations["rbac/managed-by"] != "operator" {
t.Errorf("expected annotation 'rbac/managed-by'='operator', got '%s'",
cr.Annotations["rbac/managed-by"])
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test doesn't fail when annotations are unexpectedly nil.

If annotations weren't applied due to a bug, cr.Annotations would be nil and the test would silently pass. The assertion should explicitly fail when annotations are missing.

💚 Proposed fix
 				m.CreateCalls(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
 					if cr, ok := obj.(*rbacv1.ClusterRole); ok {
 						// Verify annotations are applied
-						if cr.Annotations != nil {
-							if cr.Annotations["rbac/managed-by"] != "operator" {
-								t.Errorf("expected annotation 'rbac/managed-by'='operator', got '%s'",
-									cr.Annotations["rbac/managed-by"])
-							}
+						if cr.Annotations == nil {
+							t.Error("expected annotations to be applied, got nil")
+						} else if cr.Annotations["rbac/managed-by"] != "operator" {
+							t.Errorf("expected annotation 'rbac/managed-by'='operator', got '%s'",
+								cr.Annotations["rbac/managed-by"])
 						}
 					}
 					return nil
 				})

The same pattern applies to the other annotation tests at lines 333-340, 356-365, and 380-389.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if cr, ok := obj.(*rbacv1.ClusterRole); ok {
// Verify annotations are applied
if cr.Annotations != nil {
if cr.Annotations["rbac/managed-by"] != "operator" {
t.Errorf("expected annotation 'rbac/managed-by'='operator', got '%s'",
cr.Annotations["rbac/managed-by"])
}
}
}
if cr, ok := obj.(*rbacv1.ClusterRole); ok {
// Verify annotations are applied
if cr.Annotations == nil {
t.Error("expected annotations to be applied, got nil")
} else if cr.Annotations["rbac/managed-by"] != "operator" {
t.Errorf("expected annotation 'rbac/managed-by'='operator', got '%s'",
cr.Annotations["rbac/managed-by"])
}
}
🤖 Prompt for AI Agents
In `@pkg/controller/external_secrets/rbacs_test.go` around lines 307 - 315, The
test currently skips verification when annotations are nil; update the block
that inspects obj.(*rbacv1.ClusterRole) (variable cr) so it explicitly fails if
cr.Annotations is nil (e.g., t.Fatalf or t.Errorf + return) and only then
asserts cr.Annotations["rbac/managed-by"] == "operator"; apply the same
explicit-nil-fails change to the other annotation checks that inspect
ClusterRole and Role/RoleBinding annotations (the blocks checking
"rbac/managed-by" and related keys at the other locations).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@api/v1alpha1/external_secrets_config_types.go`:
- Around line 117-123: The XValidation rule that rejects annotation keys using
contains('kubernetes.io/') is too broad and disallows keys like
"mykubernetes.io/..." — update the reserved-domain rule for the annotations
validation to check only the prefix (key.split('/')[0]) and ensure that prefix
is not exactly or a subdomain of the reserved domains; for example, replace the
current contains(...) check with logic that extracts prefix := key.split('/')[0]
(or handles absence of a prefix when no '/') and asserts !(prefix ==
"kubernetes.io" || prefix.endsWith(".kubernetes.io") || prefix == "openshift.io"
|| prefix.endsWith(".openshift.io") || prefix == "k8s.io" ||
prefix.endsWith(".k8s.io")). Ensure this change is applied to the XValidation
rule on the annotations field so only reserved domains and their subdomains are
rejected.

In `@pkg/controller/common/utils.go`:
- Around line 209-221: In ObjectMetadataModified, normalize annotation maps
before comparing to avoid nil vs empty-map mismatches: ensure
desired.GetAnnotations() and the result of
filterSystemManagedAnnotations(fetched.GetAnnotations()) are both non-nil (e.g.,
convert nil to an empty map) or both nil before calling reflect.DeepEqual;
update the comparison in the ObjectMetadataModified function (and any helper
usage of filterSystemManagedAnnotations) so annotations are canonicalized (nil →
empty map or vice versa) prior to equality check to prevent spurious diffs.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@pkg/controller/common/utils.go`:
- Around line 223-261: FilterReservedAnnotations is too broad because it
searches the whole key for reserved domain substrings; instead, split the
annotation key at the first '/' and only inspect the left-hand part (the domain
portion) against reservedDomains. Replace the strings.Contains checks with exact
equality (domain) or suffix match for subdomains (e.g.,
strings.HasSuffix(domainPart, "."+domain)) while keeping the existing direct
prefix check logic; update the loop that iterates reservedDomains accordingly
and ensure result preserves non-reserved entries.

In `@pkg/controller/external_secrets/deployments_test.go`:
- Around line 633-705: The reserved-annotation assertion in the
validateDeployment test currently only fails when the annotation value equals
"value", so change the check in the validateDeployment closure (where
reservedKeys is defined) to assert on presence regardless of value: for each key
in reservedKeys, test existence with something like "if _, exists :=
annotations[key]; exists { t.Errorf(... ) }" so any reserved annotation is
flagged even if its value is not "value".

In `@test/e2e/testdata/external_secrets_with_annotations.yaml`:
- Around line 24-27: The test network policy data only includes DNS port 5353;
update the DNS egress rules in external_secrets_with_annotations.yaml to also
allow port 53 for both TCP and UDP. Locate the DNS egress entries (the protocol:
TCP/UDP blocks listing port: 5353) and add corresponding entries with port: 53
so both "port: 53" and "port: 5353" are present for TCP and UDP to match the
reference networkpolicy_allow-dns.yaml.
♻️ Duplicate comments (1)
pkg/controller/common/utils.go (1)

209-221: Normalize annotation maps before DeepEqual comparisons.

Nil vs empty maps can produce false diffs once reserved keys are filtered, causing unnecessary updates. Consider normalizing both sides (and avoiding the early nil return in deploymentSpecModified).

🔧 Suggested normalization to avoid nil/empty mismatches
 func ObjectMetadataModified(desired, fetched client.Object) bool {
 	// Check if labels have changed
 	if !reflect.DeepEqual(desired.GetLabels(), fetched.GetLabels()) {
 		return true
 	}
 	// Check if annotations have changed (ignoring system-managed annotations)
 	desiredAnnotates := desired.GetAnnotations()
 	fetchedAnnotates := FilterReservedAnnotations(fetched.GetAnnotations())
+	if desiredAnnotates == nil {
+		desiredAnnotates = map[string]string{}
+	}
+	if fetchedAnnotates == nil {
+		fetchedAnnotates = map[string]string{}
+	}
 	if !reflect.DeepEqual(desiredAnnotates, fetchedAnnotates) {
 		return true
 	}
 	return false
 }
 
@@
-	if desired.Annotations != nil {
-		fetchedAnnots := fetched.GetAnnotations()
-		if fetchedAnnots == nil {
-			return true
-		}
-
-		fetchedAnnots = FilterReservedAnnotations(fetchedAnnots)
-		if !reflect.DeepEqual(desired.Annotations, fetchedAnnots) {
-			return true
-		}
-	}
+	if desired.Annotations != nil {
+		fetchedAnnots := FilterReservedAnnotations(fetched.GetAnnotations())
+		if fetchedAnnots == nil {
+			fetchedAnnots = map[string]string{}
+		}
+		if !reflect.DeepEqual(desired.Annotations, fetchedAnnots) {
+			return true
+		}
+	}

Also applies to: 273-281

🧹 Nitpick comments (2)
pkg/controller/external_secrets/deployments.go (2)

174-198: Consider moving validateAndFilterAnnotations to common utils.

This function performs validation and filtering that appears to be needed across multiple resource types (Deployments, Secrets, Services, etc.). Centralizing it in the common package would:

  1. Avoid duplication across controllers
  2. Enable single-point validation before passing annotations to reconciliation functions (similar to how resourceLabels is handled)

Based on past review comments, annotations should be validated/filtered once and passed around rather than calling this for every managed resource.


108-115: Nested length checks add verbosity.

The pattern of checking len(esc.Spec.ControllerConfig.Annotations) > 0 before calling validateAndFilterAnnotations, then checking len(annotationsMap) > 0 before applying, is verbose. Consider moving the empty-check logic into UpdateResourceAnnotations so callers don't need to guard against empty maps.

♻️ Suggested simplification

If UpdateResourceAnnotations handles empty maps gracefully (no-op), the caller code simplifies to:

annotationsMap := validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
common.UpdateResourceAnnotations(deployment, annotationsMap)
updatePodTemplateAnnotations(deployment, annotationsMap)

Comment on lines 633 to 705
name: "deployment reconciliation filters reserved annotation prefixes",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, capturedDeployment **appsv1.Deployment) {
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
switch o := obj.(type) {
case *appsv1.Deployment:
deployment := testDeployment(controllerDeploymentAssetName)
deployment.DeepCopyInto(o)
}
return true, nil
})
m.UpdateWithRetryCalls(func(ctx context.Context, obj client.Object, _ ...client.UpdateOption) error {
switch o := obj.(type) {
case *appsv1.Deployment:
*capturedDeployment = o.DeepCopy()
}
return nil
})
},
updateExternalSecretsConfig: func(esc *v1alpha1.ExternalSecretsConfig) {
esc.Spec.ControllerConfig.Annotations = map[string]string{
"allowed-annotation": "allowed",
"kubernetes.io/forbidden": "value",
"app.kubernetes.io/managed-by": "value",
"deployment.kubernetes.io/revision": "100",
"pod.kubernetes.io/name": "test",
"openshift.io/test": "value",
"console.openshift.io/route": "value",
}
},
validateDeployment: func(t *testing.T, deployment *appsv1.Deployment) {
if deployment == nil {
t.Error("deployment should not be nil")
return
}

annotations := deployment.GetAnnotations()
if annotations == nil {
t.Error("deployment annotations should not be nil")
return
}

// Allowed annotation should be present
if annotations["allowed-annotation"] != "allowed" {
t.Errorf("allowed-annotation should be present with value 'allowed', got: %v",
annotations["allowed-annotation"])
}

// Verify ALL reserved domain patterns are filtered
reservedKeys := []string{
"kubernetes.io/forbidden",
"app.kubernetes.io/managed-by",
"deployment.kubernetes.io/revision",
"pod.kubernetes.io/name",
"openshift.io/test",
"console.openshift.io/route",
}

for _, key := range reservedKeys {
if val, exists := annotations[key]; exists && val == "value" {
t.Errorf("reserved annotation %q should have been filtered but found with value %q", key, val)
}
}

// Verify only the allowed annotation exists (excluding any default annotations)
userAnnotationCount := 0
for key := range annotations {
if key == "allowed-annotation" {
userAnnotationCount++
}
}
if userAnnotationCount != 1 {
t.Errorf("expected exactly 1 user annotation, found user annotations: %v", annotations)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reserved-annotation test can miss non‑value entries.

The loop only errors when the value equals "value", so keys like deployment.kubernetes.io/revision or pod.kubernetes.io/name could slip through if present. Check for existence regardless of value.

🔧 Tighten reserved-key assertions
 	for _, key := range reservedKeys {
-		if val, exists := annotations[key]; exists && val == "value" {
-			t.Errorf("reserved annotation %q should have been filtered but found with value %q", key, val)
-		}
+		if val, exists := annotations[key]; exists {
+			t.Errorf("reserved annotation %q should have been filtered but found with value %q", key, val)
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name: "deployment reconciliation filters reserved annotation prefixes",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, capturedDeployment **appsv1.Deployment) {
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
switch o := obj.(type) {
case *appsv1.Deployment:
deployment := testDeployment(controllerDeploymentAssetName)
deployment.DeepCopyInto(o)
}
return true, nil
})
m.UpdateWithRetryCalls(func(ctx context.Context, obj client.Object, _ ...client.UpdateOption) error {
switch o := obj.(type) {
case *appsv1.Deployment:
*capturedDeployment = o.DeepCopy()
}
return nil
})
},
updateExternalSecretsConfig: func(esc *v1alpha1.ExternalSecretsConfig) {
esc.Spec.ControllerConfig.Annotations = map[string]string{
"allowed-annotation": "allowed",
"kubernetes.io/forbidden": "value",
"app.kubernetes.io/managed-by": "value",
"deployment.kubernetes.io/revision": "100",
"pod.kubernetes.io/name": "test",
"openshift.io/test": "value",
"console.openshift.io/route": "value",
}
},
validateDeployment: func(t *testing.T, deployment *appsv1.Deployment) {
if deployment == nil {
t.Error("deployment should not be nil")
return
}
annotations := deployment.GetAnnotations()
if annotations == nil {
t.Error("deployment annotations should not be nil")
return
}
// Allowed annotation should be present
if annotations["allowed-annotation"] != "allowed" {
t.Errorf("allowed-annotation should be present with value 'allowed', got: %v",
annotations["allowed-annotation"])
}
// Verify ALL reserved domain patterns are filtered
reservedKeys := []string{
"kubernetes.io/forbidden",
"app.kubernetes.io/managed-by",
"deployment.kubernetes.io/revision",
"pod.kubernetes.io/name",
"openshift.io/test",
"console.openshift.io/route",
}
for _, key := range reservedKeys {
if val, exists := annotations[key]; exists && val == "value" {
t.Errorf("reserved annotation %q should have been filtered but found with value %q", key, val)
}
}
// Verify only the allowed annotation exists (excluding any default annotations)
userAnnotationCount := 0
for key := range annotations {
if key == "allowed-annotation" {
userAnnotationCount++
}
}
if userAnnotationCount != 1 {
t.Errorf("expected exactly 1 user annotation, found user annotations: %v", annotations)
}
name: "deployment reconciliation filters reserved annotation prefixes",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, capturedDeployment **appsv1.Deployment) {
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
switch o := obj.(type) {
case *appsv1.Deployment:
deployment := testDeployment(controllerDeploymentAssetName)
deployment.DeepCopyInto(o)
}
return true, nil
})
m.UpdateWithRetryCalls(func(ctx context.Context, obj client.Object, _ ...client.UpdateOption) error {
switch o := obj.(type) {
case *appsv1.Deployment:
*capturedDeployment = o.DeepCopy()
}
return nil
})
},
updateExternalSecretsConfig: func(esc *v1alpha1.ExternalSecretsConfig) {
esc.Spec.ControllerConfig.Annotations = map[string]string{
"allowed-annotation": "allowed",
"kubernetes.io/forbidden": "value",
"app.kubernetes.io/managed-by": "value",
"deployment.kubernetes.io/revision": "100",
"pod.kubernetes.io/name": "test",
"openshift.io/test": "value",
"console.openshift.io/route": "value",
}
},
validateDeployment: func(t *testing.T, deployment *appsv1.Deployment) {
if deployment == nil {
t.Error("deployment should not be nil")
return
}
annotations := deployment.GetAnnotations()
if annotations == nil {
t.Error("deployment annotations should not be nil")
return
}
// Allowed annotation should be present
if annotations["allowed-annotation"] != "allowed" {
t.Errorf("allowed-annotation should be present with value 'allowed', got: %v",
annotations["allowed-annotation"])
}
// Verify ALL reserved domain patterns are filtered
reservedKeys := []string{
"kubernetes.io/forbidden",
"app.kubernetes.io/managed-by",
"deployment.kubernetes.io/revision",
"pod.kubernetes.io/name",
"openshift.io/test",
"console.openshift.io/route",
}
for _, key := range reservedKeys {
if val, exists := annotations[key]; exists {
t.Errorf("reserved annotation %q should have been filtered but found with value %q", key, val)
}
}
// Verify only the allowed annotation exists (excluding any default annotations)
userAnnotationCount := 0
for key := range annotations {
if key == "allowed-annotation" {
userAnnotationCount++
}
}
if userAnnotationCount != 1 {
t.Errorf("expected exactly 1 user annotation, found user annotations: %v", annotations)
}
🤖 Prompt for AI Agents
In `@pkg/controller/external_secrets/deployments_test.go` around lines 633 - 705,
The reserved-annotation assertion in the validateDeployment test currently only
fails when the annotation value equals "value", so change the check in the
validateDeployment closure (where reservedKeys is defined) to assert on presence
regardless of value: for each key in reservedKeys, test existence with something
like "if _, exists := annotations[key]; exists { t.Errorf(... ) }" so any
reserved annotation is flagged even if its value is not "value".

Comment on lines 24 to 27
- protocol: TCP
port: 5353 # DNS
- protocol: UDP
port: 5353 # DNS
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for DNS port configurations
rg -n "port:\s*(53|5353)" --type yaml -C 2

Repository: openshift/external-secrets-operator

Length of output: 1810


🏁 Script executed:

# Also check for DNS-related egress rules with any port
rg -n "DNS|dns" --type yaml -C 3

Repository: openshift/external-secrets-operator

Length of output: 24059


🏁 Script executed:

# Check what this test file is about
cat -n test/e2e/testdata/external_secrets_with_annotations.yaml | head -40

Repository: openshift/external-secrets-operator

Length of output: 1128


Include port 53 in addition to port 5353 for DNS egress.

The reference network policy (bindata/external-secrets/networkpolicy_allow-dns.yaml) allows both port 5353 (OpenShift DNS daemonset) and port 53 (standard DNS). This test data includes only port 5353, which is incomplete. Add port 53 to match the production configuration and ensure DNS resolution works correctly in all scenarios.

🤖 Prompt for AI Agents
In `@test/e2e/testdata/external_secrets_with_annotations.yaml` around lines 24 -
27, The test network policy data only includes DNS port 5353; update the DNS
egress rules in external_secrets_with_annotations.yaml to also allow port 53 for
both TCP and UDP. Locate the DNS egress entries (the protocol: TCP/UDP blocks
listing port: 5353) and add corresponding entries with port: 53 so both "port:
53" and "port: 5353" are present for TCP and UDP to match the reference
networkpolicy_allow-dns.yaml.

@siddhibhor-56 siddhibhor-56 force-pushed the eso-annotations branch 2 times, most recently from fc0cd78 to b65ff41 Compare January 28, 2026 19:44
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml (1)

753-775: cert-manager.io entry now conflicts with the reserved-domain rule.
Line 764 still expects cert-manager.io/cluster-issuer to be accepted, but the CRD now rejects that domain. Remove it from the “allowed” case or add a failing test that asserts the rejection.

🩹 Suggested edit (remove from allowed list)
               vault.hashicorp.com/agent-inject: "true"
-              cert-manager.io/cluster-issuer: "letsencrypt"

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml (1)

752-775: Test includes annotation that should be rejected per the validation rules.

The test expects cert-manager.io/cluster-issuer to be allowed, but the error messages throughout this file (e.g., line 523) explicitly list cert-manager.io/ as a reserved domain that is not allowed. This test will fail.

Either remove the cert-manager.io/cluster-issuer annotation from this test case, or if cert-manager.io annotations should genuinely be allowed, the error messages need to be updated to remove cert-manager.io from the reserved domains list.

🐛 Proposed fix to remove the disallowed annotation
     - name: Should allow common third-party annotation keys
       resourceName: cluster
       initial: |
         apiVersion: operator.openshift.io/v1alpha1
         kind: ExternalSecretsConfig
         spec:
           controllerConfig:
             annotations:
               prometheus.io/scrape: "true"
               prometheus.io/port: "8080"
               argocd.argoproj.io/sync-wave: "1"
               vault.hashicorp.com/agent-inject: "true"
-              cert-manager.io/cluster-issuer: "letsencrypt"
       expected: |
         apiVersion: operator.openshift.io/v1alpha1
         kind: ExternalSecretsConfig
         spec:
           controllerConfig:
             annotations:
               prometheus.io/scrape: "true"
               prometheus.io/port: "8080"
               argocd.argoproj.io/sync-wave: "1"
               vault.hashicorp.com/agent-inject: "true"
-              cert-manager.io/cluster-issuer: "letsencrypt"
🧹 Nitpick comments (1)
api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml (1)

580-589: Consider adding subdomain tests for openshift.io and cert-manager.io for completeness.

Tests exist for subdomains of kubernetes.io and k8s.io, but not for openshift.io or cert-manager.io. While the validation logic likely handles all domains uniformly, explicit tests would ensure complete coverage.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml (1)

753-775: cert-manager.io is now disallowed but still listed as allowed

The CRD rule now rejects cert-manager.io/ keys; this test should expect failure or remove the entry to avoid invalid objects passing here.

🔧 Proposed fix (remove from allowlist test)
               prometheus.io/scrape: "true"
               prometheus.io/port: "8080"
               argocd.argoproj.io/sync-wave: "1"
               vault.hashicorp.com/agent-inject: "true"
-              cert-manager.io/cluster-issuer: "letsencrypt"
@@
               prometheus.io/scrape: "true"
               prometheus.io/port: "8080"
               argocd.argoproj.io/sync-wave: "1"
               vault.hashicorp.com/agent-inject: "true"
-              cert-manager.io/cluster-issuer: "letsencrypt"
🤖 Fix all issues with AI agents
In `@pkg/controller/common/utils_test.go`:
- Around line 12-53: In TestFilterAnnotations the test calls
FilterReservedAnnotations(retained) so reserved keys are never removed; change
the invocation to call FilterReservedAnnotations(source) (in
TestFilterAnnotations) so the mixed map with reserved keys is filtered, then
assert the returned map equals retained; keep the same retained expected map and
compare filtered to retained using reflect.DeepEqual/cmp.Diff.

# Conflicts:
#	api/v1alpha1/external_secrets_config_types.go
#	api/v1alpha1/zz_generated.deepcopy.go
#	bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml
#	config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml
#	docs/api_reference.md

# Conflicts:
#	pkg/controller/external_secrets/deployments_test.go
@openshift-ci
Copy link

openshift-ci bot commented Jan 30, 2026

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: siddhibhor-56
Once this PR has been reviewed and has the lgtm label, please assign trilokgeer for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml (1)

753-775: ⚠️ Potential issue | 🟠 Major

Test expectation conflicts with the cert-manager.io disallowlist.
ControllerConfig.Annotations rejects cert-manager.io/ keys, so this case should expect failure or drop the key.

✅ Suggested adjustment
@@
-              cert-manager.io/cluster-issuer: "letsencrypt"
@@
-              cert-manager.io/cluster-issuer: "letsencrypt"
🤖 Fix all issues with AI agents
In `@config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml`:
- Around line 1191-1197: The CEL rule for cert-manager domain restriction is
missing a closing parenthesis causing an invalid CRD schema; update the rule
string that currently reads "rule: self.all(key,
!key.matches('^(cert-manager\\.io)/')" by adding the missing closing parenthesis
so the expression is balanced (i.e., ends with "'))"); ensure the corrected rule
is applied where the cert-manager domain restriction rule is defined.

In `@docs/api_reference.md`:
- Line 204: Update the `annotations` property docs to include the
`cert-manager.io/` reserved domain (and its subdomains) in the list of
disallowed prefixes; specifically, amend the sentence that lists forbidden
prefixes (`kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, `k8s.io/`) to
also include `cert-manager.io/` and clarify that subdomains of these prefixes
are prohibited, so the `annotations` documentation accurately reflects the
validation enforced by the code.

In `@pkg/controller/common/utils.go`:
- Around line 231-255: The FilterReservedAnnotations function currently only
blocks the exact "cert-manager.io/" prefix but not subdomains like
"*.cert-manager.io/"; update the reserved checks so cert-manager subdomains are
filtered the same way as other domains by either adding "cert-manager.io" to the
reservedDomains slice (so the existing strings.HasPrefix(key, domain+"/") ||
strings.Contains(key, "."+domain+"/") logic covers it) or by extending the
explicit cert-manager check to also test strings.Contains(key,
".cert-manager.io/") before marking isReserved in FilterReservedAnnotations.

In `@pkg/controller/external_secrets/certificate_test.go`:
- Around line 444-483: The test "certificate with custom annotations applied
successfully" asserts the reserved key
"cert-manager.io/issue-temporary-certificate" is allowed, but controller
validation disallows cert-manager.io/*; update the test to use a non-reserved
annotation instead of changing validation. Concretely, in the test's preReq
CreateCalls assertion (inside the preReq func) and in the esc closure
(esc.Spec.ControllerConfig.Annotations), replace the reserved key with a
non-reserved key (e.g., "example.com/issue-temporary-certificate" or reuse
"team/owner") so the m.CreateCalls check and the provided esc annotations match
the allowed key. Ensure the assertion in the CreateCalls refers to that new key
name.

In `@pkg/controller/external_secrets/networkpolicy.go`:
- Around line 140-146: The ControllerConfig annotations are only applied to
static NetworkPolicy objects; ensure the same validated annotations from
esc.Spec.ControllerConfig.Annotations are applied to NetworkPolicies created by
buildNetworkPolicyFromConfig as well: call
validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log) when
building custom policies (or accept an annotations map parameter in
buildNetworkPolicyFromConfig) and then invoke
common.UpdateResourceAnnotations(networkPolicy, annotationsMap) on the
NetworkPolicy returned/constructed by buildNetworkPolicyFromConfig so global
annotations are present on custom resources too.
🧹 Nitpick comments (2)
test/utils/conditions.go (1)

208-227: Fail fast on missing template variables.

text/template defaults can silently emit <no value>/zero values, which can hide fixture mistakes in e2e tests. Consider enabling missingkey=error to surface issues early.

Suggested change
-		tmpl, err := template.New("asset").Parse(string(fileContent))
+		tmpl, err := template.New("asset").Option("missingkey=error").Parse(string(fileContent))
test/e2e/e2e_test.go (1)

58-61: Remove unused type definition.

ExternalSecretsConfigTemplate is defined at lines 58–59 but never referenced anywhere in the codebase. Remove this struct definition.

| `certProvider` _[CertProvidersConfig](#certprovidersconfig)_ | certProvider is for defining the configuration for certificate providers used to manage TLS certificates for webhook and plugins. | | Optional: \{\} <br /> |
| `labels` _object (keys:string, values:string)_ | labels to apply to all resources created for the external-secrets operand deployment.<br />This field can have a maximum of 20 entries. | | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
| `annotations` _[Annotation](#annotation) array_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment. The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys with prefixes `kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, or `k8s.io/` are not allowed. | | MaxItems: 20 <br />MinItems: 0 <br /> |
| `annotations` _object (keys:string, values:string)_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment.<br />The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys with prefixes `kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, or `k8s.io/` are not allowed. | | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the cert-manager.io/ reserved domain in annotations.
The validation disallows cert-manager.io/ (and subdomains), but the docs omit it.

📝 Suggested doc update
-| `annotations` _object (keys:string, values:string)_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment.<br />The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys with prefixes `kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, or `k8s.io/` are not allowed. |  | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
+| `annotations` _object (keys:string, values:string)_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment.<br />The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys containing reserved domains `kubernetes.io/`, `openshift.io/`, `k8s.io/`, or `cert-manager.io/` (including subdomains like `*.kubernetes.io/`) are not allowed. |  | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `annotations` _object (keys:string, values:string)_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment.<br />The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys with prefixes `kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, or `k8s.io/` are not allowed. | | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
| `annotations` _object (keys:string, values:string)_ | annotations are for adding custom annotations to all the resources created for external-secrets deployment.<br />The annotations are merged with any default annotations set by the operator. User-specified annotations take precedence over defaults in case of conflicts.<br />Annotation keys containing reserved domains `kubernetes.io/`, `openshift.io/`, `k8s.io/`, or `cert-manager.io/` (including subdomains like `*.kubernetes.io/`) are not allowed. | | MaxProperties: 20 <br />MinProperties: 0 <br />Optional: \{\} <br /> |
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 204-204: Table column style
Table pipe has extra space to the right for style "compact"

(MD060, table-column-style)


[warning] 204-204: Table column style
Table pipe has extra space to the left for style "compact"

(MD060, table-column-style)

🤖 Prompt for AI Agents
In `@docs/api_reference.md` at line 204, Update the `annotations` property docs to
include the `cert-manager.io/` reserved domain (and its subdomains) in the list
of disallowed prefixes; specifically, amend the sentence that lists forbidden
prefixes (`kubernetes.io/`, `app.kubernetes.io/`, `openshift.io/`, `k8s.io/`) to
also include `cert-manager.io/` and clarify that subdomains of these prefixes
are prohibited, so the `annotations` documentation accurately reflects the
validation enforced by the code.

Comment on lines +231 to +255
// FilterReservedAnnotations filters out Kubernetes/OpenShift reserved domain annotations
// and annotations managed by external controllers that should not trigger reconciliation.
// Reserved domains: kubernetes.io, openshift.io, k8s.io
func FilterReservedAnnotations(annotations map[string]string) map[string]string {
if len(annotations) == 0 {
return annotations
}

// Reserved domain patterns: blocks both "kubernetes.io/*" and "*.kubernetes.io/*"
reservedDomains := []string{"kubernetes.io", "openshift.io", "k8s.io"}

result := make(map[string]string, len(annotations))
for key, value := range annotations {
isReserved := false

for _, domain := range reservedDomains {
// Checks for patterns like kubernetes.io/* and *.kubernetes.io/* only
if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
isReserved = true
break
}
if strings.HasPrefix(key, "cert-manager.io/") {
isReserved = true
break
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Filter *.cert-manager.io/ subdomains as reserved.
Validation disallows cert-manager subdomains, but the filter only blocks the exact cert-manager.io/ prefix.

🔧 Suggested fix
-// Reserved domains: kubernetes.io, openshift.io, k8s.io
+// Reserved domains: kubernetes.io, openshift.io, k8s.io, cert-manager.io
@@
-reservedDomains := []string{"kubernetes.io", "openshift.io", "k8s.io"}
+reservedDomains := []string{"kubernetes.io", "openshift.io", "k8s.io", "cert-manager.io"}
@@
-	for _, domain := range reservedDomains {
-		// Checks for patterns like kubernetes.io/* and *.kubernetes.io/* only
-		if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
-			isReserved = true
-			break
-		}
-		if strings.HasPrefix(key, "cert-manager.io/") {
-			isReserved = true
-			break
-		}
-	}
+	for _, domain := range reservedDomains {
+		// Checks for patterns like kubernetes.io/* and *.kubernetes.io/*
+		if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
+			isReserved = true
+			break
+		}
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// FilterReservedAnnotations filters out Kubernetes/OpenShift reserved domain annotations
// and annotations managed by external controllers that should not trigger reconciliation.
// Reserved domains: kubernetes.io, openshift.io, k8s.io
func FilterReservedAnnotations(annotations map[string]string) map[string]string {
if len(annotations) == 0 {
return annotations
}
// Reserved domain patterns: blocks both "kubernetes.io/*" and "*.kubernetes.io/*"
reservedDomains := []string{"kubernetes.io", "openshift.io", "k8s.io"}
result := make(map[string]string, len(annotations))
for key, value := range annotations {
isReserved := false
for _, domain := range reservedDomains {
// Checks for patterns like kubernetes.io/* and *.kubernetes.io/* only
if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
isReserved = true
break
}
if strings.HasPrefix(key, "cert-manager.io/") {
isReserved = true
break
}
// FilterReservedAnnotations filters out Kubernetes/OpenShift reserved domain annotations
// and annotations managed by external controllers that should not trigger reconciliation.
// Reserved domains: kubernetes.io, openshift.io, k8s.io, cert-manager.io
func FilterReservedAnnotations(annotations map[string]string) map[string]string {
if len(annotations) == 0 {
return annotations
}
// Reserved domain patterns: blocks both "kubernetes.io/*" and "*.kubernetes.io/*"
reservedDomains := []string{"kubernetes.io", "openshift.io", "k8s.io", "cert-manager.io"}
result := make(map[string]string, len(annotations))
for key, value := range annotations {
isReserved := false
for _, domain := range reservedDomains {
// Checks for patterns like kubernetes.io/* and *.kubernetes.io/*
if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
isReserved = true
break
}
}
🤖 Prompt for AI Agents
In `@pkg/controller/common/utils.go` around lines 231 - 255, The
FilterReservedAnnotations function currently only blocks the exact
"cert-manager.io/" prefix but not subdomains like "*.cert-manager.io/"; update
the reserved checks so cert-manager subdomains are filtered the same way as
other domains by either adding "cert-manager.io" to the reservedDomains slice
(so the existing strings.HasPrefix(key, domain+"/") || strings.Contains(key,
"."+domain+"/") logic covers it) or by extending the explicit cert-manager check
to also test strings.Contains(key, ".cert-manager.io/") before marking
isReserved in FilterReservedAnnotations.

Comment on lines +140 to +146
// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
annotationsMap := validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
if len(annotationsMap) > 0 {
common.UpdateResourceAnnotations(networkPolicy, annotationsMap)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply ControllerConfig annotations to custom NetworkPolicies too.

This only updates static assets; custom NetworkPolicies built via buildNetworkPolicyFromConfig never receive global annotations, so user-specified annotations won’t apply to those resources.

Proposed fix
 func (r *Reconciler) buildNetworkPolicyFromConfig(esc *operatorv1alpha1.ExternalSecretsConfig, npConfig operatorv1alpha1.NetworkPolicy, resourceLabels map[string]string) (*networkingv1.NetworkPolicy, error) {
@@
 	networkPolicy := &networkingv1.NetworkPolicy{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      npConfig.Name,
 			Namespace: namespace,
 			Labels:    resourceLabels,
 		},
@@
 	}
+
+	if len(esc.Spec.ControllerConfig.Annotations) > 0 {
+		annotationsMap := validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
+		if len(annotationsMap) > 0 {
+			common.UpdateResourceAnnotations(networkPolicy, annotationsMap)
+		}
+	}
 
 	return networkPolicy, nil
 }
🤖 Prompt for AI Agents
In `@pkg/controller/external_secrets/networkpolicy.go` around lines 140 - 146, The
ControllerConfig annotations are only applied to static NetworkPolicy objects;
ensure the same validated annotations from esc.Spec.ControllerConfig.Annotations
are applied to NetworkPolicies created by buildNetworkPolicyFromConfig as well:
call validateAndFilterAnnotations(esc.Spec.ControllerConfig.Annotations, r.log)
when building custom policies (or accept an annotations map parameter in
buildNetworkPolicyFromConfig) and then invoke
common.UpdateResourceAnnotations(networkPolicy, annotationsMap) on the
NetworkPolicy returned/constructed by buildNetworkPolicyFromConfig so global
annotations are present on custom resources too.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@api/v1alpha1/external_secrets_config_types.go`:
- Around line 123-124: The validation annotation message for the XValidation
rule on the kubebuilder tag that uses the regex
'^([^/]*\\.)?(kubernetes\\.io|k8s\\.io|openshift\\.io)/' is inaccurate because
it mentions 'cert-manager.io/' even though cert-manager.io is not included in
that regex; update the message text on that annotation (the comment containing
"+kubebuilder:validation:XValidation:rule=\"self.all(key,
!key.matches('^([^/]*\\\\.)?(kubernetes\\\\.io|k8s\\\\.io|openshift\\\\.io)/'))\",message=...")
to remove any reference to 'cert-manager.io/' so it reflects only the domains
actually checked, leaving the separate cert-manager.io rule (the next
XValidation annotation) unchanged.
🧹 Nitpick comments (2)
test/e2e/e2e_test.go (1)

58-61: Unused type definition.

ExternalSecretsConfigTemplate is defined but not used anywhere in this file. Consider removing it if it's not needed.

♻️ Suggested removal
-// ExternalSecretsConfigTemplate represents the template data for ExternalSecretsConfig
-type ExternalSecretsConfigTemplate struct {
-	Annotations map[string]string
-}
-
pkg/controller/common/utils.go (1)

246-256: Move cert-manager.io check outside the loop for efficiency.

The cert-manager.io/ prefix check on lines 252-255 is inside the reservedDomains loop, causing it to be evaluated 3 times per annotation key. This is a minor inefficiency but easy to fix.

♻️ Suggested fix
 	for key, value := range annotations {
 		isReserved := false
 
+		// Check cert-manager.io separately (exact prefix only, no subdomain)
+		if strings.HasPrefix(key, "cert-manager.io/") {
+			isReserved = true
+		}
+
 		for _, domain := range reservedDomains {
 			// Checks for patterns like kubernetes.io/* and *.kubernetes.io/* only
 			if strings.HasPrefix(key, domain+"/") || strings.Contains(key, "."+domain+"/") {
 				isReserved = true
 				break
 			}
-			if strings.HasPrefix(key, "cert-manager.io/") {
-				isReserved = true
-				break
-			}
 		}
 
 		if !isReserved {

@siddhibhor-56 siddhibhor-56 force-pushed the eso-annotations branch 2 times, most recently from 4ce0658 to c58041a Compare January 30, 2026 12:52
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@test/e2e/e2e_test.go`:
- Around line 319-446: The test currently overwrites
Spec.ControllerConfig.Annotations on the ExternalSecretsConfig (via
runtimeClient.Get + updatedCR := existingCR.DeepCopy();
updatedCR.Spec.ControllerConfig.Annotations = testAnnotations) which can drop
pre-existing annotations; change it to capture originalAnnotations :=
existingCR.Spec.ControllerConfig.Annotations (nil-safe), merge the
testAnnotations into a copy when updating (instead of assigning), and in the
deferred cleanup restore the originalAnnotations (replacing the whole map)
rather than only deleting test keys so pre-existing annotations are preserved;
reference runtimeClient, ExternalSecretsConfig, existingCR, updatedCR,
Spec.ControllerConfig.Annotations and the defer cleanup block when making the
change.

Comment on lines +319 to +441
Context("Annotations", func() {
It("should apply custom annotations to created resources", func() {
// Define test annotations
testAnnotations := map[string]string{
"example.com/custom-annotation": "test-value",
"mycompany.io/owner": "platform-team",
}

By("Updating ExternalSecretsConfig with custom annotations")
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
existingCR := &operatorv1alpha1.ExternalSecretsConfig{}
if err := runtimeClient.Get(ctx, client.ObjectKey{Name: "cluster"}, existingCR); err != nil {
return err
}

updatedCR := existingCR.DeepCopy()
updatedCR.Spec.ControllerConfig.Annotations = testAnnotations

return runtimeClient.Update(ctx, updatedCR)
})
Expect(err).NotTo(HaveOccurred(), "should update ExternalSecretsConfig with annotations")

defer func() {
// Remove the test annotations
By("Removing test annotations from ExternalSecretsConfig CR")
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
currentCR := &operatorv1alpha1.ExternalSecretsConfig{}
if err := runtimeClient.Get(ctx, client.ObjectKey{Name: "cluster"}, currentCR); err != nil {
return err
}
for key := range testAnnotations {
delete(currentCR.Spec.ControllerConfig.Annotations, key)
}
return runtimeClient.Update(ctx, currentCR)
})
Expect(err).NotTo(HaveOccurred(), "should remove test annotations from ExternalSecretsConfig")
}()

By("Waiting for external-secrets operand pods to be ready")
Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operandNamespace, []string{
operandCoreControllerPodPrefix,
operandCertControllerPodPrefix,
operandWebhookPodPrefix,
})).To(Succeed())

By("Verifying annotations are applied to Deployment resources")
Eventually(func(g Gomega) {
deployments, err := clientset.AppsV1().Deployments(operandNamespace).List(ctx, metav1.ListOptions{})
g.Expect(err).NotTo(HaveOccurred(), "should list deployments in %s namespace", operandNamespace)
g.Expect(deployments.Items).NotTo(BeEmpty(), "should have at least one deployment")

for _, deployment := range deployments.Items {
if !strings.HasPrefix(deployment.Name, "external-secrets") {
continue
}

// annotations on deployment.metadata
annotations := deployment.GetAnnotations()
for key, value := range testAnnotations {
g.Expect(annotations).To(HaveKeyWithValue(key, value),
"deployment %s should have annotation %s=%s", deployment.Name, key, value)
}

// annotations on deployment.spec.template
annotations = deployment.Spec.Template.Annotations
for key, value := range testAnnotations {
g.Expect(annotations).To(HaveKeyWithValue(key, value),
"deployment %s should have annotation %s=%s", deployment.Name, key, value)
}
}
}, 2*time.Minute, 5*time.Second).Should(Succeed())

By("Verifying annotations are applied to Service resources")
Eventually(func(g Gomega) {
services, err := clientset.CoreV1().Services(operandNamespace).List(ctx, metav1.ListOptions{})
g.Expect(err).NotTo(HaveOccurred(), "should list services in %s namespace", operandNamespace)

for _, service := range services.Items {
if !strings.HasPrefix(service.Name, "external-secrets") {
continue
}

annotations := service.GetAnnotations()
for key, value := range testAnnotations {
g.Expect(annotations).To(HaveKeyWithValue(key, value),
"service %s should have annotation %s=%s", service.Name, key, value)
}
}
}, 2*time.Minute, 5*time.Second).Should(Succeed())

By("Verifying annotations are applied to ServiceAccount resources")
Eventually(func(g Gomega) {
serviceAccounts, err := clientset.CoreV1().ServiceAccounts(operandNamespace).List(ctx, metav1.ListOptions{})
g.Expect(err).NotTo(HaveOccurred(), "should list service accounts in %s namespace", operandNamespace)

for _, sa := range serviceAccounts.Items {
if !strings.HasPrefix(sa.Name, "external-secrets") {
continue
}

annotations := sa.GetAnnotations()
for key, value := range testAnnotations {
g.Expect(annotations).To(HaveKeyWithValue(key, value),
"service account %s should have annotation %s=%s", sa.Name, key, value)
}
}
}, 2*time.Minute, 5*time.Second).Should(Succeed())
})

It("should not allow updating annotations with reserved domain prefix", func() {
By("Getting the existing ExternalSecretsConfig CR")
existingCR := &operatorv1alpha1.ExternalSecretsConfig{}
err := runtimeClient.Get(ctx, client.ObjectKey{Name: "cluster"}, existingCR)
Expect(err).NotTo(HaveOccurred(), "should get ExternalSecretsConfig CR")

By("Attempting to update with a reserved domain annotation")
updatedCR := existingCR.DeepCopy()
if updatedCR.Spec.ControllerConfig.Annotations == nil {
updatedCR.Spec.ControllerConfig.Annotations = make(map[string]string)
}

// Add two reserved annotations that should be rejected
updatedCR.Spec.ControllerConfig.Annotations["deployment.kubernetes.io/revision"] = "9"
updatedCR.Spec.ControllerConfig.Annotations["k8s.io/not-allowed"] = "denied"

err = runtimeClient.Update(ctx, updatedCR)
Expect(err).To(HaveOccurred(), "update with reserved domain annotations should fail")
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve and restore existing annotations to avoid test pollution.
The update replaces the entire annotations map, and cleanup only removes test keys, which can drop pre-existing config and affect later tests. Capture the original annotations, merge the test keys, and restore on cleanup.

🛠️ Suggested fix
-			err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
-				existingCR := &operatorv1alpha1.ExternalSecretsConfig{}
-				if err := runtimeClient.Get(ctx, client.ObjectKey{Name: "cluster"}, existingCR); err != nil {
-					return err
-				}
-
-				updatedCR := existingCR.DeepCopy()
-				updatedCR.Spec.ControllerConfig.Annotations = testAnnotations
-
-				return runtimeClient.Update(ctx, updatedCR)
-			})
+			var originalAnnotations map[string]string
+			err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
+				existingCR := &operatorv1alpha1.ExternalSecretsConfig{}
+				if err := runtimeClient.Get(ctx, client.ObjectKey{Name: "cluster"}, existingCR); err != nil {
+					return err
+				}
+				if originalAnnotations == nil {
+					originalAnnotations = map[string]string{}
+					for k, v := range existingCR.Spec.ControllerConfig.Annotations {
+						originalAnnotations[k] = v
+					}
+				}
+
+				updatedCR := existingCR.DeepCopy()
+				if updatedCR.Spec.ControllerConfig.Annotations == nil {
+					updatedCR.Spec.ControllerConfig.Annotations = map[string]string{}
+				}
+				for key, value := range testAnnotations {
+					updatedCR.Spec.ControllerConfig.Annotations[key] = value
+				}
+
+				return runtimeClient.Update(ctx, updatedCR)
+			})
@@
-				for key := range testAnnotations {
-					delete(currentCR.Spec.ControllerConfig.Annotations, key)
-				}
+				if currentCR.Spec.ControllerConfig.Annotations == nil {
+					currentCR.Spec.ControllerConfig.Annotations = map[string]string{}
+				}
+				for key := range currentCR.Spec.ControllerConfig.Annotations {
+					delete(currentCR.Spec.ControllerConfig.Annotations, key)
+				}
+				for key, value := range originalAnnotations {
+					currentCR.Spec.ControllerConfig.Annotations[key] = value
+				}
🤖 Prompt for AI Agents
In `@test/e2e/e2e_test.go` around lines 319 - 446, The test currently overwrites
Spec.ControllerConfig.Annotations on the ExternalSecretsConfig (via
runtimeClient.Get + updatedCR := existingCR.DeepCopy();
updatedCR.Spec.ControllerConfig.Annotations = testAnnotations) which can drop
pre-existing annotations; change it to capture originalAnnotations :=
existingCR.Spec.ControllerConfig.Annotations (nil-safe), merge the
testAnnotations into a copy when updating (instead of assigning), and in the
deferred cleanup restore the originalAnnotations (replacing the whole map)
rather than only deleting test keys so pre-existing annotations are preserved;
reference runtimeClient, ExternalSecretsConfig, existingCR, updatedCR,
Spec.ControllerConfig.Annotations and the defer cleanup block when making the
change.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml (1)

1168-1197: ⚠️ Potential issue | 🟠 Major

Block cert-manager subdomains in annotation validation.

The rule on Line 1197 only rejects cert-manager.io/, so foo.cert-manager.io/ would pass even though reserved-domain filtering is intended. Align it with the other domain-based rule.

🛠️ Proposed fix
-                      rule: self.all(key, !key.matches('^(cert-manager\.io)/'))
+                      rule: self.all(key, !key.matches('^([^/]*\\.)?cert-manager\\.io/'))
bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml (1)

1168-1197: ⚠️ Potential issue | 🟠 Major

Keep manifest validation strict for cert-manager subdomains.

The manifest rule also only blocks cert-manager.io/, so foo.cert-manager.io/ slips through. Please mirror the domain-based restriction used for other reserved domains.

🛠️ Proposed fix
-                      rule: self.all(key, !key.matches('^(cert-manager\.io)/'))
+                      rule: self.all(key, !key.matches('^([^/]*\\.)?cert-manager\\.io/'))

@openshift-ci
Copy link

openshift-ci bot commented Jan 30, 2026

@siddhibhor-56: all tests passed!

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

Copy link
Contributor

@bharath-b-rh bharath-b-rh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't taken a look at coderabbit provided suggestions. Could you please address valid suggestions by it.

console.openshift.io/disable-operand-delete: "true"
containerImage: openshift.io/external-secrets-operator:latest
createdAt: "2026-01-13T09:33:27Z"
createdAt: "2026-01-30T12:44:22Z"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: no actual change here, not required to commit

images:
- name: controller
newName: openshift.io/external-secrets-operator
newTag: latest
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this field removed?

Comment on lines +82 to +84
for k, v := range annotations {
a[k] = v
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be flagged by linter. We need make similar change here as well.

custom.company.io/team: "platform"
custom.company.io/app: "external-secrets"
- name: Should fail with annotation key starting with kubernetes.io
- name: Should allow custom annotations with cert-manager subdomain
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think below scenarios could be also added

Should not allow annotation key with subdomain of openshift.io
Should not allow annotation key with deep subdomain of kubernetes.io
Should allow annotation key similar to k8s.io but different domain
Should allow annotation key similar to cert-manager.io but different domain
Should allow reserved domain without trailing slash


default:
panic(fmt.Sprintf("unsupported object type: %T", desired))
return ObjectMetadataModified(desired, fetched)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from panic to ObjectMetadataModified in the default case is intentional as without this change Service Account resource panics and cannot update annotations, which is not handled by the switch case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we need to add a case for ServiceAccount isn't, instead of changing the behavior?

return make(map[string]string)
}

// Filter reserved domains kubernetes.io/*, *.kubernetes.io/*, openshift.io/*, *.openshift.io/*, etc.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this is not required.

if a == nil {
a = make(map[string]string, len(annotations))
}
for k, v := range annotations {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same linter suggestion here as well.

// getClusterRoleObject is for obtaining the content of given ClusterRole static asset, and
// then updating it with desired values.
func (r *Reconciler) getClusterRoleObject(assetName string, resourceLabels map[string]string) *rbacv1.ClusterRole {
func (r *Reconciler) getClusterRoleObject(esc *operatorv1alpha1.ExternalSecretsConfig, assetName string, resourceLabels map[string]string) *rbacv1.ClusterRole {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we were going to refactor here, to pass common arg which contains both labels and annotations.

common.UpdateResourceLabels(clusterRole, resourceLabels)

// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is repeating at multiple places, which can be avoided with refactoring I feel.

common.UpdateResourceLabels(service, resourceLabels)

// Apply annotations from ControllerConfig
if len(esc.Spec.ControllerConfig.Annotations) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please refactor this repeating everywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

jira/valid-reference Indicates that this PR references a valid Jira ticket of any type.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants