From 6bba692f8ddc9fe19e9b3770fdc322d7fbef1006 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Fri, 21 Nov 2025 13:30:06 +0200 Subject: [PATCH 01/19] feat: add support for multiple SCP composition --- config/crds/v1/all-crds.yaml | 7 + ...cy.k8s.elastic.co_stackconfigpolicies.yaml | 7 + .../eck-operator-crds/templates/all-crds.yaml | 7 + docs/reference/api-reference/main.md | 1 + .../v1alpha1/stackconfigpolicy_types.go | 4 + pkg/controller/common/annotation/constants.go | 2 + pkg/controller/common/reconciler/secret.go | 109 +++- .../elasticsearch/filesettings/reconciler.go | 2 +- .../elasticsearch/filesettings/secret.go | 16 - .../elasticsearch/filesettings/secret_test.go | 47 -- .../stackconfigpolicy/controller.go | 260 +++++++--- .../stackconfigpolicy/controller_test.go | 36 -- .../elasticsearch_config_settings.go | 19 +- .../kibana_config_settings.go | 63 +-- .../kibana_config_settings_test.go | 140 +---- pkg/controller/stackconfigpolicy/ownership.go | 184 +++++++ .../stackconfigpolicy/stackconfigpolicy.go | 489 ++++++++++++++++++ 17 files changed, 1002 insertions(+), 391 deletions(-) create mode 100644 pkg/controller/stackconfigpolicy/ownership.go create mode 100644 pkg/controller/stackconfigpolicy/stackconfigpolicy.go diff --git a/config/crds/v1/all-crds.yaml b/config/crds/v1/all-crds.yaml index 749f0672cc1..68eec1ce4cc 100644 --- a/config/crds/v1/all-crds.yaml +++ b/config/crds/v1/all-crds.yaml @@ -10932,6 +10932,13 @@ spec: - secretName type: object type: array + weight: + default: 0 + description: |- + Weight determines the priority of this policy when multiple policies target the same resource. + Lower weight values take precedence. Defaults to 0. + format: int32 + type: integer type: object status: properties: diff --git a/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml b/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml index 146a6d3ab74..0f7bb05580e 100644 --- a/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml +++ b/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml @@ -288,6 +288,13 @@ spec: - secretName type: object type: array + weight: + default: 0 + description: |- + Weight determines the priority of this policy when multiple policies target the same resource. + Lower weight values take precedence. Defaults to 0. + format: int32 + type: integer type: object status: properties: diff --git a/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml b/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml index f1d9bcf4f79..243f40ab837 100644 --- a/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml +++ b/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml @@ -11002,6 +11002,13 @@ spec: - secretName type: object type: array + weight: + default: 0 + description: |- + Weight determines the priority of this policy when multiple policies target the same resource. + Lower weight values take precedence. Defaults to 0. + format: int32 + type: integer type: object status: properties: diff --git a/docs/reference/api-reference/main.md b/docs/reference/api-reference/main.md index 7bbb407bfd7..2cdd423b473 100644 --- a/docs/reference/api-reference/main.md +++ b/docs/reference/api-reference/main.md @@ -2068,6 +2068,7 @@ StackConfigPolicy represents a StackConfigPolicy resource in a Kubernetes cluste | Field | Description | | --- | --- | | *`resourceSelector`* __[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#labelselector-v1-meta)__ | | +| *`weight`* __integer__ | Weight determines the priority of this policy when multiple policies target the same resource.
Lower weight values take precedence. Defaults to 0. | | *`secureSettings`* __[SecretSource](#secretsource) array__ | Deprecated: SecureSettings only applies to Elasticsearch and is deprecated. It must be set per application instead. | | *`elasticsearch`* __[ElasticsearchConfigPolicySpec](#elasticsearchconfigpolicyspec)__ | | | *`kibana`* __[KibanaConfigPolicySpec](#kibanaconfigpolicyspec)__ | | diff --git a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go index 058f197d231..e7d17e20078 100644 --- a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go +++ b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go @@ -55,6 +55,10 @@ type StackConfigPolicyList struct { type StackConfigPolicySpec struct { ResourceSelector metav1.LabelSelector `json:"resourceSelector,omitempty"` + // Weight determines the priority of this policy when multiple policies target the same resource. + // Lower weight values take precedence. Defaults to 0. + // +kubebuilder:default=0 + Weight int32 `json:"weight,omitempty"` // Deprecated: SecureSettings only applies to Elasticsearch and is deprecated. It must be set per application instead. SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` Elasticsearch ElasticsearchConfigPolicySpec `json:"elasticsearch,omitempty"` diff --git a/pkg/controller/common/annotation/constants.go b/pkg/controller/common/annotation/constants.go index 41d5a890f80..34a5a68ebf9 100644 --- a/pkg/controller/common/annotation/constants.go +++ b/pkg/controller/common/annotation/constants.go @@ -25,4 +25,6 @@ const ( ElasticsearchConfigAndSecretMountsHashAnnotation = "policy.k8s.elastic.co/elasticsearch-config-mounts-hash" //nolint:gosec SourceSecretAnnotationName = "policy.k8s.elastic.co/source-secret-name" //nolint:gosec + + SoftOwnerRefsAnnotation = "eck.k8s.elastic.co/owner-refs" ) diff --git a/pkg/controller/common/reconciler/secret.go b/pkg/controller/common/reconciler/secret.go index d280023d4b8..0a07d2d5418 100644 --- a/pkg/controller/common/reconciler/secret.go +++ b/pkg/controller/common/reconciler/secret.go @@ -6,7 +6,9 @@ package reconciler import ( "context" + "encoding/json" "reflect" + "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -17,6 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" @@ -92,6 +95,49 @@ func SoftOwnerRefFromLabels(labels map[string]string) (SoftOwnerRef, bool) { return SoftOwnerRef{Namespace: namespace, Name: name, Kind: kind}, true } +// SoftOwnerRefs returns the soft owner references of the given object. +func SoftOwnerRefs(obj metav1.Object) ([]SoftOwnerRef, error) { + // Check if this Secret has a soft-owner kind label set + ownerKind, exists := obj.GetLabels()[SoftOwnerKindLabel] + if !exists { + // Not a soft-owned secret + return nil, nil + } + + // Check for multi-policy ownership (annotation-based) + if ownerRefsBytes, exists := obj.GetAnnotations()[commonannotation.SoftOwnerRefsAnnotation]; exists { + // Multi-policy soft owned secret - parse the JSON map of owners + var ownerRefs map[string]struct{} + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + return nil, err + } + + // Convert the map keys (namespaced name strings) back to NamespacedName objects + var ownerRefsNsn []SoftOwnerRef + for nsnStr := range ownerRefs { + // Split the string format "namespace/name" into components + nsnComponents := strings.Split(nsnStr, string(types.Separator)) + if len(nsnComponents) != 2 { + // Skip malformed entries + continue + } + ownerRefsNsn = append(ownerRefsNsn, SoftOwnerRef{Namespace: nsnComponents[0], Name: nsnComponents[1], Kind: ownerKind}) + } + + return ownerRefsNsn, nil + } + + // Fall back to single-policy ownership (label-based) + currentOwner, referenced := SoftOwnerRefFromLabels(obj.GetLabels()) + if !referenced { + // No soft owner found in labels + return nil, nil + } + + // Return the single owner as a slice with one element + return []SoftOwnerRef{currentOwner}, nil +} + // ReconcileSecretNoOwnerRef should be called to reconcile a Secret for which we explicitly don't want // an owner reference to be set, and want existing ownerReferences from previous operator versions to be removed, // because of this k8s bug: https://github.com/kubernetes/kubernetes/issues/65200 (fixed in k8s 1.20). @@ -200,43 +246,54 @@ func GarbageCollectAllSoftOwnedOrphanSecrets(ctx context.Context, c k8s.Client, var secrets corev1.SecretList if err := c.List(ctx, &secrets, - client.HasLabels{SoftOwnerNamespaceLabel, SoftOwnerNameLabel, SoftOwnerKindLabel}, + client.HasLabels{SoftOwnerKindLabel}, ); err != nil { return err } // remove any secret whose owner doesn't exist for i := range secrets.Items { secret := secrets.Items[i] - softOwner, referenced := SoftOwnerRefFromLabels(secret.Labels) - if !referenced { - continue - } - if restrictedToOwnerNamespace(softOwner.Kind) && softOwner.Namespace != secret.Namespace { - // Secret references an owner in a different namespace: this likely results - // from a "manual" copy of the secret in another namespace, not handled by the operator. - // We don't want to touch that secret. - continue + softOwners, err := SoftOwnerRefs(&secret) + if err != nil { + return err } - owner, managed := ownerKinds[softOwner.Kind] - if !managed { + if len(softOwners) == 0 { continue } - owner = k8s.DeepCopyObject(owner) - err := c.Get(ctx, types.NamespacedName{Namespace: softOwner.Namespace, Name: softOwner.Name}, owner) - if err != nil { - if apierrors.IsNotFound(err) { - // owner doesn't exit anymore - ulog.FromContext(ctx).Info("Deleting secret as part of garbage collection", - "namespace", secret.Namespace, "secret_name", secret.Name, - "owner_kind", softOwner.Kind, "owner_namespace", softOwner.Namespace, "owner_name", softOwner.Name, - ) - options := client.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &secret.UID}} - if err := c.Delete(ctx, &secret, &options); err != nil && !apierrors.IsNotFound(err) { - return err - } + + missingOwners := make(map[types.NamespacedName]client.Object) + for _, softOwner := range softOwners { + if restrictedToOwnerNamespace(softOwner.Kind) && softOwner.Namespace != secret.Namespace { + // Secret references an owner in a different namespace: this likely results + // from a "manual" copy of the secret in another namespace, not handled by the operator. + // We don't want to touch that secret. continue } - return err + owner, managed := ownerKinds[softOwner.Kind] + if !managed { + continue + } + owner = k8s.DeepCopyObject(owner) + err := c.Get(ctx, types.NamespacedName{Namespace: softOwner.Namespace, Name: softOwner.Name}, owner) + if err != nil { + if apierrors.IsNotFound(err) { + // owner doesn't exit anymore + ulog.FromContext(ctx).Info("Deleting secret as part of garbage collection", + "namespace", secret.Namespace, "secret_name", secret.Name, + "owner_kind", softOwner.Kind, "owner_namespace", softOwner.Namespace, "owner_name", softOwner.Name, + ) + missingOwners[types.NamespacedName{Namespace: softOwner.Namespace, Name: softOwner.Name}] = owner + continue + } + return err + } + } + + if len(missingOwners) == len(softOwners) { + options := client.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &secret.UID}} + if err := c.Delete(ctx, &secret, &options); err != nil && !apierrors.IsNotFound(err) { + return err + } } // owner still exists, keep the secret } diff --git a/pkg/controller/elasticsearch/filesettings/reconciler.go b/pkg/controller/elasticsearch/filesettings/reconciler.go index 63688838ac8..35b0aedc03e 100644 --- a/pkg/controller/elasticsearch/filesettings/reconciler.go +++ b/pkg/controller/elasticsearch/filesettings/reconciler.go @@ -29,7 +29,7 @@ var ( // managedAnnotations are the annotations managed by the operator for the stack config policy related secrets, which means that the operator // will always take precedence to update or remove these annotations. - managedAnnotations = []string{commonannotation.SecureSettingsSecretsAnnotationName, commonannotation.SettingsHashAnnotationName, commonannotation.ElasticsearchConfigAndSecretMountsHashAnnotation, commonannotation.KibanaConfigHashAnnotation} + managedAnnotations = []string{commonannotation.SecureSettingsSecretsAnnotationName, commonannotation.SettingsHashAnnotationName, commonannotation.ElasticsearchConfigAndSecretMountsHashAnnotation, commonannotation.KibanaConfigHashAnnotation, commonannotation.SoftOwnerRefsAnnotation} ) // ReconcileEmptyFileSettingsSecret reconciles an empty File settings Secret for the given Elasticsearch only when there is no Secret. diff --git a/pkg/controller/elasticsearch/filesettings/secret.go b/pkg/controller/elasticsearch/filesettings/secret.go index 69e9604d4b7..bbc5586a3f6 100644 --- a/pkg/controller/elasticsearch/filesettings/secret.go +++ b/pkg/controller/elasticsearch/filesettings/secret.go @@ -85,9 +85,6 @@ func newSettingsSecret(version int64, es types.NamespacedName, currentSecret *co } if policy != nil { - // set this policy as soft owner of this Secret - SetSoftOwner(settingsSecret, *policy) - // add the Secure Settings Secret sources to the Settings Secret if err := setSecureSettings(settingsSecret, *policy); err != nil { return corev1.Secret{}, 0, err @@ -160,19 +157,6 @@ func setSecureSettings(settingsSecret *corev1.Secret, policy policyv1alpha1.Stac return nil } -// CanBeOwnedBy return true if the Settings Secret can be owned by the given StackConfigPolicy, either because the Secret -// belongs to no one or because it already belongs to the given policy. -func CanBeOwnedBy(settingsSecret corev1.Secret, policy policyv1alpha1.StackConfigPolicy) (reconciler.SoftOwnerRef, bool) { - currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(settingsSecret.Labels) - // either there is no soft owner - if !referenced { - return reconciler.SoftOwnerRef{}, true - } - // or the owner is already the given policy - canBeOwned := currentOwner.Kind == policyv1alpha1.Kind && currentOwner.Namespace == policy.Namespace && currentOwner.Name == policy.Name - return currentOwner, canBeOwned -} - // getSecureSettings returns the SecureSettings Secret sources stores in an annotation of the given file settings Secret. func getSecureSettings(settingsSecret corev1.Secret) ([]commonv1.NamespacedSecretSource, error) { rawString, ok := settingsSecret.Annotations[commonannotation.SecureSettingsSecretsAnnotationName] diff --git a/pkg/controller/elasticsearch/filesettings/secret_test.go b/pkg/controller/elasticsearch/filesettings/secret_test.go index 8353ee957cc..f139864b68f 100644 --- a/pkg/controller/elasticsearch/filesettings/secret_test.go +++ b/pkg/controller/elasticsearch/filesettings/secret_test.go @@ -101,53 +101,6 @@ func Test_SettingsSecret_hasChanged(t *testing.T) { assert.Equal(t, strconv.FormatInt(newVersion, 10), newSettings.Metadata.Version) } -func Test_SettingsSecret_setSoftOwner_canBeOwnedBy(t *testing.T) { - es := types.NamespacedName{ - Namespace: "esNs", - Name: "esName", - } - policy := policyv1alpha1.StackConfigPolicy{ - TypeMeta: metav1.TypeMeta{ - Kind: policyv1alpha1.Kind, - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "policyNs", - Name: "policyName", - }, - } - otherPolicy := policyv1alpha1.StackConfigPolicy{ - TypeMeta: metav1.TypeMeta{ - Kind: policyv1alpha1.Kind, - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "otherPolicyNs", - Name: "otherPolicyName", - }, - } - - // empty settings can be owned by any policy - secret, _, err := NewSettingsSecretWithVersion(es, nil, nil, metadata.Metadata{}) - assert.NoError(t, err) - _, canBeOwned := CanBeOwnedBy(secret, policy) - assert.Equal(t, true, canBeOwned) - _, canBeOwned = CanBeOwnedBy(secret, otherPolicy) - assert.Equal(t, true, canBeOwned) - - // set a policy soft owner - SetSoftOwner(&secret, policy) - _, canBeOwned = CanBeOwnedBy(secret, policy) - assert.Equal(t, true, canBeOwned) - _, canBeOwned = CanBeOwnedBy(secret, otherPolicy) - assert.Equal(t, false, canBeOwned) - - // update the policy soft owner - SetSoftOwner(&secret, otherPolicy) - _, canBeOwned = CanBeOwnedBy(secret, policy) - assert.Equal(t, false, canBeOwned) - _, canBeOwned = CanBeOwnedBy(secret, otherPolicy) - assert.Equal(t, true, canBeOwned) -} - func Test_SettingsSecret_setSecureSettings_getSecureSettings(t *testing.T) { es := types.NamespacedName{ Namespace: "esNs", diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index b2cefc8ea52..33e63429e8d 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -6,6 +6,7 @@ package stackconfigpolicy import ( "context" + "errors" "fmt" "reflect" "regexp" @@ -110,13 +111,24 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileStackC func reconcileRequestForSoftOwnerPolicy() handler.TypedEventHandler[*corev1.Secret, reconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](func(ctx context.Context, secret *corev1.Secret) []reconcile.Request { - softOwner, referenced := reconciler.SoftOwnerRefFromLabels(secret.GetLabels()) - if !referenced || softOwner.Kind != policyv1alpha1.Kind { + softOwners, err := reconciler.SoftOwnerRefs(secret) + if err != nil { + ulog.Log.Error(err, "Fail to get soft-owner policies of secret", "secret_name", secret.GetName(), "secret_namespace", secret.GetNamespace()) return nil } - return []reconcile.Request{ - {NamespacedName: types.NamespacedName{Namespace: softOwner.Namespace, Name: softOwner.Name}}, + + if len(softOwners) == 0 { + return nil + } + + requests := make([]reconcile.Request, len(softOwners)) + for idx, nsn := range softOwners { + if nsn.Kind != policyv1alpha1.Kind { + continue + } + requests[idx] = reconcile.Request{NamespacedName: types.NamespacedName{Namespace: nsn.Namespace, Name: nsn.Name}} } + return requests }) } @@ -206,12 +218,12 @@ type esMap map[types.NamespacedName]esv1.Elasticsearch // instances configured by a StackConfigPolicy. type kbMap map[types.NamespacedName]kibanav1.Kibana -func (r *ReconcileStackConfigPolicy) doReconcile(ctx context.Context, policy policyv1alpha1.StackConfigPolicy) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { +func (r *ReconcileStackConfigPolicy) doReconcile(ctx context.Context, reconcilingPolicy policyv1alpha1.StackConfigPolicy) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { log := ulog.FromContext(ctx) log.V(1).Info("Reconcile StackConfigPolicy") results := reconciler.NewResult(ctx) - status := policyv1alpha1.NewStatus(policy) + status := policyv1alpha1.NewStatus(reconcilingPolicy) defer status.Update() // Enterprise license check @@ -222,22 +234,27 @@ func (r *ReconcileStackConfigPolicy) doReconcile(ctx context.Context, policy pol if !enabled { msg := "StackConfigPolicy is an enterprise feature. Enterprise features are disabled" log.Info(msg) - r.recorder.Eventf(&policy, corev1.EventTypeWarning, events.EventReconciliationError, msg) + r.recorder.Eventf(&reconcilingPolicy, corev1.EventTypeWarning, events.EventReconciliationError, msg) // we don't have a good way of watching for the license level to change so just requeue with a reasonably long delay return results.WithRequeue(5 * time.Minute), status } // run validation in case the webhook is disabled - if err := r.validate(ctx, &policy); err != nil { + if err := r.validate(ctx, &reconcilingPolicy); err != nil { status.Phase = policyv1alpha1.InvalidPhase - r.recorder.Eventf(&policy, corev1.EventTypeWarning, events.EventReasonValidation, err.Error()) + r.recorder.Eventf(&reconcilingPolicy, corev1.EventTypeWarning, events.EventReasonValidation, err.Error()) + return results.WithError(err), status + } + + var policyList policyv1alpha1.StackConfigPolicyList + if err := r.Client.List(ctx, &policyList); err != nil { return results.WithError(err), status } // reconcile elasticsearch resources - results, status = r.reconcileElasticsearchResources(ctx, policy, status) + results, status = r.reconcileElasticsearchResources(ctx, reconcilingPolicy, status, policyList.Items) // reconcile kibana resources - kibanaResults, status := r.reconcileKibanaResources(ctx, policy, status) + kibanaResults, status := r.reconcileKibanaResources(ctx, reconcilingPolicy, status, policyList.Items) // Combine results from kibana reconciliation with results from Elasticsearch reconciliation results.WithResults(kibanaResults) @@ -250,7 +267,7 @@ func (r *ReconcileStackConfigPolicy) doReconcile(ctx context.Context, policy pol return results, status } -func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context.Context, policy policyv1alpha1.StackConfigPolicy, status policyv1alpha1.StackConfigPolicyStatus) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { +func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context.Context, reconcilingPolicy policyv1alpha1.StackConfigPolicy, status policyv1alpha1.StackConfigPolicyStatus, allPolicies []policyv1alpha1.StackConfigPolicy) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { defer tracing.Span(&ctx)() log := ulog.FromContext(ctx) log.V(1).Info("Reconcile Elasticsearch resources") @@ -259,17 +276,17 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context // prepare the selector to find Elastic resources to configure selector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ - MatchLabels: policy.Spec.ResourceSelector.MatchLabels, - MatchExpressions: policy.Spec.ResourceSelector.MatchExpressions, + MatchLabels: reconcilingPolicy.Spec.ResourceSelector.MatchLabels, + MatchExpressions: reconcilingPolicy.Spec.ResourceSelector.MatchExpressions, }) if err != nil { return results.WithError(err), status } listOpts := client.ListOptions{LabelSelector: selector} - // restrict the search to the policy namespace if it is different from the operator namespace - if policy.Namespace != r.params.OperatorNamespace { - listOpts.Namespace = policy.Namespace + // restrict the search to the reconcilingPolicy namespace if it is different from the operator namespace + if reconcilingPolicy.Namespace != r.params.OperatorNamespace { + listOpts.Namespace = reconcilingPolicy.Namespace } // find the list of Elasticsearch to configure @@ -278,6 +295,7 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context return results.WithError(err), status } + reconcilingPolicyNsn := k8s.ExtractNamespacedName(&reconcilingPolicy) configuredResources := esMap{} for _, es := range esList.Items { log.V(1).Info("Reconcile StackConfigPolicy", "es_namespace", es.Namespace, "es_name", es.Name) @@ -294,7 +312,7 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } if v.LT(filesettings.FileBasedSettingsMinPreVersion) { err = fmt.Errorf("invalid version to configure resource Elasticsearch %s/%s: actual %s, expected >= %s", es.Namespace, es.Name, v, filesettings.FileBasedSettingsMinVersion) - r.recorder.Eventf(&policy, corev1.EventTypeWarning, events.EventReasonUnexpected, err.Error()) + r.recorder.Eventf(&reconcilingPolicy, corev1.EventTypeWarning, events.EventReasonUnexpected, err.Error()) results.WithError(err) err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ErrorPhase, err.Error(), policyv1alpha1.ElasticsearchResourceType) if err != nil { @@ -314,23 +332,41 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context return results.WithError(err), status } - // check that there is no other policy that already owns the Settings Secret - currentOwner, ok := filesettings.CanBeOwnedBy(actualSettingsSecret, policy) - if !ok { - err = fmt.Errorf("conflict: resource Elasticsearch %s/%s already configured by StackConfigpolicy %s/%s", es.Namespace, es.Name, currentOwner.Namespace, currentOwner.Name) - r.recorder.Eventf(&policy, corev1.EventTypeWarning, events.EventReasonUnexpected, err.Error()) - results.WithError(err) - err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ConflictPhase, err.Error(), policyv1alpha1.ElasticsearchResourceType) + // build the final config by merging all policies that target the given Elasticsearch cluster + esPolicyConfigFinal, err := getPolicyConfigForElasticsearch(&es, allPolicies, r.params) + switch { + case errors.Is(err, errMergeConflict): + log.V(1).Info("StackConfigPolicy merge conflict for Elasticsearch", "es_namespace", es.Namespace, "es_name", es.Name, "error", err) + results.WithRequeue(defaultRequeue) + if esPolicyConfigFinal == nil { + continue + } + conflictErr, exists := esPolicyConfigFinal.PoliciesWithConflictErrors[reconcilingPolicyNsn] + if !exists || conflictErr == nil { + continue + } + err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ConflictPhase, conflictErr.Error(), policyv1alpha1.ElasticsearchResourceType) if err != nil { return results.WithError(err), status } continue + case err != nil: + return results.WithError(err), status } // extract the metadata that should be propagated to children meta := metadata.Propagate(&es, metadata.Metadata{Labels: eslabel.NewLabels(k8s.ExtractNamespacedName(&es))}) // create the expected Settings Secret - expectedSecret, expectedVersion, err := filesettings.NewSettingsSecretWithVersion(esNsn, &actualSettingsSecret, &policy, meta) + expectedSecret, expectedVersion, err := filesettings.NewSettingsSecretWithVersion(esNsn, &actualSettingsSecret, &policyv1alpha1.StackConfigPolicy{ + ObjectMeta: reconcilingPolicy.ObjectMeta, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: esPolicyConfigFinal.Spec, + }, + }, meta) + if err != nil { + return results.WithError(err), status + } + err = setMultipleSoftOwners(&expectedSecret, esPolicyConfigFinal.PoliciesRefs) if err != nil { return results.WithError(err), status } @@ -339,8 +375,10 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context return results.WithError(err), status } - // Copy all the Secrets that are present in spec.elasticsearch.secretMounts - if err := reconcileSecretMounts(ctx, r.Client, es, &policy, meta); err != nil { + // Copy all the Secrets that are present in the reconciling policy. This is required to ensure that the + // ES cluster pods can mount the secrets that may be referenced in the Elasticsearch configuration after + // merging all policies. + if err := reconcileSecretMounts(ctx, r.Client, es, &reconcilingPolicy, meta); err != nil { if apierrors.IsNotFound(err) { err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ErrorPhase, err.Error(), policyv1alpha1.ElasticsearchResourceType) if err != nil { @@ -352,7 +390,11 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // create expected elasticsearch config secret - expectedConfigSecret, err := newElasticsearchConfigSecret(policy, es) + expectedConfigSecret, err := newElasticsearchConfigSecret(esPolicyConfigFinal.Spec, es) + if err != nil { + return results.WithError(err), status + } + err = setMultipleSoftOwners(&expectedConfigSecret, esPolicyConfigFinal.PoliciesRefs) if err != nil { return results.WithError(err), status } @@ -362,7 +404,7 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // Check if required Elasticsearch config and secret mounts are applied. - configAndSecretMountsApplied, err := elasticsearchConfigAndSecretMountsApplied(ctx, r.Client, policy, es) + configAndSecretMountsApplied, err := elasticsearchConfigAndSecretMountsApplied(ctx, r.Client, esPolicyConfigFinal.Spec, es) if err != nil { return results.WithError(err), status } @@ -386,18 +428,18 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // Add dynamic watches on the additional secret mounts - // This will also remove dynamic watches for secrets that no longer are refrenced in the stackconfigpolicy - if err = r.addDynamicWatchesOnAdditionalSecretMounts(policy); err != nil { + // This will also remove dynamic watches for secrets that no longer are referenced in the stackconfigpolicy + if err = r.addDynamicWatchesOnAdditionalSecretMounts(reconcilingPolicy); err != nil { return results.WithError(err), status } - // reset/delete Settings secrets for resources no longer selected by this policy - results.WithError(handleOrphanSoftOwnedSecrets(ctx, r.Client, k8s.ExtractNamespacedName(&policy), configuredResources, nil, policyv1alpha1.ElasticsearchResourceType)) + // reset/delete Settings secrets for resources no longer selected by this reconcilingPolicy + results.WithError(handleOrphanSoftOwnedSecrets(ctx, r.Client, reconcilingPolicyNsn, configuredResources, nil, policyv1alpha1.ElasticsearchResourceType)) return results, status } -func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Context, policy policyv1alpha1.StackConfigPolicy, status policyv1alpha1.StackConfigPolicyStatus) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { +func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Context, reconcilingPolicy policyv1alpha1.StackConfigPolicy, status policyv1alpha1.StackConfigPolicyStatus, allPolicies []policyv1alpha1.StackConfigPolicy) (*reconciler.Results, policyv1alpha1.StackConfigPolicyStatus) { defer tracing.Span(&ctx)() log := ulog.FromContext(ctx) log.V(1).Info("Reconcile Kibana Resources") @@ -406,17 +448,17 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex // prepare the selector to find Kibana resources to configure selector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ - MatchLabels: policy.Spec.ResourceSelector.MatchLabels, - MatchExpressions: policy.Spec.ResourceSelector.MatchExpressions, + MatchLabels: reconcilingPolicy.Spec.ResourceSelector.MatchLabels, + MatchExpressions: reconcilingPolicy.Spec.ResourceSelector.MatchExpressions, }) if err != nil { return results.WithError(err), status } listOpts := client.ListOptions{LabelSelector: selector} - // restrict the search to the policy namespace if it is different from the operator namespace - if policy.Namespace != r.params.OperatorNamespace { - listOpts.Namespace = policy.Namespace + // restrict the search to the reconcilingPolicy namespace if it is different from the operator namespace + if reconcilingPolicy.Namespace != r.params.OperatorNamespace { + listOpts.Namespace = reconcilingPolicy.Namespace } // find the list of Kibana to configure @@ -425,6 +467,7 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex return results.WithError(err), status } + reconcilingPolicyNsn := k8s.ExtractNamespacedName(&reconcilingPolicy) configuredResources := kbMap{} for _, kibana := range kibanaList.Items { log.V(1).Info("Reconcile StackConfigPolicy", "kibana_namespace", kibana.Namespace, "kibana_name", kibana.Name) @@ -433,29 +476,34 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex // keep the list of Kibana to be configured kibanaNsn := k8s.ExtractNamespacedName(&kibana) - // check that there is no other policy that already owns the kibana config secret - currentOwner, ok, err := canBeOwned(ctx, r.Client, policy, kibana) - if err != nil { - return results.WithError(err), status - } - - // record error if already owned by another stack config policy - if !ok { - err := fmt.Errorf("conflict: resource Kibana %s/%s already configured by StackConfigpolicy %s/%s", kibana.Namespace, kibana.Name, currentOwner.Namespace, currentOwner.Name) - r.recorder.Eventf(&policy, corev1.EventTypeWarning, events.EventReasonUnexpected, err.Error()) - results.WithError(err) - if err := status.AddPolicyErrorFor(kibanaNsn, policyv1alpha1.ConflictPhase, err.Error(), policyv1alpha1.KibanaResourceType); err != nil { + // build the final config by merging all policies that target the given Kibana instance + kbnPolicyConfigFinal, err := getPolicyConfigForKibana(&kibana, allPolicies, r.params) + switch { + case errors.Is(err, errMergeConflict): + log.V(1).Info("StackConfigPolicy merge conflict for Kibana", "kibana_namespace", kibana.Namespace, "kibana_name", kibana.Name, "error", err) + results.WithRequeue(defaultRequeue) + if kbnPolicyConfigFinal == nil { + continue + } + conflictErr, exists := kbnPolicyConfigFinal.PoliciesWithConflictErrors[reconcilingPolicyNsn] + if !exists || conflictErr == nil { + continue + } + err = status.AddPolicyErrorFor(kibanaNsn, policyv1alpha1.ConflictPhase, conflictErr.Error(), policyv1alpha1.KibanaResourceType) + if err != nil { return results.WithError(err), status } continue + case err != nil: + return results.WithError(err), status } // Create the Secret that holds the Kibana configuration. - if policy.Spec.Kibana.Config != nil { + if kbnPolicyConfigFinal.Spec.Config != nil { // Only add to configured resources if Kibana config is set. - // This will help clean up the config secret if config gets removed from the stack config policy. + // This will help clean up the config secret if config gets removed from the stack config reconcilingPolicy. configuredResources[kibanaNsn] = kibana - expectedConfigSecret, err := newKibanaConfigSecret(policy, kibana) + expectedConfigSecret, err := newKibanaConfigSecret(kbnPolicyConfigFinal.Spec, reconcilingPolicy.GetNamespace(), kibana, kbnPolicyConfigFinal.PoliciesRefs) if err != nil { return results.WithError(err), status } @@ -466,7 +514,7 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex } // Check if required Kibana configs are applied. - configApplied, err := kibanaConfigApplied(r.Client, policy, kibana) + configApplied, err := kibanaConfigApplied(r.Client, kbnPolicyConfigFinal.Spec, kibana) if err != nil { return results.WithError(err), status } @@ -478,8 +526,8 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex } } - // delete Settings secrets for resources no longer selected by this policy - results.WithError(deleteOrphanSoftOwnedSecrets(ctx, r.Client, k8s.ExtractNamespacedName(&policy), nil, configuredResources, policyv1alpha1.KibanaResourceType)) + // delete Settings secrets for resources no longer selected by this reconcilingPolicy + results.WithError(deleteOrphanSoftOwnedSecrets(ctx, r.Client, k8s.ExtractNamespacedName(&reconcilingPolicy), nil, configuredResources, policyv1alpha1.KibanaResourceType)) return results, status } @@ -585,8 +633,6 @@ func resetOrphanSoftOwnedFileSettingSecrets( log := ulog.FromContext(ctx) var secrets corev1.SecretList matchLabels := client.MatchingLabels{ - reconciler.SoftOwnerNamespaceLabel: softOwner.Namespace, - reconciler.SoftOwnerNameLabel: softOwner.Name, reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, commonlabels.StackConfigPolicyOnDeleteLabelName: commonlabels.OrphanSecretResetOnPolicyDelete, } @@ -605,6 +651,13 @@ func resetOrphanSoftOwnedFileSettingSecrets( } for i := range secrets.Items { s := secrets.Items[i] + owned, err := isPolicySoftOwner(&s, softOwner) + if err != nil { + return err + } + if !owned { + continue + } configuredApplicationType := s.Labels[commonv1.TypeLabelName] switch configuredApplicationType { case eslabel.Type: @@ -616,10 +669,6 @@ func resetOrphanSoftOwnedFileSettingSecrets( continue } - log.V(1).Info("Reconcile empty file settings Secret for Elasticsearch", - "es_namespace", namespacedName.Namespace, "es_name", namespacedName.Name, - "owner_namespace", softOwner.Namespace, "owner_name", softOwner.Name) - var es esv1.Elasticsearch err := c.Get(ctx, namespacedName, &es) if err != nil && !apierrors.IsNotFound(err) { @@ -630,9 +679,24 @@ func resetOrphanSoftOwnedFileSettingSecrets( return nil } - if err := filesettings.ReconcileEmptyFileSettingsSecret(ctx, c, es, false); err != nil { + remainingOwners, err := removePolicySoftOwner(&s, softOwner) + if err != nil { return err } + + if remainingOwners == 0 { + log.V(1).Info("Reconcile empty file settings Secret for Elasticsearch", + "es_namespace", namespacedName.Namespace, "es_name", namespacedName.Name, + "owner_namespace", softOwner.Namespace, "owner_name", softOwner.Name) + + if err := filesettings.ReconcileEmptyFileSettingsSecret(ctx, c, es, false); err != nil && !apierrors.IsNotFound(err) { + return err + } + } else { + if err := filesettings.ReconcileSecret(ctx, c, s, &es); err != nil && !apierrors.IsNotFound(err) { + return err + } + } case kblabel.Type: // Currently we do not reset labels for kibana, so we shouldn't hit this. // Implement if needed in the future @@ -656,8 +720,6 @@ func deleteOrphanSoftOwnedSecrets( ) error { var secrets corev1.SecretList matchLabels := client.MatchingLabels{ - reconciler.SoftOwnerNamespaceLabel: softOwner.Namespace, - reconciler.SoftOwnerNameLabel: softOwner.Name, reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, commonlabels.StackConfigPolicyOnDeleteLabelName: commonlabels.OrphanSecretDeleteOnPolicyDelete, } @@ -676,8 +738,16 @@ func deleteOrphanSoftOwnedSecrets( for i := range secrets.Items { secret := secrets.Items[i] - configuredApplicationType := secret.Labels[commonv1.TypeLabelName] + owned, err := isPolicySoftOwner(&secret, softOwner) + if err != nil { + return err + } + if !owned { + continue + } + var ownerObject client.Object + configuredApplicationType := secret.Labels[commonv1.TypeLabelName] switch configuredApplicationType { case eslabel.Type: namespacedName := types.NamespacedName{ @@ -688,6 +758,25 @@ func deleteOrphanSoftOwnedSecrets( if _, exist := configuredESResources[namespacedName]; exist { continue } + + if len(secret.OwnerReferences) == 0 { + break + } + + var es esv1.Elasticsearch + err := c.Get(ctx, types.NamespacedName{ + Namespace: secret.Namespace, + Name: secret.Labels[eslabel.ClusterNameLabelName], + }, &es) + switch { + case err == nil: + ownerObject = &es + case apierrors.IsNotFound(err): + break + default: + return err + } + case kblabel.Type: namespacedName := types.NamespacedName{ Namespace: secret.Namespace, @@ -697,15 +786,44 @@ func deleteOrphanSoftOwnedSecrets( if _, exist := configuredKibanaResources[namespacedName]; exist { continue } + + if len(secret.OwnerReferences) == 0 { + break + } + + var kbn kibanav1.Kibana + err := c.Get(ctx, types.NamespacedName{ + Namespace: secret.Namespace, + Name: secret.Labels[kblabel.KibanaNameLabelName], + }, &kbn) + switch { + case err == nil: + ownerObject = &kbn + case apierrors.IsNotFound(err): + break + default: + return err + } default: return fmt.Errorf("secret configured for unknown application type %s", configuredApplicationType) } - // given kibana/elasticsearch cluster is no longer managed by stack config policy, delete secret. - err := c.Delete(ctx, &secret) - if err != nil && !apierrors.IsNotFound(err) { + remainingOwners, err := removePolicySoftOwner(&secret, softOwner) + if err != nil { return err } + + if remainingOwners == 0 { + // given kibana/elasticsearch cluster is no longer managed by stack config policy, delete secret. + err = c.Delete(ctx, &secret) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + } else { + if err := filesettings.ReconcileSecret(ctx, c, secret, ownerObject); err != nil && !apierrors.IsNotFound(err) { + return err + } + } } return nil diff --git a/pkg/controller/stackconfigpolicy/controller_test.go b/pkg/controller/stackconfigpolicy/controller_test.go index 851c861bed7..f6c13767cc9 100644 --- a/pkg/controller/stackconfigpolicy/controller_test.go +++ b/pkg/controller/stackconfigpolicy/controller_test.go @@ -363,42 +363,6 @@ func TestReconcileStackConfigPolicy_Reconcile(t *testing.T) { wantErr: false, wantRequeueAfter: true, }, - { - name: "Reconcile Kibana already owned by another policy", - args: args{ - client: k8s.NewFakeClient(&policyFixture, &kibanaFixture, MkKibanaConfigSecret("ns", "another-policy", "ns", "testvalue")), - licenseChecker: &license.MockLicenseChecker{EnterpriseEnabled: true}, - }, - post: func(r ReconcileStackConfigPolicy, recorder record.FakeRecorder) { - events := fetchEvents(&recorder) - assert.ElementsMatch(t, []string{"Warning Unexpected conflict: resource Kibana ns/test-kb already configured by StackConfigpolicy ns/another-policy"}, events) - - policy := r.getPolicy(t, k8s.ExtractNamespacedName(&policyFixture)) - assert.Equal(t, 1, policy.Status.Resources) - assert.Equal(t, 0, policy.Status.Ready) - assert.Equal(t, policyv1alpha1.ConflictPhase, policy.Status.Phase) - }, - wantErr: true, - wantRequeueAfter: true, - }, - { - name: "Reconcile Elasticsearch already owned by another policy", - args: args{ - client: k8s.NewFakeClient(&policyFixture, &esFixture, conflictingSecretFixture), - licenseChecker: &license.MockLicenseChecker{EnterpriseEnabled: true}, - }, - post: func(r ReconcileStackConfigPolicy, recorder record.FakeRecorder) { - events := fetchEvents(&recorder) - assert.ElementsMatch(t, []string{"Warning Unexpected conflict: resource Elasticsearch ns/test-es already configured by StackConfigpolicy ns/another-policy"}, events) - - policy := r.getPolicy(t, k8s.ExtractNamespacedName(&policyFixture)) - assert.Equal(t, 1, policy.Status.Resources) - assert.Equal(t, 0, policy.Status.Ready) - assert.Equal(t, policyv1alpha1.ConflictPhase, policy.Status.Phase) - }, - wantErr: true, - wantRequeueAfter: true, - }, { name: "Elasticsearch cluster in old version without support for file based settings", args: args{ diff --git a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go index f190cb637b6..cee3455ab4b 100644 --- a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go +++ b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go @@ -33,19 +33,19 @@ const ( SecretsMountKey = "secretMounts.json" ) -func newElasticsearchConfigSecret(policy policyv1alpha1.StackConfigPolicy, es esv1.Elasticsearch) (corev1.Secret, error) { +func newElasticsearchConfigSecret(esConfig policyv1alpha1.ElasticsearchConfigPolicySpec, es esv1.Elasticsearch) (corev1.Secret, error) { data := make(map[string][]byte) - if len(policy.Spec.Elasticsearch.SecretMounts) > 0 { - secretMountBytes, err := json.Marshal(policy.Spec.Elasticsearch.SecretMounts) + if len(esConfig.SecretMounts) > 0 { + secretMountBytes, err := json.Marshal(esConfig.SecretMounts) if err != nil { return corev1.Secret{}, err } data[SecretsMountKey] = secretMountBytes } - elasticsearchAndMountsConfigHash := getElasticsearchConfigAndMountsHash(policy.Spec.Elasticsearch.Config, policy.Spec.Elasticsearch.SecretMounts) - if policy.Spec.Elasticsearch.Config != nil { - configDataJSONBytes, err := policy.Spec.Elasticsearch.Config.MarshalJSON() + elasticsearchAndMountsConfigHash := getElasticsearchConfigAndMountsHash(esConfig.Config, esConfig.SecretMounts) + if esConfig.Config != nil { + configDataJSONBytes, err := esConfig.Config.MarshalJSON() if err != nil { return corev1.Secret{}, err } @@ -67,9 +67,6 @@ func newElasticsearchConfigSecret(policy policyv1alpha1.StackConfigPolicy, es es Data: data, } - // Set StackConfigPolicy as the soft owner - filesettings.SetSoftOwner(&elasticsearchConfigSecret, policy) - // Add label to delete secret on deletion of the stack config policy elasticsearchConfigSecret.Labels[commonlabels.StackConfigPolicyOnDeleteLabelName] = commonlabels.OrphanSecretDeleteOnPolicyDelete @@ -126,7 +123,7 @@ func getElasticsearchConfigAndMountsHash(elasticsearchConfig *commonv1.Config, s } // elasticsearchConfigAndSecretMountsApplied checks if the Elasticsearch config and secret mounts from the stack config policy have been applied to the Elasticsearch cluster. -func elasticsearchConfigAndSecretMountsApplied(ctx context.Context, c k8s.Client, policy policyv1alpha1.StackConfigPolicy, es esv1.Elasticsearch) (bool, error) { +func elasticsearchConfigAndSecretMountsApplied(ctx context.Context, c k8s.Client, esConfigPolicy policyv1alpha1.ElasticsearchConfigPolicySpec, es esv1.Elasticsearch) (bool, error) { // Get Pods for the given Elasticsearch podList := corev1.PodList{} if err := c.List(ctx, &podList, client.InNamespace(es.Namespace), client.MatchingLabels{ @@ -135,7 +132,7 @@ func elasticsearchConfigAndSecretMountsApplied(ctx context.Context, c k8s.Client return false, err } - elasticsearchAndMountsConfigHash := getElasticsearchConfigAndMountsHash(policy.Spec.Elasticsearch.Config, policy.Spec.Elasticsearch.SecretMounts) + elasticsearchAndMountsConfigHash := getElasticsearchConfigAndMountsHash(esConfigPolicy.Config, esConfigPolicy.SecretMounts) for _, esPod := range podList.Items { if esPod.Annotations[commonannotation.ElasticsearchConfigAndSecretMountsHashAnnotation] != elasticsearchAndMountsConfigHash { return false, nil diff --git a/pkg/controller/stackconfigpolicy/kibana_config_settings.go b/pkg/controller/stackconfigpolicy/kibana_config_settings.go index b7406610524..61bbf735077 100644 --- a/pkg/controller/stackconfigpolicy/kibana_config_settings.go +++ b/pkg/controller/stackconfigpolicy/kibana_config_settings.go @@ -5,15 +5,10 @@ package stackconfigpolicy import ( - "context" "encoding/json" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/metadata" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" kibanav1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" @@ -21,8 +16,7 @@ import ( commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/hash" commonlabels "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/metadata" kblabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/kibana/label" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ) @@ -31,12 +25,12 @@ const ( KibanaConfigKey = "kibana.json" ) -func newKibanaConfigSecret(policy policyv1alpha1.StackConfigPolicy, kibana kibanav1.Kibana) (corev1.Secret, error) { - kibanaConfigHash := getKibanaConfigHash(policy.Spec.Kibana.Config) +func newKibanaConfigSecret(kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, policyNamespace string, kibana kibanav1.Kibana, policyRefs []policyv1alpha1.StackConfigPolicy) (corev1.Secret, error) { + kibanaConfigHash := getKibanaConfigHash(kbConfigPolicy.Config) configDataJSONBytes := []byte("") var err error - if policy.Spec.Kibana.Config != nil { - if configDataJSONBytes, err = policy.Spec.Kibana.Config.MarshalJSON(); err != nil { + if kbConfigPolicy.Config != nil { + if configDataJSONBytes, err = kbConfigPolicy.Config.MarshalJSON(); err != nil { return corev1.Secret{}, err } } @@ -58,14 +52,15 @@ func newKibanaConfigSecret(policy policyv1alpha1.StackConfigPolicy, kibana kiban }, } - // Set policy as the soft owner - filesettings.SetSoftOwner(&kibanaConfigSecret, policy) - // Add label to delete secret on deletion of the stack config policy kibanaConfigSecret.Labels[commonlabels.StackConfigPolicyOnDeleteLabelName] = commonlabels.OrphanSecretDeleteOnPolicyDelete // Add SecureSettings as annotation - if err = setKibanaSecureSettings(&kibanaConfigSecret, policy); err != nil { + if err = setKibanaSecureSettings(&kibanaConfigSecret, kbConfigPolicy, policyNamespace); err != nil { + return kibanaConfigSecret, err + } + + if err = setMultipleSoftOwners(&kibanaConfigSecret, policyRefs); err != nil { return kibanaConfigSecret, err } @@ -83,13 +78,13 @@ func GetPolicyConfigSecretName(kibanaName string) string { return kibanaName + "-kb-policy-config" } -func kibanaConfigApplied(c k8s.Client, policy policyv1alpha1.StackConfigPolicy, kb kibanav1.Kibana) (bool, error) { +func kibanaConfigApplied(c k8s.Client, kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, kb kibanav1.Kibana) (bool, error) { existingKibanaPods, err := k8s.PodsMatchingLabels(c, kb.Namespace, map[string]string{"kibana.k8s.elastic.co/name": kb.Name}) if err != nil || len(existingKibanaPods) == 0 { return false, err } - kibanaConfigHash := getKibanaConfigHash(policy.Spec.Kibana.Config) + kibanaConfigHash := getKibanaConfigHash(kbConfigPolicy.Config) for _, kbPod := range existingKibanaPods { if kbPod.Annotations[commonannotation.KibanaConfigHashAnnotation] != kibanaConfigHash { return false, nil @@ -99,42 +94,16 @@ func kibanaConfigApplied(c k8s.Client, policy policyv1alpha1.StackConfigPolicy, return true, nil } -func canBeOwned(ctx context.Context, c k8s.Client, policy policyv1alpha1.StackConfigPolicy, kb kibanav1.Kibana) (reconciler.SoftOwnerRef, bool, error) { - // Check if the secret already exists - var kibanaConfigSecret corev1.Secret - err := c.Get(ctx, types.NamespacedName{ - Name: GetPolicyConfigSecretName(kb.Name), - Namespace: kb.Namespace, - }, &kibanaConfigSecret) - if err != nil && !apierrors.IsNotFound(err) { - return reconciler.SoftOwnerRef{}, false, err - } - - if apierrors.IsNotFound(err) { - // Secret does not exist, return true - return reconciler.SoftOwnerRef{}, true, nil - } - - currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(kibanaConfigSecret.Labels) - // either there is no soft owner - if !referenced { - return currentOwner, true, nil - } - // or the owner is already the given policy - canBeOwned := currentOwner.Kind == policyv1alpha1.Kind && currentOwner.Namespace == policy.Namespace && currentOwner.Name == policy.Name - return currentOwner, canBeOwned, nil -} - // setKibanaSecureSettings stores the SecureSettings Secret sources referenced in the given StackConfigPolicy for Kibana in the annotation of the Kibana config Secret. -func setKibanaSecureSettings(settingsSecret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) error { - if len(policy.Spec.Kibana.SecureSettings) == 0 { +func setKibanaSecureSettings(settingsSecret *corev1.Secret, kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, policyNamespace string) error { + if len(kbConfigPolicy.SecureSettings) == 0 { return nil } var secretSources []commonv1.NamespacedSecretSource //nolint:prealloc // SecureSettings field under Kibana in the StackConfigPolicy - for _, src := range policy.Spec.Kibana.SecureSettings { - secretSources = append(secretSources, commonv1.NamespacedSecretSource{Namespace: policy.GetNamespace(), SecretName: src.SecretName, Entries: src.Entries}) + for _, src := range kbConfigPolicy.SecureSettings { + secretSources = append(secretSources, commonv1.NamespacedSecretSource{Namespace: policyNamespace, SecretName: src.SecretName, Entries: src.Entries}) } bytes, err := json.Marshal(secretSources) diff --git a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go index 1397e3edefc..66b901d7005 100644 --- a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go +++ b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go @@ -5,7 +5,6 @@ package stackconfigpolicy import ( - "context" "testing" "github.com/stretchr/testify/require" @@ -15,7 +14,6 @@ import ( commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" kibanav1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ) @@ -69,12 +67,11 @@ func Test_newKibanaConfigSecret(t *testing.T) { "kibana.k8s.elastic.co/name": "test-kb", "common.k8s.elastic.co/type": "kibana", "eck.k8s.elastic.co/owner-kind": "StackConfigPolicy", - "eck.k8s.elastic.co/owner-name": "test-policy", - "eck.k8s.elastic.co/owner-namespace": "test-policy-ns", }, Annotations: map[string]string{ "policy.k8s.elastic.co/kibana-config-hash": "3077592849", "policy.k8s.elastic.co/secure-settings-secrets": `[{"namespace":"test-policy-ns","secretName":"shared-secret"}]`, + "eck.k8s.elastic.co/owner-refs": `{"test-policy-ns/test-policy":{}}`, }, }, Data: map[string][]byte{ @@ -86,7 +83,7 @@ func Test_newKibanaConfigSecret(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := newKibanaConfigSecret(*tt.args.policy, tt.args.kb) + got, err := newKibanaConfigSecret(tt.args.policy.Spec.Kibana, tt.args.policy.GetNamespace(), tt.args.kb, []policyv1alpha1.StackConfigPolicy{*tt.args.policy}) require.NoError(t, err) require.Equal(t, tt.want, got) }) @@ -193,141 +190,13 @@ func Test_kibanaConfigApplied(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := kibanaConfigApplied(tt.args.client, *tt.args.policy, tt.args.kb) + got, err := kibanaConfigApplied(tt.args.client, tt.args.policy.Spec.Kibana, tt.args.kb) require.NoError(t, err) require.Equal(t, tt.want, got) }) } } -func Test_canBeOwned(t *testing.T) { - type args struct { - kb kibanav1.Kibana - policy *policyv1alpha1.StackConfigPolicy - client k8s.Client - } - - tests := []struct { - name string - args args - wantSecretRef reconciler.SoftOwnerRef - wantCanbeOwned bool - }{ - { - name: "secret owned by current policy", - args: args{ - kb: kibanav1.Kibana{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-kb", - Namespace: "test-ns", - }, - }, - policy: &policyv1alpha1.StackConfigPolicy{ - TypeMeta: metav1.TypeMeta{ - Kind: "StackConfigPolicy", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-policy", - Namespace: "test-policy-ns", - }, - Spec: policyv1alpha1.StackConfigPolicySpec{ - Kibana: policyv1alpha1.KibanaConfigPolicySpec{ - Config: &commonv1.Config{ - Data: map[string]interface{}{ - "xpack.canvas.enabled": true, - }, - }, - }, - }, - }, - client: k8s.NewFakeClient(MkKibanaConfigSecret("test-ns", "test-policy", "test-policy-ns", "3077592849")), - }, - wantSecretRef: reconciler.SoftOwnerRef{ - Namespace: "test-policy-ns", - Name: "test-policy", - Kind: "StackConfigPolicy", - }, - wantCanbeOwned: true, - }, - { - name: "secret owned by another policy", - args: args{ - kb: kibanav1.Kibana{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-kb", - Namespace: "test-ns", - }, - }, - policy: &policyv1alpha1.StackConfigPolicy{ - TypeMeta: metav1.TypeMeta{ - Kind: "StackConfigPolicy", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-policy", - Namespace: "test-policy-ns", - }, - Spec: policyv1alpha1.StackConfigPolicySpec{ - Kibana: policyv1alpha1.KibanaConfigPolicySpec{ - Config: &commonv1.Config{ - Data: map[string]interface{}{ - "xpack.canvas.enabled": true, - }, - }, - }, - }, - }, - client: k8s.NewFakeClient(MkKibanaConfigSecret("test-ns", "test-another-policy", "test-policy-ns", "3077592849")), - }, - wantSecretRef: reconciler.SoftOwnerRef{ - Namespace: "test-policy-ns", - Name: "test-another-policy", - Kind: "StackConfigPolicy", - }, - wantCanbeOwned: false, - }, - { - name: "secret does not exist", - args: args{ - kb: kibanav1.Kibana{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-kb", - Namespace: "test-ns", - }, - }, - policy: &policyv1alpha1.StackConfigPolicy{ - TypeMeta: metav1.TypeMeta{ - Kind: "StackConfigPolicy", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-policy", - Namespace: "test-policy-ns", - }, - Spec: policyv1alpha1.StackConfigPolicySpec{ - Kibana: policyv1alpha1.KibanaConfigPolicySpec{ - Config: &commonv1.Config{ - Data: map[string]interface{}{ - "xpack.canvas.enabled": true, - }, - }, - }, - }, - }, - client: k8s.NewFakeClient(), - }, - wantCanbeOwned: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - secretRef, canBeOwned, err := canBeOwned(context.Background(), tt.args.client, *tt.args.policy, tt.args.kb) - require.NoError(t, err) - require.Equal(t, tt.wantSecretRef, secretRef) - require.Equal(t, tt.wantCanbeOwned, canBeOwned) - }) - } -} - func mkKibanaPod(namespace string, hashapplied bool, hashValue string) *corev1.Pod { pod := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -356,11 +225,10 @@ func MkKibanaConfigSecret(namespace string, owningPolicyName string, owningPolic "kibana.k8s.elastic.co/name": "test-kb", "common.k8s.elastic.co/type": "kibana", "eck.k8s.elastic.co/owner-kind": "StackConfigPolicy", - "eck.k8s.elastic.co/owner-name": owningPolicyName, - "eck.k8s.elastic.co/owner-namespace": owningPolicyNamespace, }, Annotations: map[string]string{ "policy.k8s.elastic.co/kibana-config-hash": hashValue, + "eck.k8s.elastic.co/owner-refs": `{"` + owningPolicyNamespace + `/` + owningPolicyName + `":{}}`, }, }, Data: map[string][]byte{ diff --git a/pkg/controller/stackconfigpolicy/ownership.go b/pkg/controller/stackconfigpolicy/ownership.go new file mode 100644 index 00000000000..c4c08fa9461 --- /dev/null +++ b/pkg/controller/stackconfigpolicy/ownership.go @@ -0,0 +1,184 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package stackconfigpolicy + +import ( + "encoding/json" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" +) + +// setSingleSoftOwner marks a Secret as soft-owned by a single StackConfigPolicy. +// This uses labels (reconciler.SoftOwnerKindLabel, reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) +// to store the ownership relationship, allowing the policy to manage +// the Secret's lifecycle without using Kubernetes OwnerReferences. +func setSingleSoftOwner(secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { + if secret == nil { + return + } + + if secret.Annotations != nil { + // Remove multi-owner annotation if it exists + delete(secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + } + + filesettings.SetSoftOwner(secret, policy) +} + +// setMultipleSoftOwners marks a Secret as soft-owned by multiple StackConfigPolicies. +// Unlike single ownership (which uses labels), multiple ownership stores a JSON-encoded +// map of owner references in annotations to accommodate multiple policies. +// +// The function sets: +// - The label reconciler.SoftOwnerKindLabel indicating the soft owner kind to policyv1alpha1.Kind +// - The annotation commonannotation.SoftOwnerRefsAnnotation containing a JSON map of all owner namespaced names +// +// Returns an error if JSON marshaling fails. +func setMultipleSoftOwners(secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy) error { + if secret == nil { + return nil + } + + if secret.Labels == nil { + secret.Labels = map[string]string{} + } else { + // Remove single owner labels if they exist + delete(secret.Labels, reconciler.SoftOwnerNamespaceLabel) + delete(secret.Labels, reconciler.SoftOwnerNameLabel) + } + + // Mark this Secret as being soft-owned by StackConfigPolicy resources + secret.Labels[reconciler.SoftOwnerKindLabel] = policyv1alpha1.Kind + + if secret.Annotations == nil { + secret.Annotations = map[string]string{} + } + + // Build a map of owner references using namespaced names as keys. + // We use struct{} as values since we only care about the keys (acts as a set). + ownerRefs := make(map[string]struct{}) + for _, p := range policies { + ownerRefs[k8s.ExtractNamespacedName(&p).String()] = struct{}{} + } + + // Store the owner references as a JSON-encoded annotation + ownerRefsBytes, err := json.Marshal(ownerRefs) + if err != nil { + return err + } + + secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + return nil +} + +// isPolicySoftOwner checks if the given StackConfigPolicy is a soft owner of the Secret. +// It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) +// and multi-owner (commonannotation.SoftOwnerRefsAnnotation) scenarios. +// Returns true or false depending on whether the policy is an owner of the secret +// and an error if there's a problem unmarshalling the owner references +func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (bool, error) { + if secret == nil { + return false, nil + } + + // Check if this Secret is soft-owned by a StackConfigPolicy + if ownerKind := secret.Labels[reconciler.SoftOwnerKindLabel]; ownerKind != policyv1alpha1.Kind { + // Not a policy soft-owned secret + return false, nil + } + + // Check for multi-policy ownership (annotation-based) + if ownerRefsBytes, exists := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]; exists { + // Multi-policy soft owned secret - parse the JSON map of owners + var ownerRefs map[string]struct{} + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + return false, err + } + // Check if the given policy is in the set of owners + _, exists := ownerRefs[types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()] + return exists, nil + } + + // Fall back to single-policy ownership (label-based) + currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(secret.Labels) + if !referenced { + // No soft owner found in labels + return false, nil + } + + // Check if the single owner matches the given policy + return currentOwner.Name == policyNsn.Name && currentOwner.Namespace == policyNsn.Namespace, nil +} + +// removePolicySoftOwner removes a StackConfigPolicy if it is soft owning the given secret. +// It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) +// and multi-owner (commonannotation.SoftOwnerRefsAnnotation) scenarios. +// +// For single-owner secrets: +// - If the owner matches, removes all soft owner labels/annotations +// - If the owner doesn't match, leaves the secret unchanged +// +// For multi-owner secrets: +// - Removes the policy from the JSON map in annotations +// - Updates the annotation with the remaining owners or removes the annotation if no owners remain +// +// Returns the number of remaining owners after removal and an error if there's a problem with JSON marshaling/unmarshalling +func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (int, error) { + if secret == nil { + return 0, nil + } + + // Check for multi-policy ownership (annotation-based) + if ownerRefsBytes, exists := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]; exists { + // Multi-policy soft owned secret - parse and update the owner map + var ownerRefs map[string]struct{} + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + return 0, err + } + + // Remove the specified policy from the owner map + delete(ownerRefs, types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()) + if len(ownerRefs) == 0 { + // No owners remain, remove the annotation + delete(secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + return 0, nil + } + + // Marshal the updated owner map back to JSON + ownerRefsBytes, err := json.Marshal(ownerRefs) + if err != nil { + return 0, err + } + + // Update the annotation with the new owner list + secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + return len(ownerRefs), nil + } + + // Handle single-policy ownership (label-based) + currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(secret.Labels) + if !referenced { + // No soft owner found + return 0, nil + } + + // Check if the single owner matches the policy to be removed + if currentOwner.Name == policyNsn.Name && currentOwner.Namespace == policyNsn.Namespace { + // Remove the soft owner labels since this was the only owner + delete(secret.Labels, reconciler.SoftOwnerNamespaceLabel) + delete(secret.Labels, reconciler.SoftOwnerNameLabel) + return 0, nil + } + + // The policy to remove doesn't match the current owner, so no change + return 1, nil +} diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go new file mode 100644 index 00000000000..b48b46e52fa --- /dev/null +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -0,0 +1,489 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package stackconfigpolicy + +import ( + "errors" + "fmt" + "maps" + "slices" + "strconv" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + + commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" + esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" + kbv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" + policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/settings" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" +) + +var errMergeConflict = errors.New("merge conflict") + +// esPolicyConfig represents the merged configuration from multiple StackConfigPolicies +// that apply to a specific Elasticsearch cluster. +type esPolicyConfig struct { + // Spec holds the merged Elasticsearch configuration from all applicable policies + Spec policyv1alpha1.ElasticsearchConfigPolicySpec + // PoliciesWithConflictErrors maps policy namespaced names to conflict errors when multiple policies + // have the same weight + PoliciesWithConflictErrors map[types.NamespacedName]error + // PoliciesRefs contains all StackConfigPolicies that target this Elasticsearch cluster + PoliciesRefs []policyv1alpha1.StackConfigPolicy +} + +// getPolicyConfigForElasticsearch builds a merged stack config policy for the given Elasticsearch cluster. +// It processes all provided policies, filtering those that target the Elasticsearch cluster, and merges them +// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. +// Returns an esPolicyConfig containing the merged configuration and any error occurred during merging. +func getPolicyConfigForElasticsearch(es *esv1.Elasticsearch, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*esPolicyConfig, error) { + esPolicy := esPolicyConfig{ + PoliciesWithConflictErrors: make(map[types.NamespacedName]error), + } + if len(allPolicies) == 0 { + return &esPolicy, nil + } + + // Group policies by weight + var weights []int32 + weightKeyStackPolicies := make(map[int32][]*policyv1alpha1.StackConfigPolicy) + for _, p := range allPolicies { + isRef, err := doesPolicyRefsObject(&p, es, params.OperatorNamespace) + if err != nil { + return nil, err + } + if !isRef { + // policy does not target the given Elasticsearch + continue + } + + if _, exists := weightKeyStackPolicies[p.Spec.Weight]; !exists { + weights = append(weights, p.Spec.Weight) + } + + weightKeyStackPolicies[p.Spec.Weight] = append(weightKeyStackPolicies[p.Spec.Weight], &p) + esPolicy.PoliciesRefs = append(esPolicy.PoliciesRefs, p) + } + + if len(esPolicy.PoliciesRefs) == 1 { + // Since we have only one policy avoid merging (including canonicalise) + // and thus avoid any reconciliation storm caused by unnecessary + // secret changes + esConfigPolicy := esPolicy.PoliciesRefs[0].Spec.Elasticsearch.DeepCopy() + esPolicy.Spec = *esConfigPolicy + return &esPolicy, nil + } + + // Process policies in order of weight (lowest first) + slices.Sort(weights) + + // Reverse the weights so that we process policies in order of weight (highest first) + slices.Reverse(weights) + + var previouslyAppliedPolicy *policyv1alpha1.StackConfigPolicy + for _, weight := range weights { + policiesWithSameWeight := weightKeyStackPolicies[weight] + if len(policiesWithSameWeight) > 1 { + // Multiple policies with the same weight - this is a conflict + conflictErr := getPolicyConflictError(policiesWithSameWeight, weight) + for _, p := range policiesWithSameWeight { + esPolicy.PoliciesWithConflictErrors[k8s.ExtractNamespacedName(p)] = conflictErr + } + return &esPolicy, conflictErr + } + policy := policiesWithSameWeight[0] + // Merge the single policy at this weight level + if err := mergeElasticsearchConfig(&esPolicy.Spec, policy.Spec.Elasticsearch); err != nil { + if errors.Is(err, errMergeConflict) { + policyNsn := k8s.ExtractNamespacedName(policy) + esPolicy.PoliciesWithConflictErrors[policyNsn] = err + + if previouslyAppliedPolicy != nil { + previouslyAppliedPolicyNsn := k8s.ExtractNamespacedName(previouslyAppliedPolicy) + esPolicy.PoliciesWithConflictErrors[previouslyAppliedPolicyNsn] = err + } + return &esPolicy, err + } + return nil, err + } + previouslyAppliedPolicy = policy + } + + return &esPolicy, nil +} + +// kbnPolicyConfig represents the merged configuration from multiple StackConfigPolicies +// that apply to a specific Kibana instance. +type kbnPolicyConfig struct { + // Spec contains the merged Kibana configuration from all applicable policies + Spec policyv1alpha1.KibanaConfigPolicySpec + // PoliciesWithConflictErrors maps policy namespaced names to conflict errors when multiple policies + // have the same weight + PoliciesWithConflictErrors map[types.NamespacedName]error + // PoliciesRefs contains all StackConfigPolicies that target this Kibana instance + PoliciesRefs []policyv1alpha1.StackConfigPolicy +} + +// getPolicyConfigForKibana builds a merged stack config policy for the given Kibana instance. +// It processes all provided policies, filtering those that target the Kibana instance, and merges them +// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. +// Returns an kbnPolicyConfig containing the merged configuration and any error occurred during merging. +func getPolicyConfigForKibana(kb *kbv1.Kibana, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*kbnPolicyConfig, error) { + kbPolicy := kbnPolicyConfig{ + PoliciesWithConflictErrors: make(map[types.NamespacedName]error), + } + if len(allPolicies) == 0 { + return &kbPolicy, nil + } + + // Group policies by weight + var weights []int32 + weightKeyStackPolicies := make(map[int32][]*policyv1alpha1.StackConfigPolicy) + for _, p := range allPolicies { + isRef, err := doesPolicyRefsObject(&p, kb, params.OperatorNamespace) + if err != nil { + return nil, err + } + if !isRef { + // policy does not target the given Kibana instance + continue + } + + if _, exists := weightKeyStackPolicies[p.Spec.Weight]; !exists { + weights = append(weights, p.Spec.Weight) + } + + weightKeyStackPolicies[p.Spec.Weight] = append(weightKeyStackPolicies[p.Spec.Weight], &p) + kbPolicy.PoliciesRefs = append(kbPolicy.PoliciesRefs, p) + } + + if len(kbPolicy.PoliciesRefs) == 1 { + // Since we have only one policy avoid merging (including canonicalise) + // and thus don't cause a reconciliation storm by unnecessary + // secret changes + kbConfigPolicy := kbPolicy.PoliciesRefs[0].Spec.Kibana.DeepCopy() + kbPolicy.Spec = *kbConfigPolicy + return &kbPolicy, nil + } + + // Process policies in order of weight (lowest first) + slices.Sort(weights) + + // Reverse the weights so that we process policies in order of weight (highest first) + slices.Reverse(weights) + + for _, weight := range weights { + policiesWithWeight := weightKeyStackPolicies[weight] + if len(policiesWithWeight) > 1 { + // Multiple policies with the same weight - this is a conflict + conflictErr := getPolicyConflictError(policiesWithWeight, weight) + for _, p := range policiesWithWeight { + kbPolicy.PoliciesWithConflictErrors[k8s.ExtractNamespacedName(p)] = conflictErr + } + return &kbPolicy, conflictErr + } + + // Merge the single policy at this weight level + if err := mergeKibanaConfig(&kbPolicy.Spec, policiesWithWeight[0].Spec.Kibana); err != nil { + return nil, err + } + } + + return &kbPolicy, nil +} + +// doesPolicyRefsObject checks if the given StackConfigPolicy targets the given Elasticsearch cluster. +// A policy targets an Elasticsearch cluster if both following conditions are met: +// 1. The policy is in either the operator namespace or the same namespace as the Elasticsearch cluster +// 2. The policy's label selector matches the Elasticsearch cluster's labels +// Returns true or false depending on whether the given policy targets the Elasticsearch cluster and +// an error if the label selector is invalid. +func doesPolicyRefsObject(policy *policyv1alpha1.StackConfigPolicy, obj metav1.Object, operatorNamespace string) (bool, error) { + // Convert the label selector to a selector object + selector, err := metav1.LabelSelectorAsSelector(&policy.Spec.ResourceSelector) + if err != nil { + return false, err + } + + // Check namespace restrictions; the policy must be in operator namespace or same namespace as the target object + if policy.Namespace != operatorNamespace && policy.Namespace != obj.GetNamespace() { + return false, nil + } + + // Check if the label selector matches the Elasticsearch labels + if !selector.Matches(labels.Set(obj.GetLabels())) { + return false, nil + } + + return true, nil +} + +// getPolicyConflictError creates an error message listing all policies that have conflicting weights. +// The error message includes the namespaced names of all conflicting policies. +// Returns an error with a message listing all conflicting policy names. +func getPolicyConflictError(policies []*policyv1alpha1.StackConfigPolicy, weight int32) error { + strBuilder := strings.Builder{} + + strBuilder.WriteString("multiple stack config policies ") + for idx, p := range policies { + if idx > 0 { + strBuilder.WriteString(", ") + } + strBuilder.WriteString(`"`) + strBuilder.WriteString(k8s.ExtractNamespacedName(p).String()) + strBuilder.WriteString(`"`) + } + strBuilder.WriteString(" with the same weight ") + strBuilder.WriteString(strconv.Itoa(int(weight))) + return fmt.Errorf("%w: %s", errMergeConflict, strBuilder.String()) +} + +// mergeKibanaConfig merges the source KibanaConfigPolicySpec into the destination. +// For configuration fields (Config, SecureSettings), it performs a deep merge +// where source values override destination values at the field level. +// For SecretMounts and SecureSettings, it merges by name/key, with source values taking precedence. +// Returns any error occurred during configuration merges. +func mergeKibanaConfig(dst *policyv1alpha1.KibanaConfigPolicySpec, src policyv1alpha1.KibanaConfigPolicySpec) error { + var err error + if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { + return err + } + dst.SecureSettings = mergeSecretSources(dst.SecureSettings, src.SecureSettings) + return nil +} + +// mergeElasticsearchConfig merges the source ElasticsearchConfigPolicySpec into the destination. +// For configuration fields (ClusterSettings, SnapshotRepositories, etc.), it performs a deep merge +// where source values override destination values at the field level. +// For SecretMounts, conflicts are detected when the same SecretName or MountPath exists in both +// dst and src. An error is returned to prevent duplicate secret references or mount path collisions. +// For SecureSettings, it merges by SecretName/Key with source values taking precedence. +// Returns any error occurred during configuration merges. +func mergeElasticsearchConfig(dst *policyv1alpha1.ElasticsearchConfigPolicySpec, src policyv1alpha1.ElasticsearchConfigPolicySpec) error { + var err error + if dst.ClusterSettings, err = deepMergeConfig(dst.ClusterSettings, src.ClusterSettings); err != nil { + return err + } + if dst.SnapshotRepositories, err = mergeConfig(dst.SnapshotRepositories, src.SnapshotRepositories); err != nil { + return err + } + if dst.SnapshotLifecyclePolicies, err = deepMergeConfig(dst.SnapshotLifecyclePolicies, src.SnapshotLifecyclePolicies); err != nil { + return err + } + if dst.SecurityRoleMappings, err = deepMergeConfig(dst.SecurityRoleMappings, src.SecurityRoleMappings); err != nil { + return err + } + if dst.IndexLifecyclePolicies, err = deepMergeConfig(dst.IndexLifecyclePolicies, src.IndexLifecyclePolicies); err != nil { + return err + } + if dst.IngestPipelines, err = deepMergeConfig(dst.IngestPipelines, src.IngestPipelines); err != nil { + return err + } + if dst.IndexTemplates.ComposableIndexTemplates, err = deepMergeConfig(dst.IndexTemplates.ComposableIndexTemplates, src.IndexTemplates.ComposableIndexTemplates); err != nil { + return err + } + if dst.IndexTemplates.ComponentTemplates, err = deepMergeConfig(dst.IndexTemplates.ComponentTemplates, src.IndexTemplates.ComponentTemplates); err != nil { + return err + } + if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { + return err + } + + if dst.SecretMounts, err = mergeSecretMounts(dst.SecretMounts, src.SecretMounts); err != nil { + return err + } + dst.SecureSettings = mergeSecretSources(dst.SecureSettings, src.SecureSettings) + return nil +} + +// deepMergeConfig merges the source Config into the destination Config using canonical configuration merging. +// The merge is performed at the field level, with source values overriding destination values. +// If src is nil, dst is returned unchanged. If dst is nil, it is initialized before merging. +// Returns the merged config and any error occurred during config parsing or merging. +func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { + if src == nil { + return dst, nil + } + + var dstCanonicalConfig *settings.CanonicalConfig + var err error + if dst == nil { + dst = &commonv1.Config{} + dstCanonicalConfig = settings.NewCanonicalConfig() + } else { + dstCanonicalConfig, err = settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) + if err != nil { + return nil, err + } + } + + srcCanonicalConfig, err := settings.NewCanonicalConfigFrom(src.DeepCopy().Data) + if err != nil { + return nil, err + } + + err = dstCanonicalConfig.MergeWith(srcCanonicalConfig) + if err != nil { + return nil, err + } + + dst.Data = nil + err = dstCanonicalConfig.Unpack(&dst.Data) + if err != nil { + return nil, err + } + + return dst, nil +} + +// mergeConfig merges the source Config into the destination Config by replacing entire top-level keys. +// Unlike deepMergeConfig which performs recursive merging, this function replaces each top-level key +// in dst with the corresponding value from src. Both configs are first canonicalized to ensure +// consistent structure. If src is nil, dst is returned unchanged. If dst is nil, it is initialized. +// Returns the merged config and any error occurred during config parsing or unpacking. +func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { + if src == nil { + return dst, nil + } + + var dstCanonicalConfig *settings.CanonicalConfig + var err error + if dst == nil { + dst = &commonv1.Config{} + dstCanonicalConfig = settings.NewCanonicalConfig() + } else { + dstCanonicalConfig, err = settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) + if err != nil { + return nil, err + } + } + + srcCanonicalConfig, err := settings.NewCanonicalConfigFrom(src.DeepCopy().Data) + if err != nil { + return nil, err + } + + dst.Data = nil + err = dstCanonicalConfig.Unpack(&dst.Data) + if err != nil { + return nil, err + } + + srcCfg := &commonv1.Config{} + err = srcCanonicalConfig.Unpack(&srcCfg.Data) + if err != nil { + return nil, err + } + + for k, v := range srcCfg.Data { + dst.Data[k] = v + } + + return dst, nil +} + +// mergeSecretMounts merges source SecretMounts into destination SecretMounts. +// SecretMounts are keyed by SecretName and MountPath. Conflicts are detected when: +// - The same SecretName exists in both dst and src (prevents duplicate secret references) +// - The same MountPath exists in both dst and src (prevents mount path collisions) +// Returns a new slice containing the merged SecretMounts sorted by SecretName for +// deterministic output, or an error if conflicts are detected. +func mergeSecretMounts(dst []policyv1alpha1.SecretMount, src []policyv1alpha1.SecretMount) ([]policyv1alpha1.SecretMount, error) { + secretMounts := make(map[string]policyv1alpha1.SecretMount) + + // Add all destination entries + mountPoints := make(map[string]string) + for _, secretMount := range dst { + secretMounts[secretMount.SecretName] = secretMount + mountPoints[secretMount.MountPath] = secretMount.SecretName + } + + // Merge in source entries, checking for conflicts + for _, secretMount := range src { + if _, exists := secretMounts[secretMount.SecretName]; exists { + return nil, fmt.Errorf("%w: secret with name %q is defined in multiple policies", errMergeConflict, secretMount.SecretName) + } + if _, exists := mountPoints[secretMount.MountPath]; exists { + return nil, fmt.Errorf("%w: secret mount path %q is defined in multiple policies", errMergeConflict, secretMount.MountPath) + } + mountPoints[secretMount.MountPath] = secretMount.SecretName + secretMounts[secretMount.SecretName] = secretMount + } + + // Collect secret names and sort them for deterministic output + secretNames := slices.Collect(maps.Keys(secretMounts)) + if len(secretNames) == 0 { + return nil, nil + } + + slices.Sort(secretNames) + + // Build the result in sorted order + mergedSecretMounts := make([]policyv1alpha1.SecretMount, 0, len(secretNames)) + for _, secretName := range secretNames { + mergedSecretMounts = append(mergedSecretMounts, secretMounts[secretName]) + } + return mergedSecretMounts, nil +} + +// mergeSecretSources merges source SecretSources into destination SecretSources. +// SecretSources are merged at two levels: +// 1. First level: keyed by SecretName +// 2. Second level: within each SecretName, entries are keyed by Key +// If the same SecretName and Key exist in both dst and src, the src entry overrides the dst entry. +// Returns a new slice containing the merged SecretSources, sorted by SecretName and +// Key for deterministic output. +func mergeSecretSources(dst []commonv1.SecretSource, src []commonv1.SecretSource) []commonv1.SecretSource { + secureSettings := make(map[string]map[string]commonv1.KeyToPath) + // Add all destination entries + for _, secureSetting := range dst { + secureSettings[secureSetting.SecretName] = make(map[string]commonv1.KeyToPath) + for _, entry := range secureSetting.Entries { + secureSettings[secureSetting.SecretName][entry.Key] = entry + } + } + // Merge in source entries (overriding destination if same SecretName/Key) + for _, secureSetting := range src { + if _, exists := secureSettings[secureSetting.SecretName]; !exists { + secureSettings[secureSetting.SecretName] = make(map[string]commonv1.KeyToPath) + } + for _, entry := range secureSetting.Entries { + secureSettings[secureSetting.SecretName][entry.Key] = entry + } + } + + // Collect and sort secret names for deterministic output + secretNames := slices.Collect(maps.Keys(secureSettings)) + if len(secretNames) == 0 { + return nil + } + + slices.Sort(secretNames) + + // Build the result in sorted order + mergedSecureSettings := make([]commonv1.SecretSource, 0, len(secretNames)) + for _, secretName := range secretNames { + entries := secureSettings[secretName] + + // Collect and sort entry keys for deterministic output + keys := slices.Collect(maps.Keys(entries)) + slices.Sort(keys) + + secretSource := commonv1.SecretSource{ + SecretName: secretName, + } + for _, key := range keys { + secretSource.Entries = append(secretSource.Entries, entries[key]) + } + mergedSecureSettings = append(mergedSecureSettings, secretSource) + } + + return mergedSecureSettings +} From 400b02dc535ae60d38821c3e436546380354e30f Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Fri, 21 Nov 2025 13:30:06 +0200 Subject: [PATCH 02/19] ci: add unit-tests --- .../common/reconciler/secret_test.go | 171 +++ .../stackconfigpolicy/controller_test.go | 276 ++++ .../stackconfigpolicy/ownership_test.go | 552 ++++++++ .../stackconfigpolicy_test.go | 1122 +++++++++++++++++ 4 files changed, 2121 insertions(+) create mode 100644 pkg/controller/stackconfigpolicy/ownership_test.go create mode 100644 pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go diff --git a/pkg/controller/common/reconciler/secret_test.go b/pkg/controller/common/reconciler/secret_test.go index e4e616616eb..b235d35768c 100644 --- a/pkg/controller/common/reconciler/secret_test.go +++ b/pkg/controller/common/reconciler/secret_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,6 +21,7 @@ import ( esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" ) @@ -257,6 +259,15 @@ func ownedSecret(namespace, name, ownerNs, ownerName, ownerKind string) *corev1. }}} } +func ownedSecretMultiRefs(namespace, name, ownerRefs, ownerKind string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Labels: map[string]string{ + SoftOwnerKindLabel: ownerKind, + }, Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: ownerRefs, + }}} +} + func TestGarbageCollectSoftOwnedSecrets(t *testing.T) { tests := []struct { name string @@ -433,6 +444,37 @@ func TestGarbageCollectAllSoftOwnedOrphanSecrets(t *testing.T) { ownedSecret("ns", "secret-1", "another-namespace", sampleOwner().Name, sampleOwner().Kind), }, }, + { + name: "secret with multiple soft-owners that all exist", + runtimeObjs: []client.Object{ + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-1", Namespace: "namespace-1"}}, + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-2", Namespace: "namespace-2"}}, + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-3", Namespace: "namespace-3"}}, + ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + }, + wantObjs: []client.Object{ + ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + }, + }, + { + name: "secret with multiple soft-owners that all exist but some in different namespace", + runtimeObjs: []client.Object{ + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-1", Namespace: "namespace-1"}}, + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-2", Namespace: "namespace-other"}}, + &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-3", Namespace: "namespace-3"}}, + ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + }, + wantObjs: []client.Object{ + ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + }, + }, + { + name: "secret with multiple soft-owners that none exists", + runtimeObjs: []client.Object{ + ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + }, + wantObjs: []client.Object{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -546,3 +588,132 @@ func TestSoftOwnerRefFromLabels(t *testing.T) { }) } } + +//nolint:thelper +func TestSoftOwnerRefs(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + validate func(t *testing.T, owners []SoftOwnerRef, err error) + }{ + { + name: "returns multi-owner policies from annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.NoError(t, err) + require.Len(t, owners, 2) + assert.Contains(t, owners, SoftOwnerRef{Name: "policy-1", Namespace: "namespace-1", Kind: policyv1alpha1.Kind}) + assert.Contains(t, owners, SoftOwnerRef{Name: "policy-2", Namespace: "namespace-2", Kind: policyv1alpha1.Kind}) + }, + }, + { + name: "returns single-owner policy from labels", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + SoftOwnerKindLabel: policyv1alpha1.Kind, + SoftOwnerNameLabel: "single-policy", + SoftOwnerNamespaceLabel: "single-namespace", + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.NoError(t, err) + require.Len(t, owners, 1) + assert.Equal(t, SoftOwnerRef{Name: "single-policy", Namespace: "single-namespace", Kind: policyv1alpha1.Kind}, owners[0]) + }, + }, + { + name: "returns nil when secret has kind label but no owner labels", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + SoftOwnerKindLabel: policyv1alpha1.Kind, + "other-label": "other-value", + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.NoError(t, err) + assert.Nil(t, owners) + }, + }, + { + name: "returns nil for non-policy-owned secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + "some-other-label": "some-value", + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.NoError(t, err) + assert.Nil(t, owners) + }, + }, + { + name: "returns error for invalid JSON in annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.Error(t, err) + assert.Nil(t, owners) + }, + }, + { + name: "skips malformed namespaced names in annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"malformed":{},"too/many/slashes":{}}`, + }, + }, + }, + validate: func(t *testing.T, owners []SoftOwnerRef, err error) { + require.NoError(t, err) + require.Len(t, owners, 1) + assert.Equal(t, SoftOwnerRef{Name: "policy-1", Namespace: "namespace-1", Kind: policyv1alpha1.Kind}, owners[0]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owners, err := SoftOwnerRefs(tt.secret) + tt.validate(t, owners, err) + }) + } +} diff --git a/pkg/controller/stackconfigpolicy/controller_test.go b/pkg/controller/stackconfigpolicy/controller_test.go index f6c13767cc9..423e678bd46 100644 --- a/pkg/controller/stackconfigpolicy/controller_test.go +++ b/pkg/controller/stackconfigpolicy/controller_test.go @@ -29,11 +29,13 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/hash" commonlabels "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" esclient "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/client" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label" + eslabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/net" ) @@ -676,6 +678,280 @@ func TestReconcileStackConfigPolicy_Reconcile(t *testing.T) { } } +//nolint:thelper +func TestReconcileStackConfigPolicy_MultipleStackConfigPolicies(t *testing.T) { + // Setup: Create an Elasticsearch cluster and multiple StackConfigPolicies with different weights + esFixture := esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "test-es", + Labels: map[string]string{"env": "prod"}, + }, + Spec: esv1.ElasticsearchSpec{Version: "8.6.1"}, + } + + // Policy with weight 10 (applied first) + policy1 := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "policy-low", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 10, + ResourceSelector: metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]interface{}{ + "indices.recovery.max_bytes_per_sec": "40mb", + }}, + Config: &commonv1.Config{Data: map[string]interface{}{ + "logger.org.elasticsearch.discovery": "INFO", + }}, + }, + }, + } + + // Policy with weight 20 (applied second, overrides policy1) + policy2 := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "policy-high", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, + ResourceSelector: metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]interface{}{ + "indices.recovery.max_bytes_per_sec": "50mb", // Overrides policy1 + }}, + Config: &commonv1.Config{Data: map[string]interface{}{ + "logger.org.elasticsearch.gateway": "DEBUG", // Additional setting + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + { + SecretName: "test-secret", + MountPath: "/usr/test", + }, + }, + }, + }, + } + + // Policy with same weight as policy2 (should cause conflict) + policy3Conflicting := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "policy-conflict", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, // Same weight as policy2 + ResourceSelector: metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]interface{}{ + "indices.recovery.max_bytes_per_sec": "60mb", + }}, + }, + }, + } + + // Initial empty file settings secret (will be populated by controller) + esFileSettingsSecret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "test-es-es-file-settings", + Labels: map[string]string{ + commonv1.TypeLabelName: "elasticsearch", + eslabel.ClusterNameLabelName: "test-es", + commonlabels.StackConfigPolicyOnDeleteLabelName: commonlabels.OrphanSecretResetOnPolicyDelete, + }, + }, + Data: map[string][]byte{"settings.json": []byte(`{"metadata":{"version":"1","compatibility":"8.4.0"},"state":{"cluster_settings":{},"snapshot_repositories":{},"slm":{},"role_mappings":{},"autoscaling":{},"ilm":{},"ingest_pipelines":{},"index_templates":{"component_templates":{},"composable_index_templates":{}}}}`)}, + } + + esPod := getEsPod("ns", map[string]string{}) + + // Source secret that will be mounted (exists in policy namespace) + sourceSecret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "ns", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + } + + tests := []struct { + name string + policies []policyv1alpha1.StackConfigPolicy + reconcilePolicy string // Which policy to reconcile + wantResources int + wantReady int + wantPhase policyv1alpha1.PolicyPhase + wantErr bool + wantRequeueAfter bool + validateSettings func(t *testing.T, r ReconcileStackConfigPolicy) + }{ + { + name: "Multiple policies with different weights merge successfully", + policies: []policyv1alpha1.StackConfigPolicy{policy1, policy2}, + reconcilePolicy: "policy-low", + wantResources: 1, + wantReady: 0, + wantPhase: policyv1alpha1.ApplyingChangesPhase, + wantErr: false, + wantRequeueAfter: true, + validateSettings: func(t *testing.T, r ReconcileStackConfigPolicy) { + // Verify the file settings secret was updated + esNsn := k8s.ExtractNamespacedName(&esFixture) + + settingsSecretNsn := types.NamespacedName{ + Namespace: esNsn.Namespace, + Name: esv1.FileSettingsSecretName(esNsn.Name), + } + + var secret corev1.Secret + err := r.Client.Get(context.Background(), settingsSecretNsn, &secret) + require.NoError(t, err) + + // Verify the file settings secret has merged config + settings := r.getSettings(t, settingsSecretNsn) + + // Check if ClusterSettings was populated + if settings.State.ClusterSettings == nil { + t.Logf("Secret data: %s", string(secret.Data["settings.json"])) + t.Fatal("ClusterSettings should not be nil after reconciliation") + } + require.NotNil(t, settings.State.ClusterSettings.Data, "ClusterSettings.Data should not be nil") + + // Should have the value from policy2 (higher weight) + assert.EqualValues(t, map[string]any{ + "indices": map[string]any{ + "recovery": map[string]any{ + "max_bytes_per_sec": "40mb", + }, + }, + }, settings.State.ClusterSettings.Data, "ClusterSettings.Data") + + owners, err := reconciler.SoftOwnerRefs(&secret) + assert.NoError(t, err) + assert.Len(t, owners, 2, "esConfigSecret should be owned by 2 policies") + // Verify both policies are in the owner list + assert.Contains(t, owners, reconciler.SoftOwnerRef{Namespace: "ns", Name: "policy-low", Kind: policyv1alpha1.Kind}, "policy-low should be an owner of esConfigSecret") + assert.Contains(t, owners, reconciler.SoftOwnerRef{Namespace: "ns", Name: "policy-high", Kind: policyv1alpha1.Kind}, "policy-high should be an owner of esConfigSecret") + }, + }, + { + name: "Policies with same weight cause conflict", + policies: []policyv1alpha1.StackConfigPolicy{policy2, policy3Conflicting}, + reconcilePolicy: "policy-high", + wantResources: 1, + wantReady: 0, + wantPhase: policyv1alpha1.ConflictPhase, + wantErr: false, + wantRequeueAfter: true, + validateSettings: func(t *testing.T, r ReconcileStackConfigPolicy) { + // Verify policy status shows conflict + policy := r.getPolicy(t, types.NamespacedName{Namespace: "ns", Name: "policy-high"}) + + esStatus := policy.Status.Details["elasticsearch"]["ns/test-es"] + assert.Equal(t, policyv1alpha1.ConflictPhase, esStatus.Phase) + }, + }, + { + name: "Reconciling second policy sees merged state", + policies: []policyv1alpha1.StackConfigPolicy{policy1, policy2}, + reconcilePolicy: "policy-high", + wantResources: 1, + wantReady: 0, + wantPhase: policyv1alpha1.ApplyingChangesPhase, + wantErr: false, + wantRequeueAfter: true, + validateSettings: func(t *testing.T, r ReconcileStackConfigPolicy) { + // Verify elasticsearch config secret exists with merged config + var esConfigSecret corev1.Secret + err := r.Client.Get(context.Background(), types.NamespacedName{ + Namespace: "ns", + Name: esv1.StackConfigElasticsearchConfigSecretName("test-es"), + }, &esConfigSecret) + assert.NoError(t, err) + + // Verify elasticsearch config secret is soft-owned by multiple policies + owners, err := reconciler.SoftOwnerRefs(&esConfigSecret) + assert.NoError(t, err) + assert.Len(t, owners, 2, "esConfigSecret should be owned by 2 policies") + // Verify both policies are in the owner list + assert.Contains(t, owners, reconciler.SoftOwnerRef{Namespace: "ns", Name: "policy-low", Kind: policyv1alpha1.Kind}, "policy-low should be an owner of esConfigSecret") + assert.Contains(t, owners, reconciler.SoftOwnerRef{Namespace: "ns", Name: "policy-high", Kind: policyv1alpha1.Kind}, "policy-high should be an owner of esConfigSecret") + + // Verify secret mount secret exists + var secretMountSecret corev1.Secret + err = r.Client.Get(context.Background(), types.NamespacedName{ + Namespace: "ns", + Name: esv1.StackConfigAdditionalSecretName("test-es", "test-secret"), + }, &secretMountSecret) + assert.NoError(t, err) + + // Verify secret mount secret is soft-owned by SINGLE policy (the one that defines SecretMounts) + // Only policy-high has SecretMounts, so it should be the only owner + owners, err = reconciler.SoftOwnerRefs(&secretMountSecret) + assert.NoError(t, err) + assert.Len(t, owners, 1, "secretMountSecret should be owned by 1 policy") + // Verify only policy-high is the owner + assert.Contains(t, owners, reconciler.SoftOwnerRef{Namespace: "ns", Name: "policy-high", Kind: policyv1alpha1.Kind}, "policy-high should be an owner of secretMountSecret") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create client with all resources + clientObjects := []client.Object{&esFixture, &esFileSettingsSecret, esPod, &sourceSecret} + for i := range tt.policies { + clientObjects = append(clientObjects, &tt.policies[i]) + } + + fakeRecorder := record.NewFakeRecorder(100) + reconciler := ReconcileStackConfigPolicy{ + Client: k8s.NewFakeClient(clientObjects...), + esClientProvider: fakeClientProvider(esclient.FileSettings{Version: 1}, nil), + recorder: fakeRecorder, + licenseChecker: &license.MockLicenseChecker{EnterpriseEnabled: true}, + params: operator.Parameters{ + OperatorNamespace: "elastic-system", + }, + dynamicWatches: watches.NewDynamicWatches(), + } + + // Reconcile the specified policy + got, err := reconciler.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "ns", + Name: tt.reconcilePolicy, + }, + }) + + if (err != nil) != tt.wantErr { + t.Errorf("Reconcile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if (got.RequeueAfter > 0) != tt.wantRequeueAfter { + t.Errorf("Reconcile() got = %v, wantRequeueAfter %v", got, tt.wantRequeueAfter) + } + + // Verify policy status + policy := reconciler.getPolicy(t, types.NamespacedName{Namespace: "ns", Name: tt.reconcilePolicy}) + assert.Equal(t, tt.wantResources, policy.Status.Resources) + assert.Equal(t, tt.wantReady, policy.Status.Ready) + assert.Equal(t, tt.wantPhase, policy.Status.Phase) + + // Run custom validation if provided + if tt.validateSettings != nil { + tt.validateSettings(t, reconciler) + } + }) + } +} + func Test_cleanStackTrace(t *testing.T) { stacktrace := "Error processing slm state change: java.lang.IllegalArgumentException: Error on validating SLM requests\n\tat org.elasticsearch.ilm@8.6.1/org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.prepare(ReservedSnapshotAction.java:66)\n\tat org.elasticsearch.ilm@8.6.1/org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.transform(ReservedSnapshotAction.java:77)\n\tat org.elasticsearch.server@8.6.1/org.elasticsearch.reservedstate.service.ReservedClusterStateService.trialRun(ReservedClusterStateService.java:328)\n\tat org.elasticsearch.server@8.6.1/org.elasticsearch.reservedstate.service.ReservedClusterStateService.process(ReservedClusterStateService.java:169)\n\tat org.elasticsearch.server@8.6.1/org.elasticsearch.reservedstate.service.ReservedClusterStateService.process(ReservedClusterStateService.java:122)\n\tat org.elasticsearch.server@8.6.1/org.elasticsearch.reservedstate.service.FileSettingsService.processFileSettings(FileSettingsService.java:389)\n\tat org.elasticsearch.server@8.6.1/org.elasticsearch.reservedstate.service.FileSettingsService.lambda$startWatcher$3(FileSettingsService.java:312)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\n\tSuppressed: java.lang.IllegalArgumentException: no such repository [badrepo]\n\t\tat org.elasticsearch.ilm@8.6.1/org.elasticsearch.xpack.slm.SnapshotLifecycleService.validateRepositoryExists(SnapshotLifecycleService.java:244)\n\t\tat org.elasticsearch.ilm@8.6.1/org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.prepare(ReservedSnapshotAction.java:57)\n\t\t... 7 more\n" err := cleanStackTrace([]string{stacktrace}) diff --git a/pkg/controller/stackconfigpolicy/ownership_test.go b/pkg/controller/stackconfigpolicy/ownership_test.go new file mode 100644 index 00000000000..ab9d194f9f1 --- /dev/null +++ b/pkg/controller/stackconfigpolicy/ownership_test.go @@ -0,0 +1,552 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package stackconfigpolicy + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" +) + +//nolint:thelper +func Test_setSingleSoftOwner(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + policy policyv1alpha1.StackConfigPolicy + validate func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) + }{ + { + name: "overwrites existing soft owner labels", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: "old-kind", + reconciler.SoftOwnerNameLabel: "old-policy", + reconciler.SoftOwnerNamespaceLabel: "old-namespace", + "existing-label": "existing-value", + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: "{}", + "existing-annotation": "existing-value", + }, + }, + }, + policy: policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-policy", + Namespace: "new-namespace", + }, + }, + validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { + assert.NotNil(t, secret.Labels) + assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) + assert.Equal(t, "new-policy", secret.Labels[reconciler.SoftOwnerNameLabel]) + assert.Equal(t, "new-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) + assert.Equal(t, "existing-value", secret.Labels["existing-label"]) + assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) + assert.NotContains(t, secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + }, + }, + { + name: "returns nil for nil secret", + secret: nil, + validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { + assert.Nil(t, secret) + }, + }, + { + name: "secret with nil labels and annotations", + secret: &corev1.Secret{}, + validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { + assert.NotNil(t, secret) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setSingleSoftOwner(tt.secret, tt.policy) + tt.validate(t, tt.secret, tt.policy) + }) + } +} + +//nolint:thelper +func Test_setMultipleSoftOwners(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + policies []policyv1alpha1.StackConfigPolicy + validate func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) + }{ + { + name: "removes single-owner labels and sets multi-owner annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "old-single-policy", + reconciler.SoftOwnerNamespaceLabel: "old-namespace", + "existing-label": "existing-value", + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: "replaced-value", + "existing-annotation": "existing-value", + }, + }, + }, + policies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-1", + Namespace: "namespace-1", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-2", + Namespace: "namespace-2", + }, + }, + { + // should be deduplicated + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-2", + Namespace: "namespace-2", + }, + }, + }, + validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { + require.NoError(t, err) + + // Verify single-owner labels are removed + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) + + // Verify kind label is still set + assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) + + // Verify existing label is preserved + assert.Equal(t, "existing-value", secret.Labels["existing-label"]) + + // Verify existing annotation is preserved + assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) + + // Verify multi-owner annotation is set with both policies + ownerRefsJSON := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] + assert.NotEmpty(t, ownerRefsJSON) + + var ownerRefs map[string]struct{} + err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) + require.NoError(t, err) + assert.EqualValues(t, map[string]struct{}{ + types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}.String(): {}, + types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}.String(): {}, + }, ownerRefs) + }, + }, + { + name: "returns nil for nil secret", + secret: nil, + validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { + assert.Nil(t, err) + assert.Nil(t, secret) + assert.Len(t, policies, 0) + }, + }, + { + name: "secret with nil labels and annotations", + secret: &corev1.Secret{}, + validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { + assert.Nil(t, err) + assert.NotNil(t, secret) + assert.Len(t, policies, 0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := setMultipleSoftOwners(tt.secret, tt.policies) + tt.validate(t, tt.secret, tt.policies, err) + }) + } +} + +//nolint:thelper +func Test_removePolicySoftOwner(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + policyToRemove types.NamespacedName + validate func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) + }{ + { + name: "removes policy from multi-owner with remaining owners", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 2, remainingCount) + + // Verify the annotation still exists with remaining owners + ownerRefsJSON := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] + assert.NotEmpty(t, ownerRefsJSON) + + var ownerRefs map[string]struct{} + err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) + require.NoError(t, err) + assert.Len(t, ownerRefs, 2) + + // Verify policy-2 was removed + assert.EqualValues(t, map[string]struct{}{ + "namespace-1/policy-1": {}, + "namespace-3/policy-3": {}, + }, ownerRefs) + }, + }, + { + name: "removes last policy from multi-owner and cleans up annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{}}`, + "other-annotation": "preserved", + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 0, remainingCount) + + // Verify the annotation was removed + assert.NotContains(t, secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + + // Verify other annotations are preserved + assert.Equal(t, "preserved", secret.Annotations["other-annotation"]) + }, + }, + { + name: "removes matching single-owner and cleans up labels", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "single-policy", + reconciler.SoftOwnerNamespaceLabel: "single-namespace", + "other-label": "preserved", + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "single-policy", Namespace: "single-namespace"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 0, remainingCount) + + // Verify all soft owner labels were removed + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) + + // Verify other labels are preserved + assert.Equal(t, "preserved", secret.Labels["other-label"]) + }, + }, + { + name: "returns 1 when policy doesn't match single-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "existing-policy", + reconciler.SoftOwnerNamespaceLabel: "existing-namespace", + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "different-policy", Namespace: "different-namespace"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 1, remainingCount) + + // Verify labels remain unchanged + assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) + assert.Equal(t, "existing-policy", secret.Labels[reconciler.SoftOwnerNameLabel]) + assert.Equal(t, "existing-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) + }, + }, + { + name: "returns 0 for nil secret", + secret: nil, + policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 0, remainingCount) + }, + }, + { + name: "returns 0 for non-owned secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + "some-label": "some-value", + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 0, remainingCount) + }, + }, + { + name: "returns error for invalid JSON in annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.Error(t, err) + assert.Equal(t, 0, remainingCount) + }, + }, + { + name: "returns 0 when removing non-existent policy from multi-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + }, + }, + }, + policyToRemove: types.NamespacedName{Name: "non-existent", Namespace: "namespace-3"}, + validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { + require.NoError(t, err) + assert.Equal(t, 2, remainingCount) + + // Verify both original policies remain + var ownerRefs map[string]struct{} + err = json.Unmarshal([]byte(secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]), &ownerRefs) + require.NoError(t, err) + assert.Len(t, ownerRefs, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remainingCount, err := removePolicySoftOwner(tt.secret, tt.policyToRemove) + tt.validate(t, tt.secret, remainingCount, err) + }) + } +} + +//nolint:thelper +func Test_isPolicySoftOwner(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + policyNsn types.NamespacedName + validate func(t *testing.T, isOwner bool, err error) + }{ + { + name: "returns true when policy is owner in multi-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + }, + }, + }, + policyNsn: types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.True(t, isOwner) + }, + }, + { + name: "returns false when policy is not owner in multi-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + }, + }, + }, + policyNsn: types.NamespacedName{Name: "policy-3", Namespace: "namespace-3"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.False(t, isOwner) + }, + }, + { + name: "returns true when policy matches single-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "single-policy", + reconciler.SoftOwnerNamespaceLabel: "single-namespace", + }, + }, + }, + policyNsn: types.NamespacedName{Name: "single-policy", Namespace: "single-namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.True(t, isOwner) + }, + }, + { + name: "returns false when policy doesn't match single-owner", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "single-policy", + reconciler.SoftOwnerNamespaceLabel: "single-namespace", + }, + }, + }, + policyNsn: types.NamespacedName{Name: "different-policy", Namespace: "different-namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.False(t, isOwner) + }, + }, + { + name: "returns false for nil secret", + secret: nil, + policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.False(t, isOwner) + }, + }, + { + name: "returns false for non-policy-owned secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + "some-other-label": "some-value", + }, + }, + }, + policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.False(t, isOwner) + }, + }, + { + name: "returns error for invalid JSON in annotation", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + Annotations: map[string]string{ + commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + }, + }, + }, + policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.Error(t, err) + assert.False(t, isOwner) + }, + }, + { + name: "returns false when secret has kind label but no owner references", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + }, + }, + }, + policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, + validate: func(t *testing.T, isOwner bool, err error) { + require.NoError(t, err) + assert.False(t, isOwner) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isOwner, err := isPolicySoftOwner(tt.secret, tt.policyNsn) + tt.validate(t, isOwner, err) + }) + } +} diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go new file mode 100644 index 00000000000..68d60d4f554 --- /dev/null +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go @@ -0,0 +1,1122 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package stackconfigpolicy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" + esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" + kbv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" + policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" +) + +func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { + for _, tc := range []struct { + name string + policyNamespace string + operatorNamespace string + targetElasticsearch *esv1.Elasticsearch + stackConfigPolicies []policyv1alpha1.StackConfigPolicy + expectedConfigPolicy policyv1alpha1.StackConfigPolicy + expectedPolicyRefs map[string]struct{} + expectedConflictErrors map[types.NamespacedName]bool // map of policy names that should have conflict errors + }{ + { + name: "merges without overwrites", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test1.name": "policy1", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/test-policy1"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy1", MountPath: "/secret-policy1"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-backups.type": "fs", + "policy-backups": map[string]any{ + "settings.location": "/backups", + }, + }}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: -1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test2.name": "policy2", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy2", MountPath: "/secret-policy2"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-2-backups.type": "s3", + "policy-2-backups.settings": map[string]any{ + "bucket": "policy-2-backups", + "region": "us-west-2", + }, + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test1.name": "policy1", + "test2.name": "policy2", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/test-policy1"}, + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy1", MountPath: "/secret-policy1"}, + {SecretName: "secret-policy2", MountPath: "/secret-policy2"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-2-backups.type": "s3", + "policy-2-backups.settings": map[string]any{ + "bucket": "policy-2-backups", + "region": "us-west-2", + }, + "policy-backups.type": "fs", + "policy-backups": map[string]any{ + "settings.location": "/backups", + }, + }}, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/policy1": {}, + "test/policy2": {}, + }, + }, { + name: "merges with overwrites", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test.name": "policy1", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy1"}, + {Key: "test2", Path: "/test2-policy1"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy-1", MountPath: "/secret-policy1"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-2-backups.type": "fs", + "policy-2-backups.settings": map[string]any{ + "location": "/tmp/location", + }, + }}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: -1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test.name": "policy2", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy-2", MountPath: "/secret-policy2"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-2-backups.type": "s3", + "policy-2-backups.settings": map[string]any{ + "bucket": "policy-2-backups", + "region": "us-west-2", + }, + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "test.name": "policy2", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret-policy-1", MountPath: "/secret-policy1"}, + {SecretName: "secret-policy-2", MountPath: "/secret-policy2"}, + }, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy-2-backups.type": "s3", + "policy-2-backups.settings": map[string]any{ + "bucket": "policy-2-backups", + "region": "us-west-2", + }, + }}, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/policy1": {}, + "test/policy2": {}, + }, + }, + { + name: "detects policies weight conflicts", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy with unique weight - should be merged + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy1", + }}, + }, + }, + }, + // Two policies with the same weight - should conflict and be skipped + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2-conflict", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 5, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "conflict": "policy2", + }}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy3-conflict", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 5, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "conflict": "policy3", + }}, + }, + }, + }, + // Another policy with unique weight - should be merged + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy4", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 10, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy4", + }}, + }, + }, + }, + }, + expectedConflictErrors: map[types.NamespacedName]bool{ + {Namespace: "test", Name: "policy2-conflict"}: true, + {Namespace: "test", Name: "policy3-conflict"}: true, + }, + }, + { + name: "detects conflicts when same secret defined in multiple policies", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy 1 with lower weight - should be merged first + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy1", + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "shared-secret", MountPath: "/mnt/policy1"}, + }, + }, + }, + }, + // Policy 2 with higher weight - attempts to define the same secret, should conflict + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 5, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy2", + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "shared-secret", MountPath: "/mnt/policy2"}, + }, + }, + }, + }, + }, + expectedConflictErrors: map[types.NamespacedName]bool{ + {Namespace: "test", Name: "policy2"}: true, + {Namespace: "test", Name: "policy1"}: true, + }, + }, + { + name: "detects conflicts when same mount path defined in multiple policies", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy 1 with lower weight - should be merged first + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy1", + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret1", MountPath: "/mnt/shared"}, + }, + }, + }, + }, + // Policy 2 with higher weight - attempts to use the same mount path, should conflict + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 5, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "from": "policy2", + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "secret2", MountPath: "/mnt/shared"}, + }, + }, + }, + }, + }, + expectedConflictErrors: map[types.NamespacedName]bool{ + {Namespace: "test", Name: "policy2"}: true, + {Namespace: "test", Name: "policy1"}: true, + }, + }, + { + name: "successfully merges when different secrets use different mount paths", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "db-creds", MountPath: "/etc/db"}, + {SecretName: "api-keys", MountPath: "/etc/api"}, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy2", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + Weight: 5, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "tls-cert", MountPath: "/etc/tls"}, + {SecretName: "backup-creds", MountPath: "/etc/backup"}, + }, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "api-keys", MountPath: "/etc/api"}, + {SecretName: "backup-creds", MountPath: "/etc/backup"}, + {SecretName: "db-creds", MountPath: "/etc/db"}, + {SecretName: "tls-cert", MountPath: "/etc/tls"}, + }, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/policy1": {}, + "test/policy2": {}, + }, + }, + { + name: "elasticsearch different namespace", + operatorNamespace: "operator-namespace", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-es", + Namespace: "es-namespace", + Labels: map[string]string{ + "env": "production", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy in wrong namespace - should not match + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "wrong-namespace", + Name: "policy-wrong-ns", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "production"}, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "should-not": "be-included", + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{}, + }, + }, + expectedPolicyRefs: map[string]struct{}{}, + }, + { + name: "elasticsearch non-matching labels", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-es", + Namespace: "test", + Labels: map[string]string{ + "env": "production", + "team": "platform", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy with non-matching label selector - should not match + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy-wrong-labels", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "development", // doesn't match ES labels + }, + }, + Weight: 1, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "should-not": "be-included", + }}, + }, + }, + }, + // Policy with partially matching labels - should not match + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "policy-partial-match", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "production", + "team": "platform", + "missing": "label", // this label doesn't exist on ES + }, + }, + Weight: 2, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "also-should-not": "be-included", + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{}, + }, + }, + expectedPolicyRefs: map[string]struct{}{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + esStackConfig, err := getPolicyConfigForElasticsearch(tc.targetElasticsearch, tc.stackConfigPolicies, operator.Parameters{ + OperatorNamespace: tc.operatorNamespace, + }) + // Check for expected conflict errors + if tc.expectedConflictErrors != nil { + assert.ErrorIs(t, err, errMergeConflict, "getPolicyConfigForElasticsearch should return an error") + assert.NotNil(t, esStackConfig.PoliciesWithConflictErrors, "should have conflict errors") + assert.Len(t, esStackConfig.PoliciesWithConflictErrors, len(tc.expectedConflictErrors), "should have expected number of conflict errors") + + for policyNsn, shouldHaveError := range tc.expectedConflictErrors { + if shouldHaveError { + assert.Containsf(t, esStackConfig.PoliciesWithConflictErrors, policyNsn, "Expected conflict error for policy %s but found none", policyNsn) + } else { + assert.NotContainsf(t, esStackConfig.PoliciesWithConflictErrors, policyNsn, "Not expected conflict error for policy %s but found none", policyNsn) + } + } + return + } + assert.NoError(t, err, "getPolicyConfigForElasticsearch should not return an error") + assert.Empty(t, esStackConfig.PoliciesWithConflictErrors, "should not have any conflict errors") + + // we self-merge the expected config just to canonicalise it + expectedConfigPolicyCopy := tc.expectedConfigPolicy.Spec.Elasticsearch.DeepCopy() + expectedConfigPolicyCopy.SecretMounts = nil + err = mergeElasticsearchConfig(&tc.expectedConfigPolicy.Spec.Elasticsearch, *expectedConfigPolicyCopy) + require.NoError(t, err, "canonicalise expected config should not return an error") + + // Compare the merged Elasticsearch configuration + assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Elasticsearch, esStackConfig.Spec) + + // Compare policy references by building a map from the actual refs + actualPolicyRefs := make(map[string]struct{}) + for _, policy := range esStackConfig.PoliciesRefs { + nsn := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} + actualPolicyRefs[nsn.String()] = struct{}{} + } + assert.EqualValues(t, tc.expectedPolicyRefs, actualPolicyRefs) + }) + } +} + +func Test_getPolicyConfigForKibana(t *testing.T) { + for _, tc := range []struct { + name string + policyNamespace string + operatorNamespace string + targetKibana *kbv1.Kibana + stackConfigPolicies []policyv1alpha1.StackConfigPolicy + expectedConfigPolicy policyv1alpha1.StackConfigPolicy + expectedPolicyRefs map[string]struct{} + expectedConflictErrors map[types.NamespacedName]bool + }{ + { + name: "merges Kibana configs without overwrites", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "test", + Labels: map[string]string{ + "app": "kibana", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-policy1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Weight: 10, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + "logging.root.level": "info", + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "kb-secret1", + Entries: []commonv1.KeyToPath{{Key: "key1", Path: "path1"}}, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-policy2", + ResourceVersion: "2", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Weight: 20, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.reporting.enabled": false, + "server.maxPayload": float64(2097152), + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "kb-secret2", + Entries: []commonv1.KeyToPath{{Key: "key2", Path: "path2"}}, + }, + }, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack": map[string]any{ + "canvas": map[string]any{ + "enabled": true, + }, + "reporting": map[string]any{ + "enabled": false, + }, + }, + "logging": map[string]any{ + "root": map[string]any{ + "level": "info", + }, + }, + "server": map[string]any{ + "maxPayload": float64(2097152), + }, + }}, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "kb-secret1", + Entries: []commonv1.KeyToPath{{Key: "key1", Path: "path1"}}, + }, + { + SecretName: "kb-secret2", + Entries: []commonv1.KeyToPath{{Key: "key2", Path: "path2"}}, + }, + }, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/kb-policy1": {}, + "test/kb-policy2": {}, + }, + }, + { + name: "merges Kibana configs with overwrites - higher weight wins", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "test", + Labels: map[string]string{ + "env": "prod", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-low-priority", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + Weight: 10, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "logging.root.level": "info", + "server.port": uint64(5601), + }}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-high-priority", + ResourceVersion: "2", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + Weight: 20, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "logging.root.level": "debug", // Override from policy with weight 10 + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "logging": map[string]any{ + "root": map[string]any{ + "level": "info", + }, + }, + "server": map[string]any{ + "port": uint64(5601), + }, + }}, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/kb-low-priority": {}, + "test/kb-high-priority": {}, + }, + }, + { + name: "Kibana policies with same weight cause conflict", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "test", + Labels: map[string]string{ + "env": "staging", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-conflict-1", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "staging"}, + }, + Weight: 15, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + }}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-conflict-2", + ResourceVersion: "2", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "staging"}, + }, + Weight: 15, // Same weight as kb-conflict-1 + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.reporting.enabled": false, + }}, + }, + }, + }, + }, + expectedConflictErrors: map[types.NamespacedName]bool{ + {Namespace: "test", Name: "kb-conflict-1"}: true, + {Namespace: "test", Name: "kb-conflict-2"}: true, + }, + }, + { + name: "Kibana policy doesn't match due to namespace", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "prod", + Labels: map[string]string{ + "app": "kibana", + }, + }, + }, + policyNamespace: "dev", + operatorNamespace: "elastic-system", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "dev", + Name: "kb-policy-wrong-ns", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Weight: 10, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Kibana: policyv1alpha1.KibanaConfigPolicySpec{}, + }, + }, + expectedPolicyRefs: map[string]struct{}{}, + }, + { + name: "Kibana policy doesn't match due to labels", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "test", + Labels: map[string]string{ + "app": "kibana", + "env": "prod", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-policy-wrong-labels", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "kibana", + "env": "dev", // Different value + }, + }, + Weight: 10, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Kibana: policyv1alpha1.KibanaConfigPolicySpec{}, + }, + }, + expectedPolicyRefs: map[string]struct{}{}, + }, + { + name: "Single Kibana policy - no merging optimization", + targetKibana: &kbv1.Kibana{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kb", + Namespace: "test", + Labels: map[string]string{ + "app": "kibana", + }, + }, + }, + policyNamespace: "test", + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test", + Name: "kb-single-policy", + ResourceVersion: "1", + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Weight: 10, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + "logging.root.level": "info", + }}, + }, + }, + }, + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{Data: map[string]any{ + "xpack.canvas.enabled": true, + "logging.root.level": "info", + }}, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/kb-single-policy": {}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + kbPolicyConfig, err := getPolicyConfigForKibana(tc.targetKibana, tc.stackConfigPolicies, operator.Parameters{ + OperatorNamespace: tc.operatorNamespace, + }) + // Verify conflict errors + if tc.expectedConflictErrors != nil { + assert.ErrorIs(t, err, errMergeConflict) + assert.NotNil(t, kbPolicyConfig.PoliciesWithConflictErrors, "Expected conflict errors but got none") + for policyNsn, shouldHaveError := range tc.expectedConflictErrors { + if shouldHaveError { + assert.Containsf(t, kbPolicyConfig.PoliciesWithConflictErrors, policyNsn, "Expected conflict error for policy %s but found none", policyNsn) + } else { + assert.NotContainsf(t, kbPolicyConfig.PoliciesWithConflictErrors, policyNsn, "Not expected conflict error for policy %s but found none", policyNsn) + } + } + return + } + assert.NoError(t, err) + assert.Empty(t, kbPolicyConfig.PoliciesWithConflictErrors, "Expected no conflict errors") + + // Compare the merged Kibana configuration + assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Kibana, kbPolicyConfig.Spec) + + // Compare policy references + actualPolicyRefs := make(map[string]struct{}) + for _, policy := range kbPolicyConfig.PoliciesRefs { + nsn := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} + actualPolicyRefs[nsn.String()] = struct{}{} + } + assert.EqualValues(t, tc.expectedPolicyRefs, actualPolicyRefs) + }) + } +} From dd18a2d5c5167fc0369c9b1915a26d147cc88874 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Tue, 25 Nov 2025 17:15:48 +0200 Subject: [PATCH 03/19] fix: move SoftOwnerRefsAnnotation from commonannotations to reconciler package --- pkg/controller/common/annotation/constants.go | 2 -- pkg/controller/common/reconciler/secret.go | 4 +-- .../common/reconciler/secret_test.go | 9 +++-- .../elasticsearch/filesettings/reconciler.go | 2 +- pkg/controller/stackconfigpolicy/ownership.go | 19 +++++----- .../stackconfigpolicy/ownership_test.go | 35 +++++++++---------- 6 files changed, 33 insertions(+), 38 deletions(-) diff --git a/pkg/controller/common/annotation/constants.go b/pkg/controller/common/annotation/constants.go index 34a5a68ebf9..41d5a890f80 100644 --- a/pkg/controller/common/annotation/constants.go +++ b/pkg/controller/common/annotation/constants.go @@ -25,6 +25,4 @@ const ( ElasticsearchConfigAndSecretMountsHashAnnotation = "policy.k8s.elastic.co/elasticsearch-config-mounts-hash" //nolint:gosec SourceSecretAnnotationName = "policy.k8s.elastic.co/source-secret-name" //nolint:gosec - - SoftOwnerRefsAnnotation = "eck.k8s.elastic.co/owner-refs" ) diff --git a/pkg/controller/common/reconciler/secret.go b/pkg/controller/common/reconciler/secret.go index 0a07d2d5418..979dd8f3e0c 100644 --- a/pkg/controller/common/reconciler/secret.go +++ b/pkg/controller/common/reconciler/secret.go @@ -19,7 +19,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" - commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" @@ -31,6 +30,7 @@ const ( SoftOwnerNamespaceLabel = "eck.k8s.elastic.co/owner-namespace" SoftOwnerNameLabel = "eck.k8s.elastic.co/owner-name" SoftOwnerKindLabel = "eck.k8s.elastic.co/owner-kind" + SoftOwnerRefsAnnotation = "eck.k8s.elastic.co/owner-refs" ) func WithPostUpdate(f func()) func(p *Params) { @@ -105,7 +105,7 @@ func SoftOwnerRefs(obj metav1.Object) ([]SoftOwnerRef, error) { } // Check for multi-policy ownership (annotation-based) - if ownerRefsBytes, exists := obj.GetAnnotations()[commonannotation.SoftOwnerRefsAnnotation]; exists { + if ownerRefsBytes, exists := obj.GetAnnotations()[SoftOwnerRefsAnnotation]; exists { // Multi-policy soft owned secret - parse the JSON map of owners var ownerRefs map[string]struct{} if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { diff --git a/pkg/controller/common/reconciler/secret_test.go b/pkg/controller/common/reconciler/secret_test.go index b235d35768c..a4c4ad7effb 100644 --- a/pkg/controller/common/reconciler/secret_test.go +++ b/pkg/controller/common/reconciler/secret_test.go @@ -21,7 +21,6 @@ import ( esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" - commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" ) @@ -264,7 +263,7 @@ func ownedSecretMultiRefs(namespace, name, ownerRefs, ownerKind string) *corev1. ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Labels: map[string]string{ SoftOwnerKindLabel: ownerKind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: ownerRefs, + SoftOwnerRefsAnnotation: ownerRefs, }}} } @@ -606,7 +605,7 @@ func TestSoftOwnerRefs(t *testing.T) { SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, }, }, }, @@ -679,7 +678,7 @@ func TestSoftOwnerRefs(t *testing.T) { SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + SoftOwnerRefsAnnotation: `invalid-json`, }, }, }, @@ -698,7 +697,7 @@ func TestSoftOwnerRefs(t *testing.T) { SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"malformed":{},"too/many/slashes":{}}`, + SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"malformed":{},"too/many/slashes":{}}`, }, }, }, diff --git a/pkg/controller/elasticsearch/filesettings/reconciler.go b/pkg/controller/elasticsearch/filesettings/reconciler.go index 35b0aedc03e..f3722bf2a7e 100644 --- a/pkg/controller/elasticsearch/filesettings/reconciler.go +++ b/pkg/controller/elasticsearch/filesettings/reconciler.go @@ -29,7 +29,7 @@ var ( // managedAnnotations are the annotations managed by the operator for the stack config policy related secrets, which means that the operator // will always take precedence to update or remove these annotations. - managedAnnotations = []string{commonannotation.SecureSettingsSecretsAnnotationName, commonannotation.SettingsHashAnnotationName, commonannotation.ElasticsearchConfigAndSecretMountsHashAnnotation, commonannotation.KibanaConfigHashAnnotation, commonannotation.SoftOwnerRefsAnnotation} + managedAnnotations = []string{commonannotation.SecureSettingsSecretsAnnotationName, commonannotation.SettingsHashAnnotationName, commonannotation.ElasticsearchConfigAndSecretMountsHashAnnotation, commonannotation.KibanaConfigHashAnnotation, reconciler.SoftOwnerRefsAnnotation} ) // ReconcileEmptyFileSettingsSecret reconciles an empty File settings Secret for the given Elasticsearch only when there is no Secret. diff --git a/pkg/controller/stackconfigpolicy/ownership.go b/pkg/controller/stackconfigpolicy/ownership.go index c4c08fa9461..169e7aff6af 100644 --- a/pkg/controller/stackconfigpolicy/ownership.go +++ b/pkg/controller/stackconfigpolicy/ownership.go @@ -11,7 +11,6 @@ import ( "k8s.io/apimachinery/pkg/types" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" - commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" @@ -28,7 +27,7 @@ func setSingleSoftOwner(secret *corev1.Secret, policy policyv1alpha1.StackConfig if secret.Annotations != nil { // Remove multi-owner annotation if it exists - delete(secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + delete(secret.Annotations, reconciler.SoftOwnerRefsAnnotation) } filesettings.SetSoftOwner(secret, policy) @@ -40,7 +39,7 @@ func setSingleSoftOwner(secret *corev1.Secret, policy policyv1alpha1.StackConfig // // The function sets: // - The label reconciler.SoftOwnerKindLabel indicating the soft owner kind to policyv1alpha1.Kind -// - The annotation commonannotation.SoftOwnerRefsAnnotation containing a JSON map of all owner namespaced names +// - The annotation reconciler.SoftOwnerRefsAnnotation containing a JSON map of all owner namespaced names // // Returns an error if JSON marshaling fails. func setMultipleSoftOwners(secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy) error { @@ -76,13 +75,13 @@ func setMultipleSoftOwners(secret *corev1.Secret, policies []policyv1alpha1.Stac return err } - secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + secret.Annotations[reconciler.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) return nil } // isPolicySoftOwner checks if the given StackConfigPolicy is a soft owner of the Secret. // It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) -// and multi-owner (commonannotation.SoftOwnerRefsAnnotation) scenarios. +// and multi-owner (reconciler.SoftOwnerRefsAnnotation) scenarios. // Returns true or false depending on whether the policy is an owner of the secret // and an error if there's a problem unmarshalling the owner references func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (bool, error) { @@ -97,7 +96,7 @@ func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (b } // Check for multi-policy ownership (annotation-based) - if ownerRefsBytes, exists := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]; exists { + if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { // Multi-policy soft owned secret - parse the JSON map of owners var ownerRefs map[string]struct{} if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { @@ -121,7 +120,7 @@ func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (b // removePolicySoftOwner removes a StackConfigPolicy if it is soft owning the given secret. // It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) -// and multi-owner (commonannotation.SoftOwnerRefsAnnotation) scenarios. +// and multi-owner (reconciler.SoftOwnerRefsAnnotation) scenarios. // // For single-owner secrets: // - If the owner matches, removes all soft owner labels/annotations @@ -138,7 +137,7 @@ func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName } // Check for multi-policy ownership (annotation-based) - if ownerRefsBytes, exists := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]; exists { + if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { // Multi-policy soft owned secret - parse and update the owner map var ownerRefs map[string]struct{} if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { @@ -149,7 +148,7 @@ func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName delete(ownerRefs, types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()) if len(ownerRefs) == 0 { // No owners remain, remove the annotation - delete(secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + delete(secret.Annotations, reconciler.SoftOwnerRefsAnnotation) return 0, nil } @@ -160,7 +159,7 @@ func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName } // Update the annotation with the new owner list - secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + secret.Annotations[reconciler.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) return len(ownerRefs), nil } diff --git a/pkg/controller/stackconfigpolicy/ownership_test.go b/pkg/controller/stackconfigpolicy/ownership_test.go index ab9d194f9f1..28e9c7d4f6b 100644 --- a/pkg/controller/stackconfigpolicy/ownership_test.go +++ b/pkg/controller/stackconfigpolicy/ownership_test.go @@ -15,7 +15,6 @@ import ( "k8s.io/apimachinery/pkg/types" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" - commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" ) @@ -40,8 +39,8 @@ func Test_setSingleSoftOwner(t *testing.T) { "existing-label": "existing-value", }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: "{}", - "existing-annotation": "existing-value", + reconciler.SoftOwnerRefsAnnotation: "{}", + "existing-annotation": "existing-value", }, }, }, @@ -58,7 +57,7 @@ func Test_setSingleSoftOwner(t *testing.T) { assert.Equal(t, "new-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) assert.Equal(t, "existing-value", secret.Labels["existing-label"]) assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) - assert.NotContains(t, secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + assert.NotContains(t, secret.Annotations, reconciler.SoftOwnerRefsAnnotation) }, }, { @@ -106,8 +105,8 @@ func Test_setMultipleSoftOwners(t *testing.T) { "existing-label": "existing-value", }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: "replaced-value", - "existing-annotation": "existing-value", + reconciler.SoftOwnerRefsAnnotation: "replaced-value", + "existing-annotation": "existing-value", }, }, }, @@ -149,7 +148,7 @@ func Test_setMultipleSoftOwners(t *testing.T) { assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) // Verify multi-owner annotation is set with both policies - ownerRefsJSON := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] + ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] assert.NotEmpty(t, ownerRefsJSON) var ownerRefs map[string]struct{} @@ -207,7 +206,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, + reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, }, }, }, @@ -217,7 +216,7 @@ func Test_removePolicySoftOwner(t *testing.T) { assert.Equal(t, 2, remainingCount) // Verify the annotation still exists with remaining owners - ownerRefsJSON := secret.Annotations[commonannotation.SoftOwnerRefsAnnotation] + ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] assert.NotEmpty(t, ownerRefsJSON) var ownerRefs map[string]struct{} @@ -242,8 +241,8 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{}}`, - "other-annotation": "preserved", + reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{}}`, + "other-annotation": "preserved", }, }, }, @@ -253,7 +252,7 @@ func Test_removePolicySoftOwner(t *testing.T) { assert.Equal(t, 0, remainingCount) // Verify the annotation was removed - assert.NotContains(t, secret.Annotations, commonannotation.SoftOwnerRefsAnnotation) + assert.NotContains(t, secret.Annotations, reconciler.SoftOwnerRefsAnnotation) // Verify other annotations are preserved assert.Equal(t, "preserved", secret.Annotations["other-annotation"]) @@ -346,7 +345,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + reconciler.SoftOwnerRefsAnnotation: `invalid-json`, }, }, }, @@ -366,7 +365,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, }, }, }, @@ -377,7 +376,7 @@ func Test_removePolicySoftOwner(t *testing.T) { // Verify both original policies remain var ownerRefs map[string]struct{} - err = json.Unmarshal([]byte(secret.Annotations[commonannotation.SoftOwnerRefsAnnotation]), &ownerRefs) + err = json.Unmarshal([]byte(secret.Annotations[reconciler.SoftOwnerRefsAnnotation]), &ownerRefs) require.NoError(t, err) assert.Len(t, ownerRefs, 2) }, @@ -410,7 +409,7 @@ func Test_isPolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, }, }, }, @@ -430,7 +429,7 @@ func Test_isPolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, }, }, }, @@ -514,7 +513,7 @@ func Test_isPolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - commonannotation.SoftOwnerRefsAnnotation: `invalid-json`, + reconciler.SoftOwnerRefsAnnotation: `invalid-json`, }, }, }, From b5eaad20320669b38a0cedb3cf5edd7ad8b2b070 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Tue, 25 Nov 2025 17:15:48 +0200 Subject: [PATCH 04/19] refactor: config policy merging and use namespaced secret sources for secure settings related secrets --- .../v1alpha1/stackconfigpolicy_types.go | 48 ++ .../filesettings/file_settings.go | 40 +- .../filesettings/file_settings_test.go | 2 +- .../elasticsearch/filesettings/reconciler.go | 2 +- .../elasticsearch/filesettings/secret.go | 35 +- .../elasticsearch/filesettings/secret_test.go | 24 +- .../stackconfigpolicy/controller.go | 48 +- .../kibana_config_settings.go | 21 +- .../kibana_config_settings_test.go | 2 +- .../stackconfigpolicy/stackconfigpolicy.go | 520 ++++++++---------- .../stackconfigpolicy_test.go | 225 ++++---- 11 files changed, 463 insertions(+), 504 deletions(-) diff --git a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go index e7d17e20078..34d38769bfb 100644 --- a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go +++ b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go @@ -98,6 +98,54 @@ type ElasticsearchConfigPolicySpec struct { SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` } +// GetElasticsearchNamespacedSecureSettings returns the Elasticsearch secure settings from this policy +// as NamespacedSecretSources, with each secret source namespaced to the policy's namespace. +// Returns nil if the policy is nil or has no Elasticsearch secure settings defined. +func (p *StackConfigPolicy) GetElasticsearchNamespacedSecureSettings() []commonv1.NamespacedSecretSource { + if p == nil { + return nil + } + + ssLen := len(p.Spec.Elasticsearch.SecureSettings) + if ssLen == 0 { + return nil + } + pNs := p.GetNamespace() + ssNsn := make([]commonv1.NamespacedSecretSource, ssLen) + for idx, ss := range p.Spec.Elasticsearch.SecureSettings { + ssNsn[idx] = commonv1.NamespacedSecretSource{ + Namespace: pNs, + SecretName: ss.SecretName, + Entries: ss.Entries, + } + } + return ssNsn +} + +// GetKibanaNamespacedSecureSettings returns the Kibana secure settings from this policy +// as NamespacedSecretSources, with each secret source namespaced to the policy's namespace. +// Returns nil if the policy is nil or has no Kibana secure settings defined. +func (p *StackConfigPolicy) GetKibanaNamespacedSecureSettings() []commonv1.NamespacedSecretSource { + if p == nil { + return nil + } + + ssLen := len(p.Spec.Kibana.SecureSettings) + if ssLen == 0 { + return nil + } + pNs := p.GetNamespace() + ssNsn := make([]commonv1.NamespacedSecretSource, ssLen) + for idx, ss := range p.Spec.Kibana.SecureSettings { + ssNsn[idx] = commonv1.NamespacedSecretSource{ + Namespace: pNs, + SecretName: ss.SecretName, + Entries: ss.Entries, + } + } + return ssNsn +} + type KibanaConfigPolicySpec struct { // Config holds the settings that go into kibana.yml. // +kubebuilder:pruning:PreserveUnknownFields diff --git a/pkg/controller/elasticsearch/filesettings/file_settings.go b/pkg/controller/elasticsearch/filesettings/file_settings.go index 3bdbab5084d..00ef7798e07 100644 --- a/pkg/controller/elasticsearch/filesettings/file_settings.go +++ b/pkg/controller/elasticsearch/filesettings/file_settings.go @@ -81,12 +81,12 @@ func newEmptySettingsState() SettingsState { } // updateState updates the Settings state from a StackConfigPolicy for a given Elasticsearch. -func (s *Settings) updateState(es types.NamespacedName, policy policyv1alpha1.StackConfigPolicy) error { - p := policy.DeepCopy() // be sure to not mutate the original policy +func (s *Settings) updateState(es types.NamespacedName, esConfigPolicy policyv1alpha1.ElasticsearchConfigPolicySpec) error { + esConfigPolicy = *esConfigPolicy.DeepCopy() // be sure to not mutate the original es config policy state := newEmptySettingsState() // mutate Snapshot Repositories - if p.Spec.Elasticsearch.SnapshotRepositories != nil { - for name, untypedDefinition := range p.Spec.Elasticsearch.SnapshotRepositories.Data { + if esConfigPolicy.SnapshotRepositories != nil { + for name, untypedDefinition := range esConfigPolicy.SnapshotRepositories.Data { definition, ok := untypedDefinition.(map[string]interface{}) if !ok { return fmt.Errorf(`invalid type (%T) for definition of snapshot repository %q of Elasticsearch "%s/%s"`, untypedDefinition, name, es.Namespace, es.Name) @@ -95,31 +95,31 @@ func (s *Settings) updateState(es types.NamespacedName, policy policyv1alpha1.St if err != nil { return err } - p.Spec.Elasticsearch.SnapshotRepositories.Data[name] = repoSettings + esConfigPolicy.SnapshotRepositories.Data[name] = repoSettings } - state.SnapshotRepositories = p.Spec.Elasticsearch.SnapshotRepositories + state.SnapshotRepositories = esConfigPolicy.SnapshotRepositories } // just copy other settings - if p.Spec.Elasticsearch.ClusterSettings != nil { - state.ClusterSettings = p.Spec.Elasticsearch.ClusterSettings + if esConfigPolicy.ClusterSettings != nil { + state.ClusterSettings = esConfigPolicy.ClusterSettings } - if p.Spec.Elasticsearch.SnapshotLifecyclePolicies != nil { - state.SLM = p.Spec.Elasticsearch.SnapshotLifecyclePolicies + if esConfigPolicy.SnapshotLifecyclePolicies != nil { + state.SLM = esConfigPolicy.SnapshotLifecyclePolicies } - if p.Spec.Elasticsearch.SecurityRoleMappings != nil { - state.RoleMappings = p.Spec.Elasticsearch.SecurityRoleMappings + if esConfigPolicy.SecurityRoleMappings != nil { + state.RoleMappings = esConfigPolicy.SecurityRoleMappings } - if p.Spec.Elasticsearch.IndexLifecyclePolicies != nil { - state.IndexLifecyclePolicies = p.Spec.Elasticsearch.IndexLifecyclePolicies + if esConfigPolicy.IndexLifecyclePolicies != nil { + state.IndexLifecyclePolicies = esConfigPolicy.IndexLifecyclePolicies } - if p.Spec.Elasticsearch.IngestPipelines != nil { - state.IngestPipelines = p.Spec.Elasticsearch.IngestPipelines + if esConfigPolicy.IngestPipelines != nil { + state.IngestPipelines = esConfigPolicy.IngestPipelines } - if p.Spec.Elasticsearch.IndexTemplates.ComposableIndexTemplates != nil { - state.IndexTemplates.ComposableIndexTemplates = p.Spec.Elasticsearch.IndexTemplates.ComposableIndexTemplates + if esConfigPolicy.IndexTemplates.ComposableIndexTemplates != nil { + state.IndexTemplates.ComposableIndexTemplates = esConfigPolicy.IndexTemplates.ComposableIndexTemplates } - if p.Spec.Elasticsearch.IndexTemplates.ComponentTemplates != nil { - state.IndexTemplates.ComponentTemplates = p.Spec.Elasticsearch.IndexTemplates.ComponentTemplates + if esConfigPolicy.IndexTemplates.ComponentTemplates != nil { + state.IndexTemplates.ComponentTemplates = esConfigPolicy.IndexTemplates.ComponentTemplates } s.State = state return nil diff --git a/pkg/controller/elasticsearch/filesettings/file_settings_test.go b/pkg/controller/elasticsearch/filesettings/file_settings_test.go index 4c0b9a862e9..e62fd0c6333 100644 --- a/pkg/controller/elasticsearch/filesettings/file_settings_test.go +++ b/pkg/controller/elasticsearch/filesettings/file_settings_test.go @@ -436,7 +436,7 @@ func Test_updateState(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { settings := Settings{} - err := settings.updateState(esSample, tt.args.policy) + err := settings.updateState(esSample, tt.args.policy.Spec.Elasticsearch) if tt.wantErr != nil { assert.Equal(t, tt.wantErr, err) return diff --git a/pkg/controller/elasticsearch/filesettings/reconciler.go b/pkg/controller/elasticsearch/filesettings/reconciler.go index f3722bf2a7e..ade522d43b8 100644 --- a/pkg/controller/elasticsearch/filesettings/reconciler.go +++ b/pkg/controller/elasticsearch/filesettings/reconciler.go @@ -53,7 +53,7 @@ func ReconcileEmptyFileSettingsSecret( // extract the metadata that should be propagated to children meta := metadata.Propagate(&es, metadata.Metadata{Labels: label.NewLabels(k8s.ExtractNamespacedName(&es))}) // no secret, reconcile a new empty file settings - expectedSecret, _, err := NewSettingsSecretWithVersion(k8s.ExtractNamespacedName(&es), nil, nil, meta) + expectedSecret, _, err := NewSettingsSecretWithVersion(k8s.ExtractNamespacedName(&es), nil, nil, nil, meta) if err != nil { return err } diff --git a/pkg/controller/elasticsearch/filesettings/secret.go b/pkg/controller/elasticsearch/filesettings/secret.go index bbc5586a3f6..790f322d61d 100644 --- a/pkg/controller/elasticsearch/filesettings/secret.go +++ b/pkg/controller/elasticsearch/filesettings/secret.go @@ -34,18 +34,18 @@ const ( // The Settings version is updated using the current timestamp only when the Settings have changed. // If the new settings from the policy changed compared to the actual from the secret, the settings version is // updated -func NewSettingsSecretWithVersion(es types.NamespacedName, currentSecret *corev1.Secret, policy *policyv1alpha1.StackConfigPolicy, meta metadata.Metadata) (corev1.Secret, int64, error) { +func NewSettingsSecretWithVersion(es types.NamespacedName, currentSecret *corev1.Secret, esConfigPolicy *policyv1alpha1.ElasticsearchConfigPolicySpec, namespacedSecretSources []commonv1.NamespacedSecretSource, meta metadata.Metadata) (corev1.Secret, int64, error) { newVersion := time.Now().UnixNano() - return newSettingsSecret(newVersion, es, currentSecret, policy, meta) + return newSettingsSecret(newVersion, es, currentSecret, esConfigPolicy, namespacedSecretSources, meta) } // NewSettingsSecret returns a new SettingsSecret for a given Elasticsearch and StackConfigPolicy. -func newSettingsSecret(version int64, es types.NamespacedName, currentSecret *corev1.Secret, policy *policyv1alpha1.StackConfigPolicy, meta metadata.Metadata) (corev1.Secret, int64, error) { +func newSettingsSecret(version int64, es types.NamespacedName, currentSecret *corev1.Secret, esConfigPolicy *policyv1alpha1.ElasticsearchConfigPolicySpec, namespacedSecretSources []commonv1.NamespacedSecretSource, meta metadata.Metadata) (corev1.Secret, int64, error) { settings := NewEmptySettings(version) // update the settings according to the config policy - if policy != nil { - err := settings.updateState(es, *policy) + if esConfigPolicy != nil { + err := settings.updateState(es, *esConfigPolicy) if err != nil { return corev1.Secret{}, 0, err } @@ -84,11 +84,9 @@ func newSettingsSecret(version int64, es types.NamespacedName, currentSecret *co }, } - if policy != nil { - // add the Secure Settings Secret sources to the Settings Secret - if err := setSecureSettings(settingsSecret, *policy); err != nil { - return corev1.Secret{}, 0, err - } + // add the Secure Settings Secret sources to the Settings Secret + if err := setSecureSettings(settingsSecret, namespacedSecretSources); err != nil { + return corev1.Secret{}, 0, err } // Add a label to reset secret on deletion of the stack config policy @@ -131,24 +129,11 @@ func SetSoftOwner(settingsSecret *corev1.Secret, policy policyv1alpha1.StackConf } // setSecureSettings stores the SecureSettings Secret sources referenced in the given StackConfigPolicy in the annotation of the Settings Secret. -func setSecureSettings(settingsSecret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) error { - //nolint:staticcheck - if len(policy.Spec.SecureSettings) == 0 && len(policy.Spec.Elasticsearch.SecureSettings) == 0 { +func setSecureSettings(settingsSecret *corev1.Secret, secretSources []commonv1.NamespacedSecretSource) error { + if len(secretSources) == 0 { return nil } - var secretSources []commonv1.NamespacedSecretSource //nolint:prealloc - // Common secureSettings field, this is mainly there to maintain backwards compatibility - //nolint:staticcheck - for _, src := range policy.Spec.SecureSettings { - secretSources = append(secretSources, commonv1.NamespacedSecretSource{Namespace: policy.GetNamespace(), SecretName: src.SecretName, Entries: src.Entries}) - } - - // SecureSettings field under Elasticsearch in the StackConfigPolicy - for _, src := range policy.Spec.Elasticsearch.SecureSettings { - secretSources = append(secretSources, commonv1.NamespacedSecretSource{Namespace: policy.GetNamespace(), SecretName: src.SecretName, Entries: src.Entries}) - } - bytes, err := json.Marshal(secretSources) if err != nil { return err diff --git a/pkg/controller/elasticsearch/filesettings/secret_test.go b/pkg/controller/elasticsearch/filesettings/secret_test.go index f139864b68f..de1a592b2fd 100644 --- a/pkg/controller/elasticsearch/filesettings/secret_test.go +++ b/pkg/controller/elasticsearch/filesettings/secret_test.go @@ -38,7 +38,7 @@ func Test_NewSettingsSecret(t *testing.T) { // no policy expectedVersion := int64(1) - secret, reconciledVersion, err := newSettingsSecret(expectedVersion, es, nil, nil, metadata.Metadata{}) + secret, reconciledVersion, err := newSettingsSecret(expectedVersion, es, nil, nil, nil, metadata.Metadata{}) assert.NoError(t, err) assert.Equal(t, "esNs", secret.Namespace) assert.Equal(t, "esName-es-file-settings", secret.Name) @@ -47,7 +47,7 @@ func Test_NewSettingsSecret(t *testing.T) { // policy expectedVersion = int64(2) - secret, reconciledVersion, err = newSettingsSecret(expectedVersion, es, &secret, &policy, metadata.Metadata{}) + secret, reconciledVersion, err = newSettingsSecret(expectedVersion, es, &secret, &policy.Spec.Elasticsearch, policy.GetElasticsearchNamespacedSecureSettings(), metadata.Metadata{}) assert.NoError(t, err) assert.Equal(t, "esNs", secret.Namespace) assert.Equal(t, "esName-es-file-settings", secret.Name) @@ -79,14 +79,14 @@ func Test_SettingsSecret_hasChanged(t *testing.T) { expectedEmptySettings := NewEmptySettings(expectedVersion) // no policy -> emptySettings - secret, reconciledVersion, err := newSettingsSecret(expectedVersion, es, nil, nil, metadata.Metadata{}) + secret, reconciledVersion, err := newSettingsSecret(expectedVersion, es, nil, nil, nil, metadata.Metadata{}) assert.NoError(t, err) assert.Equal(t, false, hasChanged(secret, expectedEmptySettings)) assert.Equal(t, expectedVersion, reconciledVersion) // policy without settings -> emptySettings sameSettings := NewEmptySettings(expectedVersion) - err = sameSettings.updateState(es, policy) + err = sameSettings.updateState(es, policy.Spec.Elasticsearch) assert.NoError(t, err) assert.Equal(t, false, hasChanged(secret, sameSettings)) assert.Equal(t, strconv.FormatInt(expectedVersion, 10), sameSettings.Metadata.Version) @@ -95,7 +95,7 @@ func Test_SettingsSecret_hasChanged(t *testing.T) { newVersion := int64(2) newSettings := NewEmptySettings(newVersion) - err = newSettings.updateState(es, otherPolicy) + err = newSettings.updateState(es, otherPolicy.Spec.Elasticsearch) assert.NoError(t, err) assert.Equal(t, true, hasChanged(secret, newSettings)) assert.Equal(t, strconv.FormatInt(newVersion, 10), newSettings.Metadata.Version) @@ -112,7 +112,9 @@ func Test_SettingsSecret_setSecureSettings_getSecureSettings(t *testing.T) { Name: "policyName", }, Spec: policyv1alpha1.StackConfigPolicySpec{ - SecureSettings: nil, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + SecureSettings: nil, + }, }} otherPolicy := policyv1alpha1.StackConfigPolicy{ ObjectMeta: metav1.ObjectMeta{ @@ -120,23 +122,25 @@ func Test_SettingsSecret_setSecureSettings_getSecureSettings(t *testing.T) { Name: "otherPolicyName", }, Spec: policyv1alpha1.StackConfigPolicySpec{ - SecureSettings: []commonv1.SecretSource{{SecretName: "secure-settings-secret"}}, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + SecureSettings: []commonv1.SecretSource{{SecretName: "secure-settings-secret"}}, + }, }} - secret, _, err := NewSettingsSecretWithVersion(es, nil, nil, metadata.Metadata{}) + secret, _, err := NewSettingsSecretWithVersion(es, nil, nil, nil, metadata.Metadata{}) assert.NoError(t, err) secureSettings, err := getSecureSettings(secret) assert.NoError(t, err) assert.Equal(t, []commonv1.NamespacedSecretSource{}, secureSettings) - err = setSecureSettings(&secret, policy) + err = setSecureSettings(&secret, policy.GetElasticsearchNamespacedSecureSettings()) assert.NoError(t, err) secureSettings, err = getSecureSettings(secret) assert.NoError(t, err) assert.Equal(t, []commonv1.NamespacedSecretSource{}, secureSettings) - err = setSecureSettings(&secret, otherPolicy) + err = setSecureSettings(&secret, otherPolicy.GetElasticsearchNamespacedSecureSettings()) assert.NoError(t, err) secureSettings, err = getSecureSettings(secret) assert.NoError(t, err) diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index 33e63429e8d..893689d1485 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -333,20 +333,11 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // build the final config by merging all policies that target the given Elasticsearch cluster - esPolicyConfigFinal, err := getPolicyConfigForElasticsearch(&es, allPolicies, r.params) + esConfigPolicyFinal, err := getConfigPolicyForElasticsearch(&es, allPolicies, r.params) switch { case errors.Is(err, errMergeConflict): - log.V(1).Info("StackConfigPolicy merge conflict for Elasticsearch", "es_namespace", es.Namespace, "es_name", es.Name, "error", err) - results.WithRequeue(defaultRequeue) - if esPolicyConfigFinal == nil { - continue - } - conflictErr, exists := esPolicyConfigFinal.PoliciesWithConflictErrors[reconcilingPolicyNsn] - if !exists || conflictErr == nil { - continue - } - err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ConflictPhase, conflictErr.Error(), policyv1alpha1.ElasticsearchResourceType) - if err != nil { + log.Info("StackConfigPolicy merge conflict for Elasticsearch", "es_namespace", es.Namespace, "es_name", es.Name, "error", err) + if err = status.AddPolicyErrorFor(esNsn, policyv1alpha1.ConflictPhase, err.Error(), policyv1alpha1.ElasticsearchResourceType); err != nil { return results.WithError(err), status } continue @@ -357,16 +348,11 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context // extract the metadata that should be propagated to children meta := metadata.Propagate(&es, metadata.Metadata{Labels: eslabel.NewLabels(k8s.ExtractNamespacedName(&es))}) // create the expected Settings Secret - expectedSecret, expectedVersion, err := filesettings.NewSettingsSecretWithVersion(esNsn, &actualSettingsSecret, &policyv1alpha1.StackConfigPolicy{ - ObjectMeta: reconcilingPolicy.ObjectMeta, - Spec: policyv1alpha1.StackConfigPolicySpec{ - Elasticsearch: esPolicyConfigFinal.Spec, - }, - }, meta) + expectedSecret, expectedVersion, err := filesettings.NewSettingsSecretWithVersion(esNsn, &actualSettingsSecret, &esConfigPolicyFinal.Spec, esConfigPolicyFinal.SecretSources, meta) if err != nil { return results.WithError(err), status } - err = setMultipleSoftOwners(&expectedSecret, esPolicyConfigFinal.PoliciesRefs) + err = setMultipleSoftOwners(&expectedSecret, esConfigPolicyFinal.PolicyRefs) if err != nil { return results.WithError(err), status } @@ -390,11 +376,11 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // create expected elasticsearch config secret - expectedConfigSecret, err := newElasticsearchConfigSecret(esPolicyConfigFinal.Spec, es) + expectedConfigSecret, err := newElasticsearchConfigSecret(esConfigPolicyFinal.Spec, es) if err != nil { return results.WithError(err), status } - err = setMultipleSoftOwners(&expectedConfigSecret, esPolicyConfigFinal.PoliciesRefs) + err = setMultipleSoftOwners(&expectedConfigSecret, esConfigPolicyFinal.PolicyRefs) if err != nil { return results.WithError(err), status } @@ -404,7 +390,7 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context } // Check if required Elasticsearch config and secret mounts are applied. - configAndSecretMountsApplied, err := elasticsearchConfigAndSecretMountsApplied(ctx, r.Client, esPolicyConfigFinal.Spec, es) + configAndSecretMountsApplied, err := elasticsearchConfigAndSecretMountsApplied(ctx, r.Client, esConfigPolicyFinal.Spec, es) if err != nil { return results.WithError(err), status } @@ -467,7 +453,6 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex return results.WithError(err), status } - reconcilingPolicyNsn := k8s.ExtractNamespacedName(&reconcilingPolicy) configuredResources := kbMap{} for _, kibana := range kibanaList.Items { log.V(1).Info("Reconcile StackConfigPolicy", "kibana_namespace", kibana.Namespace, "kibana_name", kibana.Name) @@ -477,20 +462,11 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex kibanaNsn := k8s.ExtractNamespacedName(&kibana) // build the final config by merging all policies that target the given Kibana instance - kbnPolicyConfigFinal, err := getPolicyConfigForKibana(&kibana, allPolicies, r.params) + kbnPolicyConfigFinal, err := getConfigPolicyForKibana(&kibana, allPolicies, r.params) switch { case errors.Is(err, errMergeConflict): - log.V(1).Info("StackConfigPolicy merge conflict for Kibana", "kibana_namespace", kibana.Namespace, "kibana_name", kibana.Name, "error", err) - results.WithRequeue(defaultRequeue) - if kbnPolicyConfigFinal == nil { - continue - } - conflictErr, exists := kbnPolicyConfigFinal.PoliciesWithConflictErrors[reconcilingPolicyNsn] - if !exists || conflictErr == nil { - continue - } - err = status.AddPolicyErrorFor(kibanaNsn, policyv1alpha1.ConflictPhase, conflictErr.Error(), policyv1alpha1.KibanaResourceType) - if err != nil { + log.Info("StackConfigPolicy merge conflict for Kibana", "kibana_namespace", kibana.Namespace, "kibana_name", kibana.Name, "error", err) + if err = status.AddPolicyErrorFor(kibanaNsn, policyv1alpha1.ConflictPhase, err.Error(), policyv1alpha1.KibanaResourceType); err != nil { return results.WithError(err), status } continue @@ -503,7 +479,7 @@ func (r *ReconcileStackConfigPolicy) reconcileKibanaResources(ctx context.Contex // Only add to configured resources if Kibana config is set. // This will help clean up the config secret if config gets removed from the stack config reconcilingPolicy. configuredResources[kibanaNsn] = kibana - expectedConfigSecret, err := newKibanaConfigSecret(kbnPolicyConfigFinal.Spec, reconcilingPolicy.GetNamespace(), kibana, kbnPolicyConfigFinal.PoliciesRefs) + expectedConfigSecret, err := newKibanaConfigSecret(kbnPolicyConfigFinal.Spec, kbnPolicyConfigFinal.SecretSources, kibana, kbnPolicyConfigFinal.PolicyRefs) if err != nil { return results.WithError(err), status } diff --git a/pkg/controller/stackconfigpolicy/kibana_config_settings.go b/pkg/controller/stackconfigpolicy/kibana_config_settings.go index 61bbf735077..7b3b77a516d 100644 --- a/pkg/controller/stackconfigpolicy/kibana_config_settings.go +++ b/pkg/controller/stackconfigpolicy/kibana_config_settings.go @@ -25,7 +25,12 @@ const ( KibanaConfigKey = "kibana.json" ) -func newKibanaConfigSecret(kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, policyNamespace string, kibana kibanav1.Kibana, policyRefs []policyv1alpha1.StackConfigPolicy) (corev1.Secret, error) { +func newKibanaConfigSecret( + kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, + namespacedSecretSources []commonv1.NamespacedSecretSource, + kibana kibanav1.Kibana, + policyRefs []policyv1alpha1.StackConfigPolicy, +) (corev1.Secret, error) { kibanaConfigHash := getKibanaConfigHash(kbConfigPolicy.Config) configDataJSONBytes := []byte("") var err error @@ -56,7 +61,7 @@ func newKibanaConfigSecret(kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, kibanaConfigSecret.Labels[commonlabels.StackConfigPolicyOnDeleteLabelName] = commonlabels.OrphanSecretDeleteOnPolicyDelete // Add SecureSettings as annotation - if err = setKibanaSecureSettings(&kibanaConfigSecret, kbConfigPolicy, policyNamespace); err != nil { + if err = setKibanaSecureSettings(&kibanaConfigSecret, namespacedSecretSources); err != nil { return kibanaConfigSecret, err } @@ -95,18 +100,12 @@ func kibanaConfigApplied(c k8s.Client, kbConfigPolicy policyv1alpha1.KibanaConfi } // setKibanaSecureSettings stores the SecureSettings Secret sources referenced in the given StackConfigPolicy for Kibana in the annotation of the Kibana config Secret. -func setKibanaSecureSettings(settingsSecret *corev1.Secret, kbConfigPolicy policyv1alpha1.KibanaConfigPolicySpec, policyNamespace string) error { - if len(kbConfigPolicy.SecureSettings) == 0 { +func setKibanaSecureSettings(settingsSecret *corev1.Secret, namespacedSecretSources []commonv1.NamespacedSecretSource) error { + if len(namespacedSecretSources) == 0 { return nil } - var secretSources []commonv1.NamespacedSecretSource //nolint:prealloc - // SecureSettings field under Kibana in the StackConfigPolicy - for _, src := range kbConfigPolicy.SecureSettings { - secretSources = append(secretSources, commonv1.NamespacedSecretSource{Namespace: policyNamespace, SecretName: src.SecretName, Entries: src.Entries}) - } - - bytes, err := json.Marshal(secretSources) + bytes, err := json.Marshal(namespacedSecretSources) if err != nil { return err } diff --git a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go index 66b901d7005..015a054ea1c 100644 --- a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go +++ b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go @@ -83,7 +83,7 @@ func Test_newKibanaConfigSecret(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := newKibanaConfigSecret(tt.args.policy.Spec.Kibana, tt.args.policy.GetNamespace(), tt.args.kb, []policyv1alpha1.StackConfigPolicy{*tt.args.policy}) + got, err := newKibanaConfigSecret(tt.args.policy.Spec.Kibana, tt.args.policy.GetKibanaNamespacedSecureSettings(), tt.args.kb, []policyv1alpha1.StackConfigPolicy{*tt.args.policy}) require.NoError(t, err) require.Equal(t, tt.want, got) }) diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index b48b46e52fa..04612a283ae 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -5,16 +5,15 @@ package stackconfigpolicy import ( + "cmp" "errors" "fmt" "maps" "slices" - "strconv" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/types" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" @@ -27,246 +26,120 @@ import ( var errMergeConflict = errors.New("merge conflict") -// esPolicyConfig represents the merged configuration from multiple StackConfigPolicies -// that apply to a specific Elasticsearch cluster. -type esPolicyConfig struct { - // Spec holds the merged Elasticsearch configuration from all applicable policies - Spec policyv1alpha1.ElasticsearchConfigPolicySpec - // PoliciesWithConflictErrors maps policy namespaced names to conflict errors when multiple policies - // have the same weight - PoliciesWithConflictErrors map[types.NamespacedName]error - // PoliciesRefs contains all StackConfigPolicies that target this Elasticsearch cluster - PoliciesRefs []policyv1alpha1.StackConfigPolicy +// configPolicy is a generic container for merged StackConfigPolicy specifications. +// It holds the merged spec of type T (either ElasticsearchConfigPolicySpec or KibanaConfigPolicySpec), +// along with metadata about which policies were merged, any conflicts encountered, and aggregated +// secret sources. The extractFunc and mergeFunc callbacks allow customization of how specs are +// extracted from policies and merged together. +type configPolicy[T any] struct { + // Spec is the merged config policy specification + Spec T + // extractFunc extracts the relevant spec (ES or Kibana) from a StackConfigPolicy + extractFunc func(p *policyv1alpha1.StackConfigPolicy) (spec T) + // mergeFunc merges a source spec into the destination spec, handling conflicts + mergeFunc func(dstSpec *T, srcSpec T, srcPolicy *policyv1alpha1.StackConfigPolicy) error + // SecretSources contains aggregated secure settings secret sources, keyed by StackConfigPolicy namespace + SecretSources []commonv1.NamespacedSecretSource + // PolicyRefs contains references to all policies that targeted and were merged for this object + PolicyRefs []policyv1alpha1.StackConfigPolicy } -// getPolicyConfigForElasticsearch builds a merged stack config policy for the given Elasticsearch cluster. -// It processes all provided policies, filtering those that target the Elasticsearch cluster, and merges them -// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. -// Returns an esPolicyConfig containing the merged configuration and any error occurred during merging. -func getPolicyConfigForElasticsearch(es *esv1.Elasticsearch, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*esPolicyConfig, error) { - esPolicy := esPolicyConfig{ - PoliciesWithConflictErrors: make(map[types.NamespacedName]error), - } +// merge processes all provided policies, filters those targeting the given object, and merges them +// in order of their weight (highest weight first). Policies with the same weight are flagged as conflicts. +// The merge operation is customized through the configPolicy's extractFunc and mergeFunc callbacks. +func merge[T any]( + c *configPolicy[T], + obj metav1.Object, + allPolicies []policyv1alpha1.StackConfigPolicy, + operatorNamespace string, +) error { if len(allPolicies) == 0 { - return &esPolicy, nil + return nil } - // Group policies by weight - var weights []int32 - weightKeyStackPolicies := make(map[int32][]*policyv1alpha1.StackConfigPolicy) + policiesByWeight := make(map[int32]policyv1alpha1.StackConfigPolicy) for _, p := range allPolicies { - isRef, err := doesPolicyRefsObject(&p, es, params.OperatorNamespace) + matches, err := doesPolicyMatchObject(&p, obj, operatorNamespace) if err != nil { - return nil, err + return err } - if !isRef { - // policy does not target the given Elasticsearch + if !matches { + // policy does not target the given k8s object continue } - - if _, exists := weightKeyStackPolicies[p.Spec.Weight]; !exists { - weights = append(weights, p.Spec.Weight) + pWeight := p.Spec.Weight + if pExisting, exists := policiesByWeight[pWeight]; exists { + pNsn := k8s.ExtractNamespacedName(&p) + pExistingNsn := k8s.ExtractNamespacedName(&pExisting) + err := fmt.Errorf("%w: policies %q and %q have the same weight %d", errMergeConflict, pNsn, pExistingNsn, pWeight) + return err } - weightKeyStackPolicies[p.Spec.Weight] = append(weightKeyStackPolicies[p.Spec.Weight], &p) - esPolicy.PoliciesRefs = append(esPolicy.PoliciesRefs, p) - } - - if len(esPolicy.PoliciesRefs) == 1 { - // Since we have only one policy avoid merging (including canonicalise) - // and thus avoid any reconciliation storm caused by unnecessary - // secret changes - esConfigPolicy := esPolicy.PoliciesRefs[0].Spec.Elasticsearch.DeepCopy() - esPolicy.Spec = *esConfigPolicy - return &esPolicy, nil + policiesByWeight[pWeight] = p + c.PolicyRefs = append(c.PolicyRefs, p) } - // Process policies in order of weight (lowest first) - slices.Sort(weights) + slices.SortFunc(c.PolicyRefs, func(p1, p2 policyv1alpha1.StackConfigPolicy) int { + return cmp.Compare(p2.Spec.Weight, p1.Spec.Weight) + }) - // Reverse the weights so that we process policies in order of weight (highest first) - slices.Reverse(weights) - - var previouslyAppliedPolicy *policyv1alpha1.StackConfigPolicy - for _, weight := range weights { - policiesWithSameWeight := weightKeyStackPolicies[weight] - if len(policiesWithSameWeight) > 1 { - // Multiple policies with the same weight - this is a conflict - conflictErr := getPolicyConflictError(policiesWithSameWeight, weight) - for _, p := range policiesWithSameWeight { - esPolicy.PoliciesWithConflictErrors[k8s.ExtractNamespacedName(p)] = conflictErr - } - return &esPolicy, conflictErr - } - policy := policiesWithSameWeight[0] - // Merge the single policy at this weight level - if err := mergeElasticsearchConfig(&esPolicy.Spec, policy.Spec.Elasticsearch); err != nil { - if errors.Is(err, errMergeConflict) { - policyNsn := k8s.ExtractNamespacedName(policy) - esPolicy.PoliciesWithConflictErrors[policyNsn] = err - - if previouslyAppliedPolicy != nil { - previouslyAppliedPolicyNsn := k8s.ExtractNamespacedName(previouslyAppliedPolicy) - esPolicy.PoliciesWithConflictErrors[previouslyAppliedPolicyNsn] = err - } - return &esPolicy, err - } - return nil, err + for _, p := range c.PolicyRefs { + srcSpec := c.extractFunc(&p) + if err := c.mergeFunc(&c.Spec, srcSpec, &p); err != nil { + return err } - previouslyAppliedPolicy = policy } - return &esPolicy, nil -} - -// kbnPolicyConfig represents the merged configuration from multiple StackConfigPolicies -// that apply to a specific Kibana instance. -type kbnPolicyConfig struct { - // Spec contains the merged Kibana configuration from all applicable policies - Spec policyv1alpha1.KibanaConfigPolicySpec - // PoliciesWithConflictErrors maps policy namespaced names to conflict errors when multiple policies - // have the same weight - PoliciesWithConflictErrors map[types.NamespacedName]error - // PoliciesRefs contains all StackConfigPolicies that target this Kibana instance - PoliciesRefs []policyv1alpha1.StackConfigPolicy + return nil } -// getPolicyConfigForKibana builds a merged stack config policy for the given Kibana instance. -// It processes all provided policies, filtering those that target the Kibana instance, and merges them +// getConfigPolicyForElasticsearch builds a merged stack config policy for the given Elasticsearch cluster. +// It processes all provided policies, filtering those that target the Elasticsearch cluster, and merges them // in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. -// Returns an kbnPolicyConfig containing the merged configuration and any error occurred during merging. -func getPolicyConfigForKibana(kb *kbv1.Kibana, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*kbnPolicyConfig, error) { - kbPolicy := kbnPolicyConfig{ - PoliciesWithConflictErrors: make(map[types.NamespacedName]error), - } - if len(allPolicies) == 0 { - return &kbPolicy, nil - } - - // Group policies by weight - var weights []int32 - weightKeyStackPolicies := make(map[int32][]*policyv1alpha1.StackConfigPolicy) - for _, p := range allPolicies { - isRef, err := doesPolicyRefsObject(&p, kb, params.OperatorNamespace) - if err != nil { - return nil, err - } - if !isRef { - // policy does not target the given Kibana instance - continue - } - - if _, exists := weightKeyStackPolicies[p.Spec.Weight]; !exists { - weights = append(weights, p.Spec.Weight) - } - - weightKeyStackPolicies[p.Spec.Weight] = append(weightKeyStackPolicies[p.Spec.Weight], &p) - kbPolicy.PoliciesRefs = append(kbPolicy.PoliciesRefs, p) - } - - if len(kbPolicy.PoliciesRefs) == 1 { - // Since we have only one policy avoid merging (including canonicalise) - // and thus don't cause a reconciliation storm by unnecessary - // secret changes - kbConfigPolicy := kbPolicy.PoliciesRefs[0].Spec.Kibana.DeepCopy() - kbPolicy.Spec = *kbConfigPolicy - return &kbPolicy, nil - } - - // Process policies in order of weight (lowest first) - slices.Sort(weights) - - // Reverse the weights so that we process policies in order of weight (highest first) - slices.Reverse(weights) +// Returns an esPolicyConfig containing the merged configuration and any error occurred during merging. +func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec], error) { + mergedPolicies := 0 + sMntAggr := secretMountsAggregator{} + sSrcAggr := secretSourceAggregator{} + cfgPolicy := &configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec]{ + extractFunc: func(p *policyv1alpha1.StackConfigPolicy) policyv1alpha1.ElasticsearchConfigPolicySpec { + return p.Spec.Elasticsearch + }, + mergeFunc: func(dst *policyv1alpha1.ElasticsearchConfigPolicySpec, src policyv1alpha1.ElasticsearchConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { + var err error + if mergedPolicies == 0 { + // First policy: copy directly without merging/canonicalizing to avoid unnecessary differences + specCopy := src.DeepCopy() + // SecureSettings are aggregated by sSrcAggr + specCopy.SecureSettings = nil + // SecretMounts are aggregated by sMntAggr + specCopy.SecretMounts = nil + *dst = *specCopy + } else { + if err = mergeElasticsearchSpecs(dst, &src); err != nil { + return err + } + } - for _, weight := range weights { - policiesWithWeight := weightKeyStackPolicies[weight] - if len(policiesWithWeight) > 1 { - // Multiple policies with the same weight - this is a conflict - conflictErr := getPolicyConflictError(policiesWithWeight, weight) - for _, p := range policiesWithWeight { - kbPolicy.PoliciesWithConflictErrors[k8s.ExtractNamespacedName(p)] = conflictErr + if dst.SecretMounts, err = sMntAggr.aggregate(dst.SecretMounts, srcPolicy.Spec.Elasticsearch.SecretMounts, srcPolicy); err != nil { + return err } - return &kbPolicy, conflictErr - } - // Merge the single policy at this weight level - if err := mergeKibanaConfig(&kbPolicy.Spec, policiesWithWeight[0].Spec.Kibana); err != nil { - return nil, err - } + sSrcAggr.aggregate(srcPolicy.Spec.Elasticsearch.SecureSettings, srcPolicy) + mergedPolicies++ + return nil + }, } - - return &kbPolicy, nil -} - -// doesPolicyRefsObject checks if the given StackConfigPolicy targets the given Elasticsearch cluster. -// A policy targets an Elasticsearch cluster if both following conditions are met: -// 1. The policy is in either the operator namespace or the same namespace as the Elasticsearch cluster -// 2. The policy's label selector matches the Elasticsearch cluster's labels -// Returns true or false depending on whether the given policy targets the Elasticsearch cluster and -// an error if the label selector is invalid. -func doesPolicyRefsObject(policy *policyv1alpha1.StackConfigPolicy, obj metav1.Object, operatorNamespace string) (bool, error) { - // Convert the label selector to a selector object - selector, err := metav1.LabelSelectorAsSelector(&policy.Spec.ResourceSelector) + err := merge(cfgPolicy, es, allPolicies, params.OperatorNamespace) if err != nil { - return false, err + return cfgPolicy, err } - - // Check namespace restrictions; the policy must be in operator namespace or same namespace as the target object - if policy.Namespace != operatorNamespace && policy.Namespace != obj.GetNamespace() { - return false, nil - } - - // Check if the label selector matches the Elasticsearch labels - if !selector.Matches(labels.Set(obj.GetLabels())) { - return false, nil - } - - return true, nil + cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources + return cfgPolicy, nil } -// getPolicyConflictError creates an error message listing all policies that have conflicting weights. -// The error message includes the namespaced names of all conflicting policies. -// Returns an error with a message listing all conflicting policy names. -func getPolicyConflictError(policies []*policyv1alpha1.StackConfigPolicy, weight int32) error { - strBuilder := strings.Builder{} - - strBuilder.WriteString("multiple stack config policies ") - for idx, p := range policies { - if idx > 0 { - strBuilder.WriteString(", ") - } - strBuilder.WriteString(`"`) - strBuilder.WriteString(k8s.ExtractNamespacedName(p).String()) - strBuilder.WriteString(`"`) - } - strBuilder.WriteString(" with the same weight ") - strBuilder.WriteString(strconv.Itoa(int(weight))) - return fmt.Errorf("%w: %s", errMergeConflict, strBuilder.String()) -} - -// mergeKibanaConfig merges the source KibanaConfigPolicySpec into the destination. -// For configuration fields (Config, SecureSettings), it performs a deep merge -// where source values override destination values at the field level. -// For SecretMounts and SecureSettings, it merges by name/key, with source values taking precedence. -// Returns any error occurred during configuration merges. -func mergeKibanaConfig(dst *policyv1alpha1.KibanaConfigPolicySpec, src policyv1alpha1.KibanaConfigPolicySpec) error { - var err error - if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { - return err - } - dst.SecureSettings = mergeSecretSources(dst.SecureSettings, src.SecureSettings) - return nil -} - -// mergeElasticsearchConfig merges the source ElasticsearchConfigPolicySpec into the destination. -// For configuration fields (ClusterSettings, SnapshotRepositories, etc.), it performs a deep merge -// where source values override destination values at the field level. -// For SecretMounts, conflicts are detected when the same SecretName or MountPath exists in both -// dst and src. An error is returned to prevent duplicate secret references or mount path collisions. -// For SecureSettings, it merges by SecretName/Key with source values taking precedence. -// Returns any error occurred during configuration merges. -func mergeElasticsearchConfig(dst *policyv1alpha1.ElasticsearchConfigPolicySpec, src policyv1alpha1.ElasticsearchConfigPolicySpec) error { +// mergeElasticsearchSpecs merges src policyv1alpha1.ElasticsearchConfigPolicySpec into dst. +func mergeElasticsearchSpecs(dst, src *policyv1alpha1.ElasticsearchConfigPolicySpec) error { var err error if dst.ClusterSettings, err = deepMergeConfig(dst.ClusterSettings, src.ClusterSettings); err != nil { return err @@ -295,12 +168,70 @@ func mergeElasticsearchConfig(dst *policyv1alpha1.ElasticsearchConfigPolicySpec, if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { return err } + return nil +} - if dst.SecretMounts, err = mergeSecretMounts(dst.SecretMounts, src.SecretMounts); err != nil { - return err +// getConfigPolicyForKibana builds a merged stack config policy for the given Kibana instance. +// It processes all provided policies, filtering those that target the Kibana instance, and merges them +// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. +// Returns an kbnPolicyConfig containing the merged configuration and any error occurred during merging. +func getConfigPolicyForKibana(kbn *kbv1.Kibana, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.KibanaConfigPolicySpec], error) { + mergedPolicies := 0 + sSrcAggr := secretSourceAggregator{} + cfgPolicy := &configPolicy[policyv1alpha1.KibanaConfigPolicySpec]{ + extractFunc: func(p *policyv1alpha1.StackConfigPolicy) policyv1alpha1.KibanaConfigPolicySpec { + return p.Spec.Kibana + }, + mergeFunc: func(dst *policyv1alpha1.KibanaConfigPolicySpec, src policyv1alpha1.KibanaConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { + if mergedPolicies == 0 { + // First policy: copy directly without merging/canonicalizing to avoid unnecessary differences + specCopy := src.DeepCopy() + // SecureSettings are aggregated by sSrcAggr + specCopy.SecureSettings = nil + *dst = *specCopy + } else { + var err error + if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { + return err + } + } + + sSrcAggr.aggregate(srcPolicy.Spec.Kibana.SecureSettings, srcPolicy) + mergedPolicies++ + return nil + }, } - dst.SecureSettings = mergeSecretSources(dst.SecureSettings, src.SecureSettings) - return nil + err := merge(cfgPolicy, kbn, allPolicies, params.OperatorNamespace) + if err != nil { + return cfgPolicy, err + } + cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources + return cfgPolicy, nil +} + +// doesPolicyMatchObject checks if the given StackConfigPolicy targets the given object. +// A policy targets an object if both following conditions are met: +// 1. The policy is in either the operator namespace or the same namespace as the object +// 2. The policy's label selector matches the object's labels +// Returns true if the policy targets the object, false otherwise, and an error if the label selector is invalid. +func doesPolicyMatchObject(policy *policyv1alpha1.StackConfigPolicy, obj metav1.Object, operatorNamespace string) (bool, error) { + // Convert the label selector to a selector object + selector, err := metav1.LabelSelectorAsSelector(&policy.Spec.ResourceSelector) + if err != nil { + return false, err + } + + // Check namespace restrictions; the policy must be in operator namespace or same namespace as the target object + if policy.Namespace != operatorNamespace && policy.Namespace != obj.GetNamespace() { + return false, nil + } + + // Check if the label selector matches the Elasticsearch labels + if !selector.Matches(labels.Set(obj.GetLabels())) { + return false, nil + } + + return true, nil } // deepMergeConfig merges the source Config into the destination Config using canonical configuration merging. @@ -382,108 +313,99 @@ func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, return nil, err } - for k, v := range srcCfg.Data { - dst.Data[k] = v - } + maps.Copy(dst.Data, srcCfg.Data) return dst, nil } -// mergeSecretMounts merges source SecretMounts into destination SecretMounts. -// SecretMounts are keyed by SecretName and MountPath. Conflicts are detected when: -// - The same SecretName exists in both dst and src (prevents duplicate secret references) -// - The same MountPath exists in both dst and src (prevents mount path collisions) -// Returns a new slice containing the merged SecretMounts sorted by SecretName for -// deterministic output, or an error if conflicts are detected. -func mergeSecretMounts(dst []policyv1alpha1.SecretMount, src []policyv1alpha1.SecretMount) ([]policyv1alpha1.SecretMount, error) { - secretMounts := make(map[string]policyv1alpha1.SecretMount) +// secretMountsAggregator aggregates secret mounts from multiple policies while detecting conflicts. +// It tracks which policy defines each secret name and mount path to ensure no two policies +// attempt to mount different secrets to the same path or mount the same secret name twice. +type secretMountsAggregator struct { + policiesByMountPath map[string]*policyv1alpha1.StackConfigPolicy + policiesBySecretName map[string]*policyv1alpha1.StackConfigPolicy + appliedPolicies int +} - // Add all destination entries - mountPoints := make(map[string]string) - for _, secretMount := range dst { - secretMounts[secretMount.SecretName] = secretMount - mountPoints[secretMount.MountPath] = secretMount.SecretName +// aggregate merges source secret mounts into destination, checking for conflicts on secret names +// and mount paths. Returns the merged slice of secret mounts sorted deterministically when +// multiple policies have been applied, or an error if conflicts are detected. +func (s *secretMountsAggregator) aggregate(dst []policyv1alpha1.SecretMount, src []policyv1alpha1.SecretMount, srcPolicy *policyv1alpha1.StackConfigPolicy) ([]policyv1alpha1.SecretMount, error) { + if src == nil { + return dst, nil + } + + s.appliedPolicies++ + + if s.policiesBySecretName == nil { + s.policiesBySecretName = make(map[string]*policyv1alpha1.StackConfigPolicy) } + if s.policiesByMountPath == nil { + s.policiesByMountPath = make(map[string]*policyv1alpha1.StackConfigPolicy) + } + + srcPolicyNsn := k8s.ExtractNamespacedName(srcPolicy) // Merge in source entries, checking for conflicts for _, secretMount := range src { - if _, exists := secretMounts[secretMount.SecretName]; exists { - return nil, fmt.Errorf("%w: secret with name %q is defined in multiple policies", errMergeConflict, secretMount.SecretName) + if existingPolicy, exists := s.policiesBySecretName[secretMount.SecretName]; exists { + existingPolicyNsn := k8s.ExtractNamespacedName(existingPolicy) + err := fmt.Errorf("%w: secret with name %q is defined in policies %q, %q", errMergeConflict, secretMount.SecretName, + srcPolicyNsn.String(), existingPolicyNsn.String()) + return nil, err } - if _, exists := mountPoints[secretMount.MountPath]; exists { - return nil, fmt.Errorf("%w: secret mount path %q is defined in multiple policies", errMergeConflict, secretMount.MountPath) + if existingPolicy, exists := s.policiesByMountPath[secretMount.MountPath]; exists { + existingPolicyNsn := k8s.ExtractNamespacedName(existingPolicy) + err := fmt.Errorf("%w: secret mount path %q is defined in policies %q, %q", errMergeConflict, secretMount.MountPath, + srcPolicyNsn.String(), existingPolicyNsn.String()) + return nil, err } - mountPoints[secretMount.MountPath] = secretMount.SecretName - secretMounts[secretMount.SecretName] = secretMount + s.policiesBySecretName[secretMount.SecretName] = srcPolicy + s.policiesByMountPath[secretMount.MountPath] = srcPolicy + dst = append(dst, secretMount) } - // Collect secret names and sort them for deterministic output - secretNames := slices.Collect(maps.Keys(secretMounts)) - if len(secretNames) == 0 { - return nil, nil + if s.appliedPolicies > 1 { + // we want sort only when we have applied more than one policy to guarantee deterministic order, otherwise + // leave namespacedSecretSources as they came to not cause undesired differences + slices.SortFunc(dst, func(a, b policyv1alpha1.SecretMount) int { + return strings.Compare(a.SecretName, b.SecretName) + }) } + return dst, nil +} - slices.Sort(secretNames) - - // Build the result in sorted order - mergedSecretMounts := make([]policyv1alpha1.SecretMount, 0, len(secretNames)) - for _, secretName := range secretNames { - mergedSecretMounts = append(mergedSecretMounts, secretMounts[secretName]) - } - return mergedSecretMounts, nil +// secretSourceAggregator aggregates secure settings secret sources from multiple policies. +// It organizes secret sources by policy namespace and ensures deterministic ordering when +// multiple policies contribute sources. +type secretSourceAggregator struct { + appliedPolicies int + namespacedSecretSources []commonv1.NamespacedSecretSource } -// mergeSecretSources merges source SecretSources into destination SecretSources. -// SecretSources are merged at two levels: -// 1. First level: keyed by SecretName -// 2. Second level: within each SecretName, entries are keyed by Key -// If the same SecretName and Key exist in both dst and src, the src entry overrides the dst entry. -// Returns a new slice containing the merged SecretSources, sorted by SecretName and -// Key for deterministic output. -func mergeSecretSources(dst []commonv1.SecretSource, src []commonv1.SecretSource) []commonv1.SecretSource { - secureSettings := make(map[string]map[string]commonv1.KeyToPath) - // Add all destination entries - for _, secureSetting := range dst { - secureSettings[secureSetting.SecretName] = make(map[string]commonv1.KeyToPath) - for _, entry := range secureSetting.Entries { - secureSettings[secureSetting.SecretName][entry.Key] = entry - } - } - // Merge in source entries (overriding destination if same SecretName/Key) - for _, secureSetting := range src { - if _, exists := secureSettings[secureSetting.SecretName]; !exists { - secureSettings[secureSetting.SecretName] = make(map[string]commonv1.KeyToPath) - } - for _, entry := range secureSetting.Entries { - secureSettings[secureSetting.SecretName][entry.Key] = entry - } +// aggregate merges source secure settings into the aggregator, organizing them by the source +// policy's namespace. Secret sources are sorted deterministically when multiple policies have +// been applied to ensure consistent results. +func (s *secretSourceAggregator) aggregate(src []commonv1.SecretSource, srcPolicy *policyv1alpha1.StackConfigPolicy) { + if src == nil { + return } + s.appliedPolicies++ - // Collect and sort secret names for deterministic output - secretNames := slices.Collect(maps.Keys(secureSettings)) - if len(secretNames) == 0 { - return nil + srcPolicyNamespace := srcPolicy.GetNamespace() + for _, ss := range src { + s.namespacedSecretSources = append(s.namespacedSecretSources, commonv1.NamespacedSecretSource{ + Namespace: srcPolicyNamespace, + SecretName: ss.SecretName, + Entries: ss.Entries, + }) } - slices.Sort(secretNames) - - // Build the result in sorted order - mergedSecureSettings := make([]commonv1.SecretSource, 0, len(secretNames)) - for _, secretName := range secretNames { - entries := secureSettings[secretName] - - // Collect and sort entry keys for deterministic output - keys := slices.Collect(maps.Keys(entries)) - slices.Sort(keys) - - secretSource := commonv1.SecretSource{ - SecretName: secretName, - } - for _, key := range keys { - secretSource.Entries = append(secretSource.Entries, entries[key]) - } - mergedSecureSettings = append(mergedSecureSettings, secretSource) + if s.appliedPolicies > 1 { + // we want sort only when we have applied more than one policy to guarantee deterministic order, otherwise + // leave namespacedSecretSources as they came to not cause undesired differences + slices.SortFunc(s.namespacedSecretSources, func(a, b commonv1.NamespacedSecretSource) int { + return strings.Compare(a.SecretName, b.SecretName) + }) } - - return mergedSecureSettings } diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go index 68d60d4f554..fb5bf3dc1fb 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -21,14 +20,15 @@ import ( func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { for _, tc := range []struct { - name string - policyNamespace string - operatorNamespace string - targetElasticsearch *esv1.Elasticsearch - stackConfigPolicies []policyv1alpha1.StackConfigPolicy - expectedConfigPolicy policyv1alpha1.StackConfigPolicy - expectedPolicyRefs map[string]struct{} - expectedConflictErrors map[types.NamespacedName]bool // map of policy names that should have conflict errors + name string + policyNamespace string + operatorNamespace string + targetElasticsearch *esv1.Elasticsearch + stackConfigPolicies []policyv1alpha1.StackConfigPolicy + expectedConfigPolicy policyv1alpha1.StackConfigPolicy + expectedSecretSources []commonv1.NamespacedSecretSource + expectedPolicyRefs map[string]struct{} + expectedMergeConflict bool }{ { name: "merges without overwrites", @@ -60,7 +60,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }}, SecureSettings: []commonv1.SecretSource{ { - SecretName: "test", + SecretName: "test-secret-policy1", Entries: []commonv1.KeyToPath{ {Key: "test", Path: "/test-policy1"}, }, @@ -95,7 +95,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }}, SecureSettings: []commonv1.SecretSource{ { - SecretName: "test", + SecretName: "test-secret-policy2", Entries: []commonv1.KeyToPath{ {Key: "test1", Path: "/test1-policy2"}, {Key: "test2", Path: "/test2-policy2"}, @@ -123,16 +123,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { "test1.name": "policy1", "test2.name": "policy2", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test", - Entries: []commonv1.KeyToPath{ - {Key: "test", Path: "/test-policy1"}, - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, - }, - }, - }, SecretMounts: []policyv1alpha1.SecretMount{ {SecretName: "secret-policy1", MountPath: "/secret-policy1"}, {SecretName: "secret-policy2", MountPath: "/secret-policy2"}, @@ -151,6 +141,23 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, + expectedSecretSources: []commonv1.NamespacedSecretSource{ + { + SecretName: "test-secret-policy1", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/test-policy1"}, + }, + }, + { + SecretName: "test-secret-policy2", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, expectedPolicyRefs: map[string]struct{}{ "test/policy1": {}, "test/policy2": {}, @@ -248,15 +255,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { ClusterSettings: &commonv1.Config{Data: map[string]any{ "test.name": "policy2", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test", - Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, - }, - }, - }, SecretMounts: []policyv1alpha1.SecretMount{ {SecretName: "secret-policy-1", MountPath: "/secret-policy1"}, {SecretName: "secret-policy-2", MountPath: "/secret-policy2"}, @@ -271,6 +269,24 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, + expectedSecretSources: []commonv1.NamespacedSecretSource{ + { + SecretName: "test", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy1"}, + {Key: "test2", Path: "/test2-policy1"}, + }, + }, + { + SecretName: "test", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test1", Path: "/test1-policy2"}, + {Key: "test2", Path: "/test2-policy2"}, + }, + }, + }, expectedPolicyRefs: map[string]struct{}{ "test/policy1": {}, "test/policy2": {}, @@ -365,10 +381,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - expectedConflictErrors: map[types.NamespacedName]bool{ - {Namespace: "test", Name: "policy2-conflict"}: true, - {Namespace: "test", Name: "policy3-conflict"}: true, - }, + expectedMergeConflict: true, }, { name: "detects conflicts when same secret defined in multiple policies", @@ -428,10 +441,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - expectedConflictErrors: map[types.NamespacedName]bool{ - {Namespace: "test", Name: "policy2"}: true, - {Namespace: "test", Name: "policy1"}: true, - }, + expectedMergeConflict: true, }, { name: "detects conflicts when same mount path defined in multiple policies", @@ -491,10 +501,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - expectedConflictErrors: map[types.NamespacedName]bool{ - {Namespace: "test", Name: "policy2"}: true, - {Namespace: "test", Name: "policy1"}: true, - }, + expectedMergeConflict: true, }, { name: "successfully merges when different secrets use different mount paths", @@ -674,39 +681,29 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - esStackConfig, err := getPolicyConfigForElasticsearch(tc.targetElasticsearch, tc.stackConfigPolicies, operator.Parameters{ + esConfigPolicy, err := getConfigPolicyForElasticsearch(tc.targetElasticsearch, tc.stackConfigPolicies, operator.Parameters{ OperatorNamespace: tc.operatorNamespace, }) // Check for expected conflict errors - if tc.expectedConflictErrors != nil { - assert.ErrorIs(t, err, errMergeConflict, "getPolicyConfigForElasticsearch should return an error") - assert.NotNil(t, esStackConfig.PoliciesWithConflictErrors, "should have conflict errors") - assert.Len(t, esStackConfig.PoliciesWithConflictErrors, len(tc.expectedConflictErrors), "should have expected number of conflict errors") - - for policyNsn, shouldHaveError := range tc.expectedConflictErrors { - if shouldHaveError { - assert.Containsf(t, esStackConfig.PoliciesWithConflictErrors, policyNsn, "Expected conflict error for policy %s but found none", policyNsn) - } else { - assert.NotContainsf(t, esStackConfig.PoliciesWithConflictErrors, policyNsn, "Not expected conflict error for policy %s but found none", policyNsn) - } - } + if tc.expectedMergeConflict { + assert.ErrorIs(t, err, errMergeConflict, "getConfigPolicyForElasticsearch should return an error") return } - assert.NoError(t, err, "getPolicyConfigForElasticsearch should not return an error") - assert.Empty(t, esStackConfig.PoliciesWithConflictErrors, "should not have any conflict errors") + assert.NoError(t, err, "getConfigPolicyForElasticsearch should not return an error") - // we self-merge the expected config just to canonicalise it - expectedConfigPolicyCopy := tc.expectedConfigPolicy.Spec.Elasticsearch.DeepCopy() - expectedConfigPolicyCopy.SecretMounts = nil - err = mergeElasticsearchConfig(&tc.expectedConfigPolicy.Spec.Elasticsearch, *expectedConfigPolicyCopy) - require.NoError(t, err, "canonicalise expected config should not return an error") + // Compare secret sources if expected + if tc.expectedSecretSources != nil { + assert.EqualValues(t, tc.expectedSecretSources, esConfigPolicy.SecretSources) + } - // Compare the merged Elasticsearch configuration - assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Elasticsearch, esStackConfig.Spec) + if len(tc.stackConfigPolicies) > 1 { + canonicaliseElasticsearchPolicyConfig(t, &tc.expectedConfigPolicy.Spec.Elasticsearch) + } + assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Elasticsearch, esConfigPolicy.Spec) // Compare policy references by building a map from the actual refs actualPolicyRefs := make(map[string]struct{}) - for _, policy := range esStackConfig.PoliciesRefs { + for _, policy := range esConfigPolicy.PolicyRefs { nsn := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} actualPolicyRefs[nsn.String()] = struct{}{} } @@ -717,14 +714,15 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { func Test_getPolicyConfigForKibana(t *testing.T) { for _, tc := range []struct { - name string - policyNamespace string - operatorNamespace string - targetKibana *kbv1.Kibana - stackConfigPolicies []policyv1alpha1.StackConfigPolicy - expectedConfigPolicy policyv1alpha1.StackConfigPolicy - expectedPolicyRefs map[string]struct{} - expectedConflictErrors map[types.NamespacedName]bool + name string + policyNamespace string + operatorNamespace string + targetKibana *kbv1.Kibana + stackConfigPolicies []policyv1alpha1.StackConfigPolicy + expectedConfigPolicy policyv1alpha1.StackConfigPolicy + expectedSecretSources []commonv1.NamespacedSecretSource + expectedPolicyRefs map[string]struct{} + expectedMergeConflict bool }{ { name: "merges Kibana configs without overwrites", @@ -811,19 +809,21 @@ func Test_getPolicyConfigForKibana(t *testing.T) { "maxPayload": float64(2097152), }, }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "kb-secret1", - Entries: []commonv1.KeyToPath{{Key: "key1", Path: "path1"}}, - }, - { - SecretName: "kb-secret2", - Entries: []commonv1.KeyToPath{{Key: "key2", Path: "path2"}}, - }, - }, }, }, }, + expectedSecretSources: []commonv1.NamespacedSecretSource{ + { + SecretName: "kb-secret1", + Namespace: "test", + Entries: []commonv1.KeyToPath{{Key: "key1", Path: "path1"}}, + }, + { + SecretName: "kb-secret2", + Namespace: "test", + Entries: []commonv1.KeyToPath{{Key: "key2", Path: "path2"}}, + }, + }, expectedPolicyRefs: map[string]struct{}{ "test/kb-policy1": {}, "test/kb-policy2": {}, @@ -951,10 +951,7 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - expectedConflictErrors: map[types.NamespacedName]bool{ - {Namespace: "test", Name: "kb-conflict-1"}: true, - {Namespace: "test", Name: "kb-conflict-2"}: true, - }, + expectedMergeConflict: true, }, { name: "Kibana policy doesn't match due to namespace", @@ -1088,31 +1085,29 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - kbPolicyConfig, err := getPolicyConfigForKibana(tc.targetKibana, tc.stackConfigPolicies, operator.Parameters{ + kbPolicyConfig, err := getConfigPolicyForKibana(tc.targetKibana, tc.stackConfigPolicies, operator.Parameters{ OperatorNamespace: tc.operatorNamespace, }) // Verify conflict errors - if tc.expectedConflictErrors != nil { + if tc.expectedMergeConflict { assert.ErrorIs(t, err, errMergeConflict) - assert.NotNil(t, kbPolicyConfig.PoliciesWithConflictErrors, "Expected conflict errors but got none") - for policyNsn, shouldHaveError := range tc.expectedConflictErrors { - if shouldHaveError { - assert.Containsf(t, kbPolicyConfig.PoliciesWithConflictErrors, policyNsn, "Expected conflict error for policy %s but found none", policyNsn) - } else { - assert.NotContainsf(t, kbPolicyConfig.PoliciesWithConflictErrors, policyNsn, "Not expected conflict error for policy %s but found none", policyNsn) - } - } return } assert.NoError(t, err) - assert.Empty(t, kbPolicyConfig.PoliciesWithConflictErrors, "Expected no conflict errors") - // Compare the merged Kibana configuration + // Compare secret sources if expected + if tc.expectedSecretSources != nil { + assert.EqualValues(t, tc.expectedSecretSources, kbPolicyConfig.SecretSources) + } + + if len(tc.stackConfigPolicies) > 1 { + canonicaliseKibanaPolicyConfig(t, &tc.expectedConfigPolicy.Spec.Kibana) + } assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Kibana, kbPolicyConfig.Spec) // Compare policy references actualPolicyRefs := make(map[string]struct{}) - for _, policy := range kbPolicyConfig.PoliciesRefs { + for _, policy := range kbPolicyConfig.PolicyRefs { nsn := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} actualPolicyRefs[nsn.String()] = struct{}{} } @@ -1120,3 +1115,33 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }) } } + +func canonicaliseElasticsearchPolicyConfig(t *testing.T, spec *policyv1alpha1.ElasticsearchConfigPolicySpec) { + t.Helper() + var err error + spec.ClusterSettings, err = deepMergeConfig(spec.ClusterSettings, spec.ClusterSettings) + assert.NoError(t, err) + spec.SnapshotRepositories, err = mergeConfig(spec.SnapshotRepositories, spec.SnapshotRepositories) + assert.NoError(t, err) + spec.SnapshotLifecyclePolicies, err = deepMergeConfig(spec.SnapshotLifecyclePolicies, spec.SnapshotLifecyclePolicies) + assert.NoError(t, err) + spec.SecurityRoleMappings, err = deepMergeConfig(spec.SecurityRoleMappings, spec.SecurityRoleMappings) + assert.NoError(t, err) + spec.IndexLifecyclePolicies, err = deepMergeConfig(spec.IndexLifecyclePolicies, spec.IndexLifecyclePolicies) + assert.NoError(t, err) + spec.IngestPipelines, err = deepMergeConfig(spec.IngestPipelines, spec.IngestPipelines) + assert.NoError(t, err) + spec.IndexTemplates.ComposableIndexTemplates, err = deepMergeConfig(spec.IndexTemplates.ComposableIndexTemplates, spec.IndexTemplates.ComposableIndexTemplates) + assert.NoError(t, err) + spec.IndexTemplates.ComponentTemplates, err = deepMergeConfig(spec.IndexTemplates.ComponentTemplates, spec.IndexTemplates.ComposableIndexTemplates) + assert.NoError(t, err) + spec.Config, err = deepMergeConfig(spec.Config, spec.Config) + assert.NoError(t, err) +} + +func canonicaliseKibanaPolicyConfig(t *testing.T, spec *policyv1alpha1.KibanaConfigPolicySpec) { + t.Helper() + var err error + spec.Config, err = deepMergeConfig(spec.Config, spec.Config) + assert.NoError(t, err) +} From 994a33ed797d4bc3a95f3837750438fca20aad7c Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Tue, 25 Nov 2025 17:15:48 +0200 Subject: [PATCH 05/19] feat: add scp weight as printable column --- config/crds/v1/all-crds.yaml | 3 +++ .../stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml | 3 +++ .../charts/eck-operator-crds/templates/all-crds.yaml | 3 +++ pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go | 1 + 4 files changed, 10 insertions(+) diff --git a/config/crds/v1/all-crds.yaml b/config/crds/v1/all-crds.yaml index 68eec1ce4cc..4a7bc3e2a5a 100644 --- a/config/crds/v1/all-crds.yaml +++ b/config/crds/v1/all-crds.yaml @@ -10673,6 +10673,9 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .spec.weight + name: Weight + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml b/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml index 0f7bb05580e..df6bb09927c 100644 --- a/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml +++ b/config/crds/v1/resources/stackconfigpolicy.k8s.elastic.co_stackconfigpolicies.yaml @@ -29,6 +29,9 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .spec.weight + name: Weight + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml b/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml index 243f40ab837..67fe5100fd5 100644 --- a/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml +++ b/deploy/eck-operator/charts/eck-operator-crds/templates/all-crds.yaml @@ -10743,6 +10743,9 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .spec.weight + name: Weight + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go index 34d38769bfb..113def3bb92 100644 --- a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go +++ b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go @@ -34,6 +34,7 @@ func init() { // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.readyCount",description="Resources configured" // +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Weight",type="integer",JSONPath=".spec.weight" // +kubebuilder:subresource:status // +kubebuilder:storageversion type StackConfigPolicy struct { From 4004fc7d5571faba5c8c4e75abdbd07ae504add7 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 12:13:13 +0200 Subject: [PATCH 06/19] fix: reduce the scope of err vars --- .../stackconfigpolicy/controller.go | 16 ++++++--------- .../stackconfigpolicy/stackconfigpolicy.go | 20 ++++++++----------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index 893689d1485..ab8a5b13bb3 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -136,8 +136,7 @@ func reconcileRequestForSoftOwnerPolicy() handler.TypedEventHandler[*corev1.Secr func reconcileRequestForAllPolicies(clnt k8s.Client) handler.TypedEventHandler[client.Object, reconcile.Request] { return handler.TypedEnqueueRequestsFromMapFunc[client.Object](func(ctx context.Context, es client.Object) []reconcile.Request { var stackConfigList policyv1alpha1.StackConfigPolicyList - err := clnt.List(context.Background(), &stackConfigList) - if err != nil { + if err := clnt.List(context.Background(), &stackConfigList); err != nil { ulog.Log.Error(err, "Fail to list StackConfigurationList while watching Elasticsearch") return nil } @@ -173,8 +172,7 @@ func (r *ReconcileStackConfigPolicy) Reconcile(ctx context.Context, request reco // retrieve the StackConfigPolicy resource var policy policyv1alpha1.StackConfigPolicy - err := r.Client.Get(ctx, request.NamespacedName, &policy) - if err != nil { + if err := r.Client.Get(ctx, request.NamespacedName, &policy); err != nil { if apierrors.IsNotFound(err) { return reconcile.Result{}, r.onDelete(ctx, types.NamespacedName{ @@ -352,8 +350,8 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context if err != nil { return results.WithError(err), status } - err = setMultipleSoftOwners(&expectedSecret, esConfigPolicyFinal.PolicyRefs) - if err != nil { + + if err = setMultipleSoftOwners(&expectedSecret, esConfigPolicyFinal.PolicyRefs); err != nil { return results.WithError(err), status } @@ -588,8 +586,7 @@ func handleOrphanSoftOwnedSecrets( configuredKibanaResources kbMap, resourceType policyv1alpha1.ResourceType, ) error { - err := resetOrphanSoftOwnedFileSettingSecrets(ctx, c, softOwner, configuredESResources, resourceType) - if err != nil { + if err := resetOrphanSoftOwnedFileSettingSecrets(ctx, c, softOwner, configuredESResources, resourceType); err != nil { return err } return deleteOrphanSoftOwnedSecrets(ctx, c, softOwner, configuredESResources, configuredKibanaResources, resourceType) @@ -646,8 +643,7 @@ func resetOrphanSoftOwnedFileSettingSecrets( } var es esv1.Elasticsearch - err := c.Get(ctx, namespacedName, &es) - if err != nil && !apierrors.IsNotFound(err) { + if err := c.Get(ctx, namespacedName, &es); err != nil && !apierrors.IsNotFound(err) { return err } if apierrors.IsNotFound(err) { diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 04612a283ae..044543c879f 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -130,8 +130,8 @@ func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []polic return nil }, } - err := merge(cfgPolicy, es, allPolicies, params.OperatorNamespace) - if err != nil { + + if err := merge(cfgPolicy, es, allPolicies, params.OperatorNamespace); err != nil { return cfgPolicy, err } cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources @@ -201,8 +201,8 @@ func getConfigPolicyForKibana(kbn *kbv1.Kibana, allPolicies []policyv1alpha1.Sta return nil }, } - err := merge(cfgPolicy, kbn, allPolicies, params.OperatorNamespace) - if err != nil { + + if err := merge(cfgPolicy, kbn, allPolicies, params.OperatorNamespace); err != nil { return cfgPolicy, err } cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources @@ -260,14 +260,12 @@ func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Conf return nil, err } - err = dstCanonicalConfig.MergeWith(srcCanonicalConfig) - if err != nil { + if err = dstCanonicalConfig.MergeWith(srcCanonicalConfig); err != nil { return nil, err } dst.Data = nil - err = dstCanonicalConfig.Unpack(&dst.Data) - if err != nil { + if err = dstCanonicalConfig.Unpack(&dst.Data); err != nil { return nil, err } @@ -302,14 +300,12 @@ func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, } dst.Data = nil - err = dstCanonicalConfig.Unpack(&dst.Data) - if err != nil { + if err = dstCanonicalConfig.Unpack(&dst.Data); err != nil { return nil, err } srcCfg := &commonv1.Config{} - err = srcCanonicalConfig.Unpack(&srcCfg.Data) - if err != nil { + if err = srcCanonicalConfig.Unpack(&srcCfg.Data); err != nil { return nil, err } From 5e03e79b793c84f166c4b60e2417cf3d1cdbace3 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 12:17:14 +0200 Subject: [PATCH 07/19] fix: improve code readability in mergeElasticsearchSpecs func --- .../stackconfigpolicy/stackconfigpolicy.go | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 044543c879f..d0f3c67378c 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -141,32 +141,26 @@ func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []polic // mergeElasticsearchSpecs merges src policyv1alpha1.ElasticsearchConfigPolicySpec into dst. func mergeElasticsearchSpecs(dst, src *policyv1alpha1.ElasticsearchConfigPolicySpec) error { var err error - if dst.ClusterSettings, err = deepMergeConfig(dst.ClusterSettings, src.ClusterSettings); err != nil { - return err - } - if dst.SnapshotRepositories, err = mergeConfig(dst.SnapshotRepositories, src.SnapshotRepositories); err != nil { - return err - } - if dst.SnapshotLifecyclePolicies, err = deepMergeConfig(dst.SnapshotLifecyclePolicies, src.SnapshotLifecyclePolicies); err != nil { - return err - } - if dst.SecurityRoleMappings, err = deepMergeConfig(dst.SecurityRoleMappings, src.SecurityRoleMappings); err != nil { - return err - } - if dst.IndexLifecyclePolicies, err = deepMergeConfig(dst.IndexLifecyclePolicies, src.IndexLifecyclePolicies); err != nil { - return err - } - if dst.IngestPipelines, err = deepMergeConfig(dst.IngestPipelines, src.IngestPipelines); err != nil { - return err - } - if dst.IndexTemplates.ComposableIndexTemplates, err = deepMergeConfig(dst.IndexTemplates.ComposableIndexTemplates, src.IndexTemplates.ComposableIndexTemplates); err != nil { - return err - } - if dst.IndexTemplates.ComponentTemplates, err = deepMergeConfig(dst.IndexTemplates.ComponentTemplates, src.IndexTemplates.ComponentTemplates); err != nil { - return err - } - if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { - return err + fields := []struct { + dst **commonv1.Config + src *commonv1.Config + merge func(*commonv1.Config, *commonv1.Config) (*commonv1.Config, error) + }{ + {&dst.ClusterSettings, src.ClusterSettings, deepMergeConfig}, + {&dst.SnapshotRepositories, src.SnapshotRepositories, mergeConfig}, + {&dst.SnapshotLifecyclePolicies, src.SnapshotLifecyclePolicies, deepMergeConfig}, + {&dst.SecurityRoleMappings, src.SecurityRoleMappings, deepMergeConfig}, + {&dst.IndexLifecyclePolicies, src.IndexLifecyclePolicies, deepMergeConfig}, + {&dst.IngestPipelines, src.IngestPipelines, deepMergeConfig}, + {&dst.IndexTemplates.ComposableIndexTemplates, src.IndexTemplates.ComposableIndexTemplates, deepMergeConfig}, + {&dst.IndexTemplates.ComponentTemplates, src.IndexTemplates.ComponentTemplates, deepMergeConfig}, + {&dst.Config, src.Config, deepMergeConfig}, + } + for _, f := range fields { + *f.dst, err = f.merge(*f.dst, f.src) + if err != nil { + return err + } } return nil } From 0b83045dce199842231d4292dd071eec97f78975 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 13:08:39 +0200 Subject: [PATCH 08/19] fix: change SoftOwnerRefsAnnotation annotation value from map to list --- pkg/controller/common/reconciler/secret.go | 10 ++--- .../kibana_config_settings_test.go | 4 +- pkg/controller/stackconfigpolicy/ownership.go | 38 +++++++++---------- .../stackconfigpolicy/ownership_test.go | 28 +++++++------- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/pkg/controller/common/reconciler/secret.go b/pkg/controller/common/reconciler/secret.go index 979dd8f3e0c..e4f2774c40e 100644 --- a/pkg/controller/common/reconciler/secret.go +++ b/pkg/controller/common/reconciler/secret.go @@ -106,15 +106,15 @@ func SoftOwnerRefs(obj metav1.Object) ([]SoftOwnerRef, error) { // Check for multi-policy ownership (annotation-based) if ownerRefsBytes, exists := obj.GetAnnotations()[SoftOwnerRefsAnnotation]; exists { - // Multi-policy soft owned secret - parse the JSON map of owners - var ownerRefs map[string]struct{} - if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + // Multi-policy soft owned secret - parse the list of owners + var ownerRefsSlice []string + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { return nil, err } - // Convert the map keys (namespaced name strings) back to NamespacedName objects + // Convert the list to []SoftOwnerRef var ownerRefsNsn []SoftOwnerRef - for nsnStr := range ownerRefs { + for _, nsnStr := range ownerRefsSlice { // Split the string format "namespace/name" into components nsnComponents := strings.Split(nsnStr, string(types.Separator)) if len(nsnComponents) != 2 { diff --git a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go index 015a054ea1c..21a16783b64 100644 --- a/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go +++ b/pkg/controller/stackconfigpolicy/kibana_config_settings_test.go @@ -71,7 +71,7 @@ func Test_newKibanaConfigSecret(t *testing.T) { Annotations: map[string]string{ "policy.k8s.elastic.co/kibana-config-hash": "3077592849", "policy.k8s.elastic.co/secure-settings-secrets": `[{"namespace":"test-policy-ns","secretName":"shared-secret"}]`, - "eck.k8s.elastic.co/owner-refs": `{"test-policy-ns/test-policy":{}}`, + "eck.k8s.elastic.co/owner-refs": `["test-policy-ns/test-policy"]`, }, }, Data: map[string][]byte{ @@ -228,7 +228,7 @@ func MkKibanaConfigSecret(namespace string, owningPolicyName string, owningPolic }, Annotations: map[string]string{ "policy.k8s.elastic.co/kibana-config-hash": hashValue, - "eck.k8s.elastic.co/owner-refs": `{"` + owningPolicyNamespace + `/` + owningPolicyName + `":{}}`, + "eck.k8s.elastic.co/owner-refs": `["` + owningPolicyNamespace + `/` + owningPolicyName + `"]`, }, }, Data: map[string][]byte{ diff --git a/pkg/controller/stackconfigpolicy/ownership.go b/pkg/controller/stackconfigpolicy/ownership.go index 169e7aff6af..60a56468d52 100644 --- a/pkg/controller/stackconfigpolicy/ownership.go +++ b/pkg/controller/stackconfigpolicy/ownership.go @@ -9,6 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" @@ -62,15 +63,13 @@ func setMultipleSoftOwners(secret *corev1.Secret, policies []policyv1alpha1.Stac secret.Annotations = map[string]string{} } - // Build a map of owner references using namespaced names as keys. - // We use struct{} as values since we only care about the keys (acts as a set). - ownerRefs := make(map[string]struct{}) + // Build a set of owner references using namespaced names as keys. + ownerRefs := sets.Set[string]{} for _, p := range policies { - ownerRefs[k8s.ExtractNamespacedName(&p).String()] = struct{}{} + ownerRefs.Insert(k8s.ExtractNamespacedName(&p).String()) } - // Store the owner references as a JSON-encoded annotation - ownerRefsBytes, err := json.Marshal(ownerRefs) + ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) if err != nil { return err } @@ -98,13 +97,13 @@ func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (b // Check for multi-policy ownership (annotation-based) if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { // Multi-policy soft owned secret - parse the JSON map of owners - var ownerRefs map[string]struct{} - if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + var ownerRefsSlice []string + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { return false, err } - // Check if the given policy is in the set of owners - _, exists := ownerRefs[types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()] - return exists, nil + + ownerRefs := sets.New(ownerRefsSlice...) + return ownerRefs.Has(types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()), nil } // Fall back to single-policy ownership (label-based) @@ -138,22 +137,23 @@ func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName // Check for multi-policy ownership (annotation-based) if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { - // Multi-policy soft owned secret - parse and update the owner map - var ownerRefs map[string]struct{} - if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefs); err != nil { + // Multi-policy soft owned secret - parse and update the set + var ownerRefsSlice []string + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { return 0, err } - // Remove the specified policy from the owner map - delete(ownerRefs, types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()) - if len(ownerRefs) == 0 { + ownerRefs := sets.New(ownerRefsSlice...) + // Remove the specified policy from the set + ownerRefs.Delete(types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()) + if ownerRefs.Len() == 0 { // No owners remain, remove the annotation delete(secret.Annotations, reconciler.SoftOwnerRefsAnnotation) return 0, nil } - // Marshal the updated owner map back to JSON - ownerRefsBytes, err := json.Marshal(ownerRefs) + // Marshal the updated owner list back to JSON + ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) if err != nil { return 0, err } diff --git a/pkg/controller/stackconfigpolicy/ownership_test.go b/pkg/controller/stackconfigpolicy/ownership_test.go index 28e9c7d4f6b..109f98c92ab 100644 --- a/pkg/controller/stackconfigpolicy/ownership_test.go +++ b/pkg/controller/stackconfigpolicy/ownership_test.go @@ -151,12 +151,12 @@ func Test_setMultipleSoftOwners(t *testing.T) { ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] assert.NotEmpty(t, ownerRefsJSON) - var ownerRefs map[string]struct{} + var ownerRefs []string err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) require.NoError(t, err) - assert.EqualValues(t, map[string]struct{}{ - types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}.String(): {}, - types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}.String(): {}, + assert.EqualValues(t, []string{ + types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}.String(), + types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}.String(), }, ownerRefs) }, }, @@ -206,7 +206,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, + reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, }, }, }, @@ -219,15 +219,15 @@ func Test_removePolicySoftOwner(t *testing.T) { ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] assert.NotEmpty(t, ownerRefsJSON) - var ownerRefs map[string]struct{} + var ownerRefs []string err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) require.NoError(t, err) assert.Len(t, ownerRefs, 2) // Verify policy-2 was removed - assert.EqualValues(t, map[string]struct{}{ - "namespace-1/policy-1": {}, - "namespace-3/policy-3": {}, + assert.EqualValues(t, []string{ + "namespace-1/policy-1", + "namespace-3/policy-3", }, ownerRefs) }, }, @@ -241,7 +241,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{}}`, + reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1"]`, "other-annotation": "preserved", }, }, @@ -365,7 +365,7 @@ func Test_removePolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, }, }, }, @@ -375,7 +375,7 @@ func Test_removePolicySoftOwner(t *testing.T) { assert.Equal(t, 2, remainingCount) // Verify both original policies remain - var ownerRefs map[string]struct{} + var ownerRefs []string err = json.Unmarshal([]byte(secret.Annotations[reconciler.SoftOwnerRefsAnnotation]), &ownerRefs) require.NoError(t, err) assert.Len(t, ownerRefs, 2) @@ -409,7 +409,7 @@ func Test_isPolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, }, }, }, @@ -429,7 +429,7 @@ func Test_isPolicySoftOwner(t *testing.T) { reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, }, }, }, From 5c216cc080eb158a75619ae5183ee930800ab716 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 13:18:48 +0200 Subject: [PATCH 09/19] doc: add comment for file-settings secret soft owners --- pkg/controller/stackconfigpolicy/controller.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index ab8a5b13bb3..c78ef27f35b 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -351,7 +351,9 @@ func (r *ReconcileStackConfigPolicy) reconcileElasticsearchResources(ctx context return results.WithError(err), status } - if err = setMultipleSoftOwners(&expectedSecret, esConfigPolicyFinal.PolicyRefs); err != nil { + // We must keep track of the soft owner references of the file-settings secret to ensure that the secret is reconciled + // back to an empty one when no policies are targeting it (look at resetOrphanSoftOwnedFileSettingSecrets) + if err := setMultipleSoftOwners(&expectedSecret, esConfigPolicyFinal.PolicyRefs); err != nil { return results.WithError(err), status } From 48f510acbf43f6962031f4c798998edfb854e552 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 13:26:24 +0200 Subject: [PATCH 10/19] fix: reconciler unit-tests --- pkg/controller/common/reconciler/secret_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/controller/common/reconciler/secret_test.go b/pkg/controller/common/reconciler/secret_test.go index a4c4ad7effb..e7d1bc8f5e8 100644 --- a/pkg/controller/common/reconciler/secret_test.go +++ b/pkg/controller/common/reconciler/secret_test.go @@ -449,10 +449,10 @@ func TestGarbageCollectAllSoftOwnedOrphanSecrets(t *testing.T) { &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-1", Namespace: "namespace-1"}}, &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-2", Namespace: "namespace-2"}}, &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-3", Namespace: "namespace-3"}}, - ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + ownedSecretMultiRefs("ns", "secret-1", `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, "StackConfigPolicy"), }, wantObjs: []client.Object{ - ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + ownedSecretMultiRefs("ns", "secret-1", `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, "StackConfigPolicy"), }, }, { @@ -461,16 +461,16 @@ func TestGarbageCollectAllSoftOwnedOrphanSecrets(t *testing.T) { &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-1", Namespace: "namespace-1"}}, &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-2", Namespace: "namespace-other"}}, &policyv1alpha1.StackConfigPolicy{ObjectMeta: metav1.ObjectMeta{Name: "policy-3", Namespace: "namespace-3"}}, - ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + ownedSecretMultiRefs("ns", "secret-1", `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, "StackConfigPolicy"), }, wantObjs: []client.Object{ - ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + ownedSecretMultiRefs("ns", "secret-1", `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, "StackConfigPolicy"), }, }, { name: "secret with multiple soft-owners that none exists", runtimeObjs: []client.Object{ - ownedSecretMultiRefs("ns", "secret-1", `{"namespace-1/policy-1":{},"namespace-2/policy-2":{},"namespace-3/policy-3":{}}`, "StackConfigPolicy"), + ownedSecretMultiRefs("ns", "secret-1", `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, "StackConfigPolicy"), }, wantObjs: []client.Object{}, }, @@ -605,7 +605,7 @@ func TestSoftOwnerRefs(t *testing.T) { SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"namespace-2/policy-2":{}}`, + SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, }, }, }, @@ -697,7 +697,7 @@ func TestSoftOwnerRefs(t *testing.T) { SoftOwnerKindLabel: policyv1alpha1.Kind, }, Annotations: map[string]string{ - SoftOwnerRefsAnnotation: `{"namespace-1/policy-1":{},"malformed":{},"too/many/slashes":{}}`, + SoftOwnerRefsAnnotation: `["namespace-1/policy-1","malformed","too/many/slashes"]`, }, }, }, From cfd5cb11a8831d49b9539789a9b0765c7e5b5224 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 13:23:07 +0200 Subject: [PATCH 11/19] fix: rework secure-settings getter funcs --- .../v1alpha1/stackconfigpolicy_types.go | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go index 113def3bb92..d6f5a8fd6a3 100644 --- a/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go +++ b/pkg/apis/stackconfigpolicy/v1alpha1/stackconfigpolicy_types.go @@ -106,21 +106,7 @@ func (p *StackConfigPolicy) GetElasticsearchNamespacedSecureSettings() []commonv if p == nil { return nil } - - ssLen := len(p.Spec.Elasticsearch.SecureSettings) - if ssLen == 0 { - return nil - } - pNs := p.GetNamespace() - ssNsn := make([]commonv1.NamespacedSecretSource, ssLen) - for idx, ss := range p.Spec.Elasticsearch.SecureSettings { - ssNsn[idx] = commonv1.NamespacedSecretSource{ - Namespace: pNs, - SecretName: ss.SecretName, - Entries: ss.Entries, - } - } - return ssNsn + return toNamespacedSecretSources(&p.Spec.Elasticsearch, p.Namespace) } // GetKibanaNamespacedSecureSettings returns the Kibana secure settings from this policy @@ -130,21 +116,34 @@ func (p *StackConfigPolicy) GetKibanaNamespacedSecureSettings() []commonv1.Names if p == nil { return nil } + return toNamespacedSecretSources(&p.Spec.Kibana, p.Namespace) +} - ssLen := len(p.Spec.Kibana.SecureSettings) - if ssLen == 0 { - return nil - } - pNs := p.GetNamespace() - ssNsn := make([]commonv1.NamespacedSecretSource, ssLen) - for idx, ss := range p.Spec.Kibana.SecureSettings { - ssNsn[idx] = commonv1.NamespacedSecretSource{ - Namespace: pNs, - SecretName: ss.SecretName, - Entries: ss.Entries, +// HasSecureSettings represents a ConfigPolicySpec that has secure settings. +// +kubebuilder:object:generate=false +type HasSecureSettings interface { + GetSecureSettings() []commonv1.SecretSource +} + +func toNamespacedSecretSources(hasSecureSettings HasSecureSettings, inNamespace string) []commonv1.NamespacedSecretSource { + secureSettings := hasSecureSettings.GetSecureSettings() + namespacedSecretSources := make([]commonv1.NamespacedSecretSource, len(secureSettings)) + for i, s := range secureSettings { + namespacedSecretSources[i] = commonv1.NamespacedSecretSource{ + Namespace: inNamespace, + SecretName: s.SecretName, + Entries: s.Entries, } } - return ssNsn + return namespacedSecretSources +} + +// GetSecureSettings returns the secure settings of the ElasticsearchConfigPolicySpec. +func (e *ElasticsearchConfigPolicySpec) GetSecureSettings() []commonv1.SecretSource { + if e == nil { + return nil + } + return e.SecureSettings } type KibanaConfigPolicySpec struct { @@ -156,6 +155,14 @@ type KibanaConfigPolicySpec struct { SecureSettings []commonv1.SecretSource `json:"secureSettings,omitempty"` } +// GetSecureSettings returns the secure settings of the KibanaConfigPolicySpec. +func (k *KibanaConfigPolicySpec) GetSecureSettings() []commonv1.SecretSource { + if k == nil { + return nil + } + return k.SecureSettings +} + type ResourceType string const ( From 6c4e5757497a018e4941e6f9c7cfe6577f84529f Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Thu, 27 Nov 2025 13:34:38 +0200 Subject: [PATCH 12/19] fix: update main.md --- docs/reference/api-reference/main.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/api-reference/main.md b/docs/reference/api-reference/main.md index 2cdd423b473..ac31396a12d 100644 --- a/docs/reference/api-reference/main.md +++ b/docs/reference/api-reference/main.md @@ -1991,6 +1991,8 @@ Package v1alpha1 contains API schema definitions for managing StackConfigPolicy | *`secureSettings`* __[SecretSource](#secretsource) array__ | SecureSettings are additional Secrets that contain data to be configured to Elasticsearch's keystore. | + + ### IndexTemplates [#indextemplates] From 3bf7a08b4b93338aff3fa9ec8c490eac68a7afa2 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 11:51:22 +0200 Subject: [PATCH 13/19] fix: rework secret mounts and sources merging --- .../stackconfigpolicy/stackconfigpolicy.go | 151 ++++++++---------- 1 file changed, 63 insertions(+), 88 deletions(-) diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 5ade66f7023..5f5b024867d 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -36,8 +36,9 @@ type configPolicy[T any] struct { Spec T // extractFunc extracts the relevant spec (ES or Kibana) from a StackConfigPolicy extractFunc func(p *policyv1alpha1.StackConfigPolicy) (spec T) - // mergeFunc merges a source spec into the destination spec, handling conflicts - mergeFunc func(dstSpec *T, srcSpec T, srcPolicy *policyv1alpha1.StackConfigPolicy) error + // mergeFunc merges a source spec into the destination configPolicy, handling conflicts and aggregating + // secret sources and mounts. It receives the entire configPolicy to allow updating both Spec and SecretSources. + mergeFunc func(dst *configPolicy[T], srcSpec T, srcPolicy *policyv1alpha1.StackConfigPolicy) error // SecretSources contains aggregated secure settings secret sources, keyed by StackConfigPolicy namespace SecretSources []commonv1.NamespacedSecretSource // PolicyRefs contains references to all policies that targeted and were merged for this object @@ -85,7 +86,7 @@ func merge[T any]( for _, p := range c.PolicyRefs { srcSpec := c.extractFunc(&p) - if err := c.mergeFunc(&c.Spec, srcSpec, &p); err != nil { + if err := c.mergeFunc(c, srcSpec, &p); err != nil { return err } } @@ -98,35 +99,22 @@ func merge[T any]( // in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. // Returns an esPolicyConfig containing the merged configuration and any error occurred during merging. func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec], error) { - mergedPolicies := 0 - sMntAggr := secretMountsAggregator{} - sSrcAggr := secretSourceAggregator{} + secretMountsAggr := secretMountsAggregator{} cfgPolicy := &configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec]{ extractFunc: func(p *policyv1alpha1.StackConfigPolicy) policyv1alpha1.ElasticsearchConfigPolicySpec { return p.Spec.Elasticsearch }, - mergeFunc: func(dst *policyv1alpha1.ElasticsearchConfigPolicySpec, src policyv1alpha1.ElasticsearchConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { + mergeFunc: func(c *configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec], src policyv1alpha1.ElasticsearchConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { var err error - if mergedPolicies == 0 { - // First policy: copy directly without merging/canonicalizing to avoid unnecessary differences - specCopy := src.DeepCopy() - // SecureSettings are aggregated by sSrcAggr - specCopy.SecureSettings = nil - // SecretMounts are aggregated by sMntAggr - specCopy.SecretMounts = nil - *dst = *specCopy - } else { - if err = mergeElasticsearchSpecs(dst, &src); err != nil { - return err - } + if err = mergeElasticsearchSpecs(&c.Spec, &src); err != nil { + return err } - if dst.SecretMounts, err = sMntAggr.aggregate(dst.SecretMounts, srcPolicy.Spec.Elasticsearch.SecretMounts, srcPolicy); err != nil { + if c.Spec.SecretMounts, err = secretMountsAggr.mergeInto(c.Spec.SecretMounts, srcPolicy.Spec.Elasticsearch.SecretMounts, srcPolicy); err != nil { return err } - sSrcAggr.aggregate(srcPolicy.Spec.Elasticsearch.SecureSettings, srcPolicy) - mergedPolicies++ + c.SecretSources = mergeSecretSources(c.SecretSources, srcPolicy.Spec.Elasticsearch.SecureSettings, srcPolicy) return nil }, } @@ -134,7 +122,6 @@ func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []polic if err := merge(cfgPolicy, es, allPolicies, params.OperatorNamespace); err != nil { return cfgPolicy, err } - cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources return cfgPolicy, nil } @@ -170,28 +157,17 @@ func mergeElasticsearchSpecs(dst, src *policyv1alpha1.ElasticsearchConfigPolicyS // in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. // Returns an kbnPolicyConfig containing the merged configuration and any error occurred during merging. func getConfigPolicyForKibana(kbn *kbv1.Kibana, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.KibanaConfigPolicySpec], error) { - mergedPolicies := 0 - sSrcAggr := secretSourceAggregator{} cfgPolicy := &configPolicy[policyv1alpha1.KibanaConfigPolicySpec]{ extractFunc: func(p *policyv1alpha1.StackConfigPolicy) policyv1alpha1.KibanaConfigPolicySpec { return p.Spec.Kibana }, - mergeFunc: func(dst *policyv1alpha1.KibanaConfigPolicySpec, src policyv1alpha1.KibanaConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { - if mergedPolicies == 0 { - // First policy: copy directly without merging/canonicalizing to avoid unnecessary differences - specCopy := src.DeepCopy() - // SecureSettings are aggregated by sSrcAggr - specCopy.SecureSettings = nil - *dst = *specCopy - } else { - var err error - if dst.Config, err = deepMergeConfig(dst.Config, src.Config); err != nil { - return err - } + mergeFunc: func(c *configPolicy[policyv1alpha1.KibanaConfigPolicySpec], src policyv1alpha1.KibanaConfigPolicySpec, srcPolicy *policyv1alpha1.StackConfigPolicy) error { + var err error + if c.Spec.Config, err = deepMergeConfig(c.Spec.Config, src.Config); err != nil { + return err } - sSrcAggr.aggregate(srcPolicy.Spec.Kibana.SecureSettings, srcPolicy) - mergedPolicies++ + c.SecretSources = mergeSecretSources(c.SecretSources, srcPolicy.Spec.Kibana.SecureSettings, srcPolicy) return nil }, } @@ -199,7 +175,6 @@ func getConfigPolicyForKibana(kbn *kbv1.Kibana, allPolicies []policyv1alpha1.Sta if err := merge(cfgPolicy, kbn, allPolicies, params.OperatorNamespace); err != nil { return cfgPolicy, err } - cfgPolicy.SecretSources = sSrcAggr.namespacedSecretSources return cfgPolicy, nil } @@ -235,23 +210,20 @@ func DoesPolicyMatchObject(policy *policyv1alpha1.StackConfigPolicy, obj metav1. // deepMergeConfig merges the source Config into the destination Config using canonical configuration merging. // The merge is performed at the field level, with source values overriding destination values. -// If src is nil, dst is returned unchanged. If dst is nil, it is initialized before merging. +// If src is nil, dst is returned unchanged. If dst is nil, a deep copy of src is returned. // Returns the merged config and any error occurred during config parsing or merging. func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { if src == nil { return dst, nil } - var dstCanonicalConfig *settings.CanonicalConfig - var err error if dst == nil { - dst = &commonv1.Config{} - dstCanonicalConfig = settings.NewCanonicalConfig() - } else { - dstCanonicalConfig, err = settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) - if err != nil { - return nil, err - } + return src.DeepCopy(), nil + } + + dstCanonicalConfig, err := settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) + if err != nil { + return nil, err } srcCanonicalConfig, err := settings.NewCanonicalConfigFrom(src.DeepCopy().Data) @@ -274,23 +246,20 @@ func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Conf // mergeConfig merges the source Config into the destination Config by replacing entire top-level keys. // Unlike deepMergeConfig which performs recursive merging, this function replaces each top-level key // in dst with the corresponding value from src. Both configs are first canonicalized to ensure -// consistent structure. If src is nil, dst is returned unchanged. If dst is nil, it is initialized. +// consistent structure. If src is nil, dst is returned unchanged. If dst is nil, a deep copy of src is returned. // Returns the merged config and any error occurred during config parsing or unpacking. func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { if src == nil { return dst, nil } - var dstCanonicalConfig *settings.CanonicalConfig - var err error if dst == nil { - dst = &commonv1.Config{} - dstCanonicalConfig = settings.NewCanonicalConfig() - } else { - dstCanonicalConfig, err = settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) - if err != nil { - return nil, err - } + return src.DeepCopy(), nil + } + + dstCanonicalConfig, err := settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) + if err != nil { + return nil, err } srcCanonicalConfig, err := settings.NewCanonicalConfigFrom(src.DeepCopy().Data) @@ -319,18 +288,24 @@ func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, type secretMountsAggregator struct { policiesByMountPath map[string]*policyv1alpha1.StackConfigPolicy policiesBySecretName map[string]*policyv1alpha1.StackConfigPolicy - appliedPolicies int } -// aggregate merges source secret mounts into destination, checking for conflicts on secret names -// and mount paths. Returns the merged slice of secret mounts sorted deterministically when +// mergeInto merges source secret mounts into destination, checking for conflicts on secret names +// and mount paths. The function validates that no two policies define the same secret name or +// mount to the same path. Returns the merged slice of secret mounts sorted deterministically when // multiple policies have been applied, or an error if conflicts are detected. -func (s *secretMountsAggregator) aggregate(dst []policyv1alpha1.SecretMount, src []policyv1alpha1.SecretMount, srcPolicy *policyv1alpha1.StackConfigPolicy) ([]policyv1alpha1.SecretMount, error) { - if src == nil { +func (s *secretMountsAggregator) mergeInto( + dst []policyv1alpha1.SecretMount, + src []policyv1alpha1.SecretMount, + srcPolicy *policyv1alpha1.StackConfigPolicy, +) ([]policyv1alpha1.SecretMount, error) { + if len(src) == 0 { return dst, nil } - s.appliedPolicies++ + // if both dst and src are non-empty, we need to sort the merge result to guarantee deterministic order. + // otherwise, we leave the result as it is to avoid undesired differences + shouldSort := len(dst) > 0 if s.policiesBySecretName == nil { s.policiesBySecretName = make(map[string]*policyv1alpha1.StackConfigPolicy) @@ -360,9 +335,7 @@ func (s *secretMountsAggregator) aggregate(dst []policyv1alpha1.SecretMount, src dst = append(dst, secretMount) } - if s.appliedPolicies > 1 { - // we want sort only when we have applied more than one policy to guarantee deterministic order, otherwise - // leave namespacedSecretSources as they came to not cause undesired differences + if shouldSort { slices.SortFunc(dst, func(a, b policyv1alpha1.SecretMount) int { return strings.Compare(a.SecretName, b.SecretName) }) @@ -370,37 +343,39 @@ func (s *secretMountsAggregator) aggregate(dst []policyv1alpha1.SecretMount, src return dst, nil } -// secretSourceAggregator aggregates secure settings secret sources from multiple policies. -// It organizes secret sources by policy namespace and ensures deterministic ordering when -// multiple policies contribute sources. -type secretSourceAggregator struct { - appliedPolicies int - namespacedSecretSources []commonv1.NamespacedSecretSource -} - -// aggregate merges source secure settings into the aggregator, organizing them by the source +// mergeSecretSources merges source secure settings into the destination slice, organizing them by the source // policy's namespace. Secret sources are sorted deterministically when multiple policies have -// been applied to ensure consistent results. -func (s *secretSourceAggregator) aggregate(src []commonv1.SecretSource, srcPolicy *policyv1alpha1.StackConfigPolicy) { - if src == nil { - return +// been applied to ensure consistent results. Returns the updated slice of namespaced secret sources. +func mergeSecretSources( + dst []commonv1.NamespacedSecretSource, + src []commonv1.SecretSource, + srcPolicy *policyv1alpha1.StackConfigPolicy, +) []commonv1.NamespacedSecretSource { + if len(src) == 0 { + return dst } - s.appliedPolicies++ + + // if both dst and src are non-empty, we need to sort the merge result to guarantee deterministic order. + // otherwise, we leave the result as it is to avoid undesired differences + shouldSort := len(dst) > 0 srcPolicyNamespace := srcPolicy.GetNamespace() for _, ss := range src { - s.namespacedSecretSources = append(s.namespacedSecretSources, commonv1.NamespacedSecretSource{ + dst = append(dst, commonv1.NamespacedSecretSource{ Namespace: srcPolicyNamespace, SecretName: ss.SecretName, Entries: ss.Entries, }) } - if s.appliedPolicies > 1 { - // we want sort only when we have applied more than one policy to guarantee deterministic order, otherwise - // leave namespacedSecretSources as they came to not cause undesired differences - slices.SortFunc(s.namespacedSecretSources, func(a, b commonv1.NamespacedSecretSource) int { + if shouldSort { + slices.SortFunc(dst, func(a, b commonv1.NamespacedSecretSource) int { + if nsComp := strings.Compare(a.Namespace, b.Namespace); nsComp != 0 { + return nsComp + } return strings.Compare(a.SecretName, b.SecretName) }) } + + return dst } From 799744bff2d4494e1fc4dcdfae25bc364ed92325 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 11:51:22 +0200 Subject: [PATCH 14/19] fix: rework config merging --- .../elasticsearch_config_settings.go | 2 +- .../stackconfigpolicy/stackconfigpolicy.go | 60 +- .../stackconfigpolicy_test.go | 725 ++++++++++++------ 3 files changed, 519 insertions(+), 268 deletions(-) diff --git a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go index cee3455ab4b..8b64b8ed820 100644 --- a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go +++ b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go @@ -122,7 +122,7 @@ func getElasticsearchConfigAndMountsHash(elasticsearchConfig *commonv1.Config, s return hash.HashObject(secretMounts) } -// elasticsearchConfigAndSecretMountsApplied checks if the Elasticsearch config and secret mounts from the stack config policy have been applied to the Elasticsearch cluster. +// elasticsearchConfigAndSecretMountsApplied checks if the Elasticsearch config and secret mounts have been applied to the Elasticsearch cluster. func elasticsearchConfigAndSecretMountsApplied(ctx context.Context, c k8s.Client, esConfigPolicy policyv1alpha1.ElasticsearchConfigPolicySpec, es esv1.Elasticsearch) (bool, error) { // Get Pods for the given Elasticsearch podList := corev1.PodList{} diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 5f5b024867d..50eae7846c9 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -96,8 +96,9 @@ func merge[T any]( // getConfigPolicyForElasticsearch builds a merged stack config policy for the given Elasticsearch cluster. // It processes all provided policies, filtering those that target the Elasticsearch cluster, and merges them -// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. -// Returns an esPolicyConfig containing the merged configuration and any error occurred during merging. +// in order of their weight (highest to lowest), with lower weight values taking precedence as they are +// merged last. Policies with the same weight are flagged as conflicts. +// Returns a configPolicy containing the merged configuration and any error occurred during merging. func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec], error) { secretMountsAggr := secretMountsAggregator{} cfgPolicy := &configPolicy[policyv1alpha1.ElasticsearchConfigPolicySpec]{ @@ -133,15 +134,16 @@ func mergeElasticsearchSpecs(dst, src *policyv1alpha1.ElasticsearchConfigPolicyS src *commonv1.Config merge func(*commonv1.Config, *commonv1.Config) (*commonv1.Config, error) }{ + // canonicalise and deep merging is supported only for Config and ClusterSettings {&dst.ClusterSettings, src.ClusterSettings, deepMergeConfig}, - {&dst.SnapshotRepositories, src.SnapshotRepositories, mergeConfig}, - {&dst.SnapshotLifecyclePolicies, src.SnapshotLifecyclePolicies, deepMergeConfig}, - {&dst.SecurityRoleMappings, src.SecurityRoleMappings, deepMergeConfig}, - {&dst.IndexLifecyclePolicies, src.IndexLifecyclePolicies, deepMergeConfig}, - {&dst.IngestPipelines, src.IngestPipelines, deepMergeConfig}, - {&dst.IndexTemplates.ComposableIndexTemplates, src.IndexTemplates.ComposableIndexTemplates, deepMergeConfig}, - {&dst.IndexTemplates.ComponentTemplates, src.IndexTemplates.ComponentTemplates, deepMergeConfig}, {&dst.Config, src.Config, deepMergeConfig}, + {&dst.SnapshotRepositories, src.SnapshotRepositories, mergeConfig}, + {&dst.SnapshotLifecyclePolicies, src.SnapshotLifecyclePolicies, mergeConfig}, + {&dst.SecurityRoleMappings, src.SecurityRoleMappings, mergeConfig}, + {&dst.IndexLifecyclePolicies, src.IndexLifecyclePolicies, mergeConfig}, + {&dst.IngestPipelines, src.IngestPipelines, mergeConfig}, + {&dst.IndexTemplates.ComposableIndexTemplates, src.IndexTemplates.ComposableIndexTemplates, mergeConfig}, + {&dst.IndexTemplates.ComponentTemplates, src.IndexTemplates.ComponentTemplates, mergeConfig}, } for _, f := range fields { *f.dst, err = f.merge(*f.dst, f.src) @@ -154,8 +156,9 @@ func mergeElasticsearchSpecs(dst, src *policyv1alpha1.ElasticsearchConfigPolicyS // getConfigPolicyForKibana builds a merged stack config policy for the given Kibana instance. // It processes all provided policies, filtering those that target the Kibana instance, and merges them -// in order of their weight (lowest to highest). Policies with the same weight are flagged as conflicts. -// Returns an kbnPolicyConfig containing the merged configuration and any error occurred during merging. +// in order of their weight (highest to lowest), with lower weight values taking precedence as they are +// merged last. Policies with the same weight are flagged as conflicts. +// Returns a configPolicy containing the merged configuration and any error occurred during merging. func getConfigPolicyForKibana(kbn *kbv1.Kibana, allPolicies []policyv1alpha1.StackConfigPolicy, params operator.Parameters) (*configPolicy[policyv1alpha1.KibanaConfigPolicySpec], error) { cfgPolicy := &configPolicy[policyv1alpha1.KibanaConfigPolicySpec]{ extractFunc: func(p *policyv1alpha1.StackConfigPolicy) policyv1alpha1.KibanaConfigPolicySpec { @@ -213,11 +216,11 @@ func DoesPolicyMatchObject(policy *policyv1alpha1.StackConfigPolicy, obj metav1. // If src is nil, dst is returned unchanged. If dst is nil, a deep copy of src is returned. // Returns the merged config and any error occurred during config parsing or merging. func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { - if src == nil { + if src == nil || len(src.Data) == 0 { return dst, nil } - if dst == nil { + if dst == nil || len(dst.Data) == 0 { return src.DeepCopy(), nil } @@ -245,39 +248,18 @@ func deepMergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Conf // mergeConfig merges the source Config into the destination Config by replacing entire top-level keys. // Unlike deepMergeConfig which performs recursive merging, this function replaces each top-level key -// in dst with the corresponding value from src. Both configs are first canonicalized to ensure -// consistent structure. If src is nil, dst is returned unchanged. If dst is nil, a deep copy of src is returned. -// Returns the merged config and any error occurred during config parsing or unpacking. +// in dst with the corresponding value from src. If src is nil, dst is returned unchanged. If dst is nil, +// a deep copy of src is returned. func mergeConfig(dst *commonv1.Config, src *commonv1.Config) (*commonv1.Config, error) { - if src == nil { + if src == nil || len(src.Data) == 0 { return dst, nil } - if dst == nil { + if dst == nil || len(dst.Data) == 0 { return src.DeepCopy(), nil } - dstCanonicalConfig, err := settings.NewCanonicalConfigFrom(dst.DeepCopy().Data) - if err != nil { - return nil, err - } - - srcCanonicalConfig, err := settings.NewCanonicalConfigFrom(src.DeepCopy().Data) - if err != nil { - return nil, err - } - - dst.Data = nil - if err = dstCanonicalConfig.Unpack(&dst.Data); err != nil { - return nil, err - } - - srcCfg := &commonv1.Config{} - if err = srcCanonicalConfig.Unpack(&srcCfg.Data); err != nil { - return nil, err - } - - maps.Copy(dst.Data, srcCfg.Data) + maps.Copy(dst.Data, src.DeepCopy().Data) return dst, nil } diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go index fb5bf3dc1fb..6d28813b6bc 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -16,12 +17,12 @@ import ( kbv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/settings" ) func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { for _, tc := range []struct { name string - policyNamespace string operatorNamespace string targetElasticsearch *esv1.Elasticsearch stackConfigPolicies []policyv1alpha1.StackConfigPolicy @@ -41,7 +42,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -54,27 +54,72 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { MatchLabels: map[string]string{"test": "test"}, }, Weight: 1, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-deprecated-secure-setting", + }, + }, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test1.name": "policy1", + "policy1.name": "policy1", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test-secret-policy1", - Entries: []commonv1.KeyToPath{ - {Key: "test", Path: "/test-policy1"}, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "type": "gcp", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "schedule": "0 1 2 3 4 ?", + }, + }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "enabled": true, + }, + }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, }, }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "description": "description", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "template": map[string]any{}, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "priority": 500, + }, + }}, }, + Config: &commonv1.Config{Data: map[string]any{ + "node.roles": []any{"policy1"}, + }}, SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy1", MountPath: "/secret-policy1"}, + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, }, - SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-backups.type": "fs", - "policy-backups": map[string]any{ - "settings.location": "/backups", + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-secure-setting", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, }, - }}, + }, }, }, }, @@ -88,30 +133,73 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { ResourceSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"test": "test"}, }, - Weight: -1, + Weight: 10, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy2-deprecated-secure-setting", + }, + }, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test2.name": "policy2", + "policy2.name": "policy2", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test-secret-policy2", - Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "type": "gcp", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "schedule": "0 1 2 3 4 ?", + }, + }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "enabled": true, + }, + }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, }, }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "description": "description", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "template": map[string]any{}, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy2": map[string]any{ + "priority": 500, + }, + }}, }, + Config: &commonv1.Config{Data: map[string]any{ + "node.roles": []any{"policy2"}, + }}, SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy2", MountPath: "/secret-policy2"}, + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, }, - SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-2-backups.type": "s3", - "policy-2-backups.settings": map[string]any{ - "bucket": "policy-2-backups", - "region": "us-west-2", + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy2-secure-setting", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy2-mount-path"}, + }, }, - }}, + }, }, }, }, @@ -119,42 +207,111 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ Spec: policyv1alpha1.StackConfigPolicySpec{ Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ - ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test1.name": "policy1", - "test2.name": "policy2", - }}, - SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy1", MountPath: "/secret-policy1"}, - {SecretName: "secret-policy2", MountPath: "/secret-policy2"}, - }, + ClusterSettings: &commonv1.Config{Data: canonicaliseMap(t, map[string]any{ + "policy1.name": "policy1", + "policy2.name": "policy2", + })}, SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-2-backups.type": "s3", - "policy-2-backups.settings": map[string]any{ - "bucket": "policy-2-backups", - "region": "us-west-2", + "policy1": map[string]any{ + "type": "gcp", + }, + "policy2": map[string]any{ + "type": "gcp", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "schedule": "0 1 2 3 4 ?", }, - "policy-backups.type": "fs", - "policy-backups": map[string]any{ - "settings.location": "/backups", + "policy2": map[string]any{ + "schedule": "0 1 2 3 4 ?", }, }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "enabled": true, + }, + "policy2": map[string]any{ + "enabled": true, + }, + }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, + }, + }, + "policy2": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, + }, + }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "description": "description", + }, + "policy2": map[string]any{ + "description": "description", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "template": map[string]any{}, + }, + "policy2": map[string]any{ + "template": map[string]any{}, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy1": map[string]any{ + "priority": float64(500), + }, + "policy2": map[string]any{ + "priority": float64(500), + }, + }}, + }, + Config: &commonv1.Config{Data: canonicaliseMap(t, map[string]any{ + "node.roles": []any{"policy2", "policy1"}, + })}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, + }, }, }, }, expectedSecretSources: []commonv1.NamespacedSecretSource{ { - SecretName: "test-secret-policy1", + SecretName: "policy1-deprecated-secure-setting", + Namespace: "test", + }, + { + SecretName: "policy1-secure-setting", Namespace: "test", Entries: []commonv1.KeyToPath{ - {Key: "test", Path: "/test-policy1"}, + {Key: "test", Path: "/policy1-mount-path"}, }, }, { - SecretName: "test-secret-policy2", + SecretName: "policy2-deprecated-secure-setting", + Namespace: "test", + }, + { + SecretName: "policy2-secure-setting", Namespace: "test", Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, + {Key: "test", Path: "/policy2-mount-path"}, }, }, }, @@ -173,7 +330,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -186,28 +342,72 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { MatchLabels: map[string]string{"test": "test"}, }, Weight: 1, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-deprecated-secure-setting", + }, + }, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test.name": "policy1", + "policy.name": "policy1", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test", - Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy1"}, - {Key: "test2", Path: "/test2-policy1"}, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "type": "gcp", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "schedule": "0 1 2 3 4 ?", + }, + }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "enabled": true, + }, + }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, }, }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "description": "policy1", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "template": map[string]any{}, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "priority": 500, + }, + }}, }, + Config: &commonv1.Config{Data: map[string]any{ + "node.store.allow_mmap": false, + }}, SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy-1", MountPath: "/secret-policy1"}, + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, }, - SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-2-backups.type": "fs", - "policy-2-backups.settings": map[string]any{ - "location": "/tmp/location", + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-secure-setting", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, }, - }}, + }, }, }, }, @@ -221,30 +421,75 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { ResourceSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"test": "test"}, }, - Weight: -1, + Weight: 10, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy2-deprecated-secure-setting", + }, + }, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test.name": "policy2", + "policy.name": "policy2", }}, - SecureSettings: []commonv1.SecretSource{ - { - SecretName: "test", - Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, + SnapshotRepositories: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "type": "fs", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "schedule": "5 ?", + }, + }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "enabled": false, + }, + }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": []any{"*"}, + }, + }, }, }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "description": "policy2", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "template": map[string]any{ + "properties": map[string]any{}, + }, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "priority": 300, + }, + }}, }, + Config: &commonv1.Config{Data: map[string]any{ + "node.store.allow_mmap": true, + }}, SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy-2", MountPath: "/secret-policy2"}, + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, }, - SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-2-backups.type": "s3", - "policy-2-backups.settings": map[string]any{ - "bucket": "policy-2-backups", - "region": "us-west-2", + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy2-secure-setting", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy2-mount-path"}, + }, }, - }}, + }, }, }, }, @@ -252,38 +497,83 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ Spec: policyv1alpha1.StackConfigPolicySpec{ Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ - ClusterSettings: &commonv1.Config{Data: map[string]any{ - "test.name": "policy2", - }}, - SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "secret-policy-1", MountPath: "/secret-policy1"}, - {SecretName: "secret-policy-2", MountPath: "/secret-policy2"}, - }, + ClusterSettings: &commonv1.Config{Data: canonicaliseMap(t, map[string]any{ + "policy.name": "policy1", + })}, SnapshotRepositories: &commonv1.Config{Data: map[string]any{ - "policy-2-backups.type": "s3", - "policy-2-backups.settings": map[string]any{ - "bucket": "policy-2-backups", - "region": "us-west-2", + "policy": map[string]any{ + "type": "gcp", + }, + }}, + SnapshotLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "schedule": "0 1 2 3 4 ?", + }, + }}, + SecurityRoleMappings: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "enabled": true, }, }}, + IndexLifecyclePolicies: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "phases": map[string]any{ + "delete": map[string]any{ + "actions": map[string]any{ + "delete": map[string]any{}, + }, + }, + }, + }, + }}, + IngestPipelines: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "description": "policy1", + }, + }}, + IndexTemplates: policyv1alpha1.IndexTemplates{ + ComponentTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "template": map[string]any{}, + }, + }}, + ComposableIndexTemplates: &commonv1.Config{Data: map[string]any{ + "policy": map[string]any{ + "priority": float64(500), + }, + }}, + }, + Config: &commonv1.Config{Data: canonicaliseMap(t, map[string]any{ + "node.store.allow_mmap": false, + })}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, + }, }, }, }, expectedSecretSources: []commonv1.NamespacedSecretSource{ { - SecretName: "test", + SecretName: "policy1-deprecated-secure-setting", + Namespace: "test", + }, + { + SecretName: "policy1-secure-setting", Namespace: "test", Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy1"}, - {Key: "test2", Path: "/test2-policy1"}, + {Key: "test", Path: "/policy1-mount-path"}, }, }, { - SecretName: "test", + SecretName: "policy2-deprecated-secure-setting", + Namespace: "test", + }, + { + SecretName: "policy2-secure-setting", Namespace: "test", Entries: []commonv1.KeyToPath{ - {Key: "test1", Path: "/test1-policy2"}, - {Key: "test2", Path: "/test2-policy2"}, + {Key: "test", Path: "/policy2-mount-path"}, }, }, }, @@ -291,9 +581,8 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { "test/policy1": {}, "test/policy2": {}, }, - }, - { - name: "detects policies weight conflicts", + }, { + name: "no changes for single policy", targetElasticsearch: &esv1.Elasticsearch{ ObjectMeta: metav1.ObjectMeta{ Name: "test", @@ -303,9 +592,7 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ - // Policy with unique weight - should be merged { ObjectMeta: metav1.ObjectMeta{ Namespace: "test", @@ -317,36 +604,123 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { MatchLabels: map[string]string{"test": "test"}, }, Weight: 1, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-deprecated-secure-setting-2", + }, + { + SecretName: "policy1-deprecated-secure-setting-1", + }, + }, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "from": "policy1", + "policy.name": "policy1", }}, + Config: &commonv1.Config{Data: map[string]any{ + "node.store.allow_mmap": false, + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, + }, + SecureSettings: []commonv1.SecretSource{ + { + SecretName: "policy1-secure-setting-2", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, + }, + { + SecretName: "policy1-secure-setting-1", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, + }, + }, }, }, }, - // Two policies with the same weight - should conflict and be skipped + }, + expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ + Spec: policyv1alpha1.StackConfigPolicySpec{ + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + ClusterSettings: &commonv1.Config{Data: map[string]any{ + "policy.name": "policy1", + }}, + Config: &commonv1.Config{Data: map[string]any{ + "node.store.allow_mmap": false, + }}, + SecretMounts: []policyv1alpha1.SecretMount{ + {SecretName: "policy2-secret-mount", MountPath: "/policy2-mount-path"}, + {SecretName: "policy1-secret-mount", MountPath: "/policy1-mount-path"}, + }, + }, + }, + }, + expectedSecretSources: []commonv1.NamespacedSecretSource{ + { + SecretName: "policy1-deprecated-secure-setting-2", + Namespace: "test", + }, + { + SecretName: "policy1-deprecated-secure-setting-1", + Namespace: "test", + }, + { + SecretName: "policy1-secure-setting-2", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, + }, + { + SecretName: "policy1-secure-setting-1", + Namespace: "test", + Entries: []commonv1.KeyToPath{ + {Key: "test", Path: "/policy1-mount-path"}, + }, + }, + }, + expectedPolicyRefs: map[string]struct{}{ + "test/policy1": {}, + }, + }, + { + name: "detects policies weight conflicts", + targetElasticsearch: &esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Labels: map[string]string{ + "test": "test", + }, + }, + }, + stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ + // Policy with unique weight - should be merged { ObjectMeta: metav1.ObjectMeta{ Namespace: "test", - Name: "policy2-conflict", + Name: "policy1", ResourceVersion: "1", }, Spec: policyv1alpha1.StackConfigPolicySpec{ ResourceSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"test": "test"}, }, - Weight: 5, + Weight: 1, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "conflict": "policy2", + "from": "policy1", }}, }, }, }, + // Two policies with the same weight - should conflict and be skipped { ObjectMeta: metav1.ObjectMeta{ Namespace: "test", - Name: "policy3-conflict", + Name: "policy2-conflict", ResourceVersion: "1", }, Spec: policyv1alpha1.StackConfigPolicySpec{ @@ -356,26 +730,25 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { Weight: 5, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "conflict": "policy3", + "conflict": "policy2", }}, }, }, }, - // Another policy with unique weight - should be merged { ObjectMeta: metav1.ObjectMeta{ Namespace: "test", - Name: "policy4", + Name: "policy3-conflict", ResourceVersion: "1", }, Spec: policyv1alpha1.StackConfigPolicySpec{ ResourceSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"test": "test"}, }, - Weight: 10, + Weight: 5, Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ ClusterSettings: &commonv1.Config{Data: map[string]any{ - "from": "policy4", + "conflict": "policy3", }}, }, }, @@ -394,7 +767,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ // Policy 1 with lower weight - should be merged first { @@ -454,7 +826,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ // Policy 1 with lower weight - should be merged first { @@ -503,75 +874,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, expectedMergeConflict: true, }, - { - name: "successfully merges when different secrets use different mount paths", - targetElasticsearch: &esv1.Elasticsearch{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", - Labels: map[string]string{ - "test": "test", - }, - }, - }, - policyNamespace: "test", - stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test", - Name: "policy1", - ResourceVersion: "1", - }, - Spec: policyv1alpha1.StackConfigPolicySpec{ - ResourceSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"test": "test"}, - }, - Weight: 1, - Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ - SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "db-creds", MountPath: "/etc/db"}, - {SecretName: "api-keys", MountPath: "/etc/api"}, - }, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test", - Name: "policy2", - ResourceVersion: "1", - }, - Spec: policyv1alpha1.StackConfigPolicySpec{ - ResourceSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"test": "test"}, - }, - Weight: 5, - Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ - SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "tls-cert", MountPath: "/etc/tls"}, - {SecretName: "backup-creds", MountPath: "/etc/backup"}, - }, - }, - }, - }, - }, - expectedConfigPolicy: policyv1alpha1.StackConfigPolicy{ - Spec: policyv1alpha1.StackConfigPolicySpec{ - Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ - SecretMounts: []policyv1alpha1.SecretMount{ - {SecretName: "api-keys", MountPath: "/etc/api"}, - {SecretName: "backup-creds", MountPath: "/etc/backup"}, - {SecretName: "db-creds", MountPath: "/etc/db"}, - {SecretName: "tls-cert", MountPath: "/etc/tls"}, - }, - }, - }, - }, - expectedPolicyRefs: map[string]struct{}{ - "test/policy1": {}, - "test/policy2": {}, - }, - }, { name: "elasticsearch different namespace", operatorNamespace: "operator-namespace", @@ -584,7 +886,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ // Policy in wrong namespace - should not match { @@ -625,7 +926,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ // Policy with non-matching label selector - should not match { @@ -696,9 +996,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { assert.EqualValues(t, tc.expectedSecretSources, esConfigPolicy.SecretSources) } - if len(tc.stackConfigPolicies) > 1 { - canonicaliseElasticsearchPolicyConfig(t, &tc.expectedConfigPolicy.Spec.Elasticsearch) - } assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Elasticsearch, esConfigPolicy.Spec) // Compare policy references by building a map from the actual refs @@ -715,7 +1012,6 @@ func Test_getStackPolicyConfigForElasticsearch(t *testing.T) { func Test_getPolicyConfigForKibana(t *testing.T) { for _, tc := range []struct { name string - policyNamespace string operatorNamespace string targetKibana *kbv1.Kibana stackConfigPolicies []policyv1alpha1.StackConfigPolicy @@ -735,7 +1031,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -830,7 +1125,7 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, { - name: "merges Kibana configs with overwrites - higher weight wins", + name: "merges Kibana configs with overwrites - lower weight wins", targetKibana: &kbv1.Kibana{ ObjectMeta: metav1.ObjectMeta{ Name: "test-kb", @@ -840,7 +1135,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -856,7 +1150,7 @@ func Test_getPolicyConfigForKibana(t *testing.T) { Kibana: policyv1alpha1.KibanaConfigPolicySpec{ Config: &commonv1.Config{Data: map[string]any{ "logging.root.level": "info", - "server.port": uint64(5601), + "server.port": 5601, }}, }, }, @@ -912,7 +1206,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -964,7 +1257,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "dev", operatorNamespace: "elastic-system", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { @@ -1005,7 +1297,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -1022,9 +1313,9 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, Weight: 10, Kibana: policyv1alpha1.KibanaConfigPolicySpec{ - Config: &commonv1.Config{Data: map[string]any{ + Config: &commonv1.Config{Data: canonicaliseMap(t, map[string]any{ "xpack.canvas.enabled": true, - }}, + })}, }, }, }, @@ -1037,7 +1328,7 @@ func Test_getPolicyConfigForKibana(t *testing.T) { expectedPolicyRefs: map[string]struct{}{}, }, { - name: "Single Kibana policy - no merging optimization", + name: "no changes for single policy", targetKibana: &kbv1.Kibana{ ObjectMeta: metav1.ObjectMeta{ Name: "test-kb", @@ -1047,7 +1338,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { }, }, }, - policyNamespace: "test", stackConfigPolicies: []policyv1alpha1.StackConfigPolicy{ { ObjectMeta: metav1.ObjectMeta{ @@ -1100,9 +1390,6 @@ func Test_getPolicyConfigForKibana(t *testing.T) { assert.EqualValues(t, tc.expectedSecretSources, kbPolicyConfig.SecretSources) } - if len(tc.stackConfigPolicies) > 1 { - canonicaliseKibanaPolicyConfig(t, &tc.expectedConfigPolicy.Spec.Kibana) - } assert.EqualValues(t, tc.expectedConfigPolicy.Spec.Kibana, kbPolicyConfig.Spec) // Compare policy references @@ -1116,32 +1403,14 @@ func Test_getPolicyConfigForKibana(t *testing.T) { } } -func canonicaliseElasticsearchPolicyConfig(t *testing.T, spec *policyv1alpha1.ElasticsearchConfigPolicySpec) { +func canonicaliseMap(t *testing.T, src map[string]any) map[string]any { t.Helper() - var err error - spec.ClusterSettings, err = deepMergeConfig(spec.ClusterSettings, spec.ClusterSettings) - assert.NoError(t, err) - spec.SnapshotRepositories, err = mergeConfig(spec.SnapshotRepositories, spec.SnapshotRepositories) - assert.NoError(t, err) - spec.SnapshotLifecyclePolicies, err = deepMergeConfig(spec.SnapshotLifecyclePolicies, spec.SnapshotLifecyclePolicies) - assert.NoError(t, err) - spec.SecurityRoleMappings, err = deepMergeConfig(spec.SecurityRoleMappings, spec.SecurityRoleMappings) - assert.NoError(t, err) - spec.IndexLifecyclePolicies, err = deepMergeConfig(spec.IndexLifecyclePolicies, spec.IndexLifecyclePolicies) - assert.NoError(t, err) - spec.IngestPipelines, err = deepMergeConfig(spec.IngestPipelines, spec.IngestPipelines) - assert.NoError(t, err) - spec.IndexTemplates.ComposableIndexTemplates, err = deepMergeConfig(spec.IndexTemplates.ComposableIndexTemplates, spec.IndexTemplates.ComposableIndexTemplates) - assert.NoError(t, err) - spec.IndexTemplates.ComponentTemplates, err = deepMergeConfig(spec.IndexTemplates.ComponentTemplates, spec.IndexTemplates.ComposableIndexTemplates) - assert.NoError(t, err) - spec.Config, err = deepMergeConfig(spec.Config, spec.Config) - assert.NoError(t, err) -} -func canonicaliseKibanaPolicyConfig(t *testing.T, spec *policyv1alpha1.KibanaConfigPolicySpec) { - t.Helper() - var err error - spec.Config, err = deepMergeConfig(spec.Config, spec.Config) - assert.NoError(t, err) + dstCanonicalConfig, err := settings.NewCanonicalConfigFrom(src) + require.NoError(t, err, "failed to canonicalise map") + + var canonicalisedMap map[string]any + err = dstCanonicalConfig.Unpack(&canonicalisedMap) + require.NoError(t, err, "failed to unpack") + return canonicalisedMap } From 025c76f28bd92881f7f954fda06fe3c2ae8b6fd4 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 11:51:22 +0200 Subject: [PATCH 15/19] fix: relocate soft owners related funcs --- pkg/controller/common/reconciler/secret.go | 185 +++++- .../common/reconciler/secret_test.go | 351 +++++++++++ .../elasticsearch/filesettings/secret.go | 11 - .../stackconfigpolicy/controller.go | 6 +- .../elasticsearch_config_settings.go | 3 +- pkg/controller/stackconfigpolicy/ownership.go | 152 +---- .../stackconfigpolicy/ownership_test.go | 573 +++--------------- 7 files changed, 619 insertions(+), 662 deletions(-) diff --git a/pkg/controller/common/reconciler/secret.go b/pkg/controller/common/reconciler/secret.go index e4f2774c40e..faedb36e05d 100644 --- a/pkg/controller/common/reconciler/secret.go +++ b/pkg/controller/common/reconciler/secret.go @@ -16,6 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" @@ -95,18 +96,192 @@ func SoftOwnerRefFromLabels(labels map[string]string) (SoftOwnerRef, bool) { return SoftOwnerRef{Namespace: namespace, Name: name, Kind: kind}, true } +// SetMultipleSoftOwners sets multiple soft owner references to an object. +// Unlike single ownership (which uses labels), multiple ownership stores a JSON-encoded +// list of owner references in the SoftOwnerRefsAnnotation annotation to accommodate multiple owners. +// +// The function sets: +// - The label SoftOwnerKindLabel indicating the soft owner kind (e.g., "StackConfigPolicy") +// - The annotation SoftOwnerRefsAnnotation containing a JSON list of all owner namespaced names +// - Removes any existing single-owner labels if present +// +// Returns an error if JSON marshaling fails. +func SetMultipleSoftOwners(obj metav1.Object, ownerKind string, owners []types.NamespacedName) error { + objLabels := obj.GetLabels() + if objLabels == nil { + objLabels = map[string]string{} + } else { + // Remove single owner labels if they exist + delete(objLabels, SoftOwnerNamespaceLabel) + delete(objLabels, SoftOwnerNameLabel) + } + + // Mark this Secret as being soft-owned by ownerKind + objLabels[SoftOwnerKindLabel] = ownerKind + + objAnnotations := obj.GetAnnotations() + if objAnnotations == nil { + objAnnotations = map[string]string{} + } + + // Build a set of owner references using namespaced names as keys. + ownerRefs := sets.Set[string]{} + for _, o := range owners { + ownerRefs.Insert(o.String()) + } + // Store the owner references as a JSON-encoded annotation + ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) + if err != nil { + return err + } + + objAnnotations[SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + obj.SetAnnotations(objAnnotations) + obj.SetLabels(objLabels) + return nil +} + +// SetSingleSoftOwner sets a single soft owner reference to an object. +// This uses labels (SoftOwnerKindLabel, SoftOwnerNameLabel, SoftOwnerNamespaceLabel) +// to store the ownership relationship, allowing the owner to manage +// the object's lifecycle without using Kubernetes OwnerReferences. +// Removes any existing multi-owner annotation if present. +func SetSingleSoftOwner(obj metav1.Object, owner SoftOwnerRef) { + objLabels := obj.GetLabels() + if objLabels == nil { + objLabels = map[string]string{} + } + + objAnnotations := obj.GetAnnotations() + if objAnnotations != nil { + // Remove multi-owner annotation if it exists + delete(objAnnotations, SoftOwnerRefsAnnotation) + } + + objLabels[SoftOwnerNamespaceLabel] = owner.Namespace + objLabels[SoftOwnerNameLabel] = owner.Name + objLabels[SoftOwnerKindLabel] = owner.Kind + obj.SetLabels(objLabels) + obj.SetAnnotations(objAnnotations) +} + +// RemoveSoftOwner removes a soft owner from an object. +// It handles both single-owner (label-based) and multi-owner (annotation-based) scenarios. +// +// For single-owner objects: +// - If the owner matches, removes all soft owner labels +// - If the owner doesn't match, leaves the object unchanged +// +// For multi-owner objects: +// - Removes the owner from the JSON list in the SoftOwnerRefsAnnotation +// - Updates the annotation with the remaining owners or removes it if no owners remain +// +// Returns the number of remaining owners after removal and an error if there's a problem +// with JSON marshalling/unmarshalling. +func RemoveSoftOwner(obj metav1.Object, owner types.NamespacedName) (remainingCount int, err error) { + objLabels := obj.GetLabels() + if objLabels == nil { + return 0, nil + } + + objAnnotations := obj.GetAnnotations() + + // Check for multi-owner ownership (annotation-based) + if ownerRefsBytes, exists := objAnnotations[SoftOwnerRefsAnnotation]; exists { + // Multi-owner soft owned object - parse and update the set + var ownerRefsSlice []string + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { + return 0, err + } + + ownerRefs := sets.New(ownerRefsSlice...) + // Remove the specified owner from the set + ownerRefs.Delete(owner.String()) + if ownerRefs.Len() == 0 { + // No owners remain, remove the annotation + delete(objAnnotations, SoftOwnerRefsAnnotation) + return 0, nil + } + + // Marshal the updated owner list back to JSON + ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) + if err != nil { + return 0, err + } + + // Update the annotation with the new owner list + objAnnotations[SoftOwnerRefsAnnotation] = string(ownerRefsBytes) + return len(ownerRefs), nil + } + + // Handle single-owner ownership (label-based) + currentOwner, referenced := SoftOwnerRefFromLabels(objLabels) + if !referenced { + // No soft owner found + return 0, nil + } + + // Check if the single owner matches the owner to be removed + if currentOwner.Name == owner.Name && currentOwner.Namespace == owner.Namespace { + // Remove the soft owner labels since this was the only owner + delete(objLabels, SoftOwnerNamespaceLabel) + delete(objLabels, SoftOwnerNameLabel) + return 0, nil + } + + // The owner to remove doesn't match the current owner, so no change + return 1, nil +} + +// IsSoftOwnedBy checks if an object is soft-owned by the given owner. +// It handles both single-owner (label-based) and multi-owner (annotation-based) scenarios. +// Returns true if the object is owned by the specified owner, false otherwise, and an error +// if there's a problem unmarshalling the owner references from annotations. +func IsSoftOwnedBy(obj metav1.Object, ownerKind string, owner types.NamespacedName) (bool, error) { + objLabels := obj.GetLabels() + if objLabels == nil { + return false, nil + } + + if objOwnerKind := objLabels[SoftOwnerKindLabel]; objOwnerKind != ownerKind { + return false, nil + } + + objAnnotations := obj.GetAnnotations() + // Check for multi-owner ownership (annotation-based) + if ownerRefsBytes, exists := objAnnotations[SoftOwnerRefsAnnotation]; exists { + var ownerRefsSlice []string + if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { + return false, err + } + + ownerRefs := sets.New(ownerRefsSlice...) + return ownerRefs.Has(owner.String()), nil + } + + // Fall back to single-owner ownership (label-based) + currentOwner, referenced := SoftOwnerRefFromLabels(objLabels) + if !referenced { + // No soft owner found in labels + return false, nil + } + + // Check if the single owner matches the given owner + return currentOwner.Name == owner.Name && currentOwner.Namespace == owner.Namespace, nil +} + // SoftOwnerRefs returns the soft owner references of the given object. func SoftOwnerRefs(obj metav1.Object) ([]SoftOwnerRef, error) { - // Check if this Secret has a soft-owner kind label set + // Check if this object has a soft-owner kind label set ownerKind, exists := obj.GetLabels()[SoftOwnerKindLabel] if !exists { - // Not a soft-owned secret + // Not a soft-owned object return nil, nil } - // Check for multi-policy ownership (annotation-based) + // Check for multi-owner ownership (annotation-based) if ownerRefsBytes, exists := obj.GetAnnotations()[SoftOwnerRefsAnnotation]; exists { - // Multi-policy soft owned secret - parse the list of owners + // Multi-owner soft owned object - parse the list of owners var ownerRefsSlice []string if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { return nil, err @@ -127,7 +302,7 @@ func SoftOwnerRefs(obj metav1.Object) ([]SoftOwnerRef, error) { return ownerRefsNsn, nil } - // Fall back to single-policy ownership (label-based) + // Fall back to single-owner ownership (label-based) currentOwner, referenced := SoftOwnerRefFromLabels(obj.GetLabels()) if !referenced { // No soft owner found in labels diff --git a/pkg/controller/common/reconciler/secret_test.go b/pkg/controller/common/reconciler/secret_test.go index e7d1bc8f5e8..cc85eeb1aee 100644 --- a/pkg/controller/common/reconciler/secret_test.go +++ b/pkg/controller/common/reconciler/secret_test.go @@ -6,6 +6,7 @@ package reconciler import ( "context" + "encoding/json" "reflect" "testing" @@ -716,3 +717,353 @@ func TestSoftOwnerRefs(t *testing.T) { }) } } + +//nolint:thelper +func TestSetSingleSoftOwner(t *testing.T) { + tests := []struct { + name string + obj *corev1.Secret + owner SoftOwnerRef + validate func(t *testing.T, obj *corev1.Secret) + }{ + { + name: "sets soft owner labels on empty object", + obj: &corev1.Secret{}, + owner: SoftOwnerRef{ + Namespace: "test-namespace", + Name: "test-owner", + Kind: "TestKind", + }, + validate: func(t *testing.T, obj *corev1.Secret) { + assert.Equal(t, "TestKind", obj.Labels[SoftOwnerKindLabel]) + assert.Equal(t, "test-owner", obj.Labels[SoftOwnerNameLabel]) + assert.Equal(t, "test-namespace", obj.Labels[SoftOwnerNamespaceLabel]) + }, + }, + { + name: "overwrites existing soft owner labels", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "OldKind", + SoftOwnerNameLabel: "old-owner", + SoftOwnerNamespaceLabel: "old-namespace", + "existing-label": "existing-value", + }, + Annotations: map[string]string{ + SoftOwnerRefsAnnotation: `["old/owner"]`, + "existing-annotation": "existing-value", + }, + }, + }, + owner: SoftOwnerRef{ + Namespace: "new-namespace", + Name: "new-owner", + Kind: "NewKind", + }, + validate: func(t *testing.T, obj *corev1.Secret) { + assert.Equal(t, "NewKind", obj.Labels[SoftOwnerKindLabel]) + assert.Equal(t, "new-owner", obj.Labels[SoftOwnerNameLabel]) + assert.Equal(t, "new-namespace", obj.Labels[SoftOwnerNamespaceLabel]) + assert.Equal(t, "existing-value", obj.Labels["existing-label"]) + assert.NotContains(t, obj.Annotations, SoftOwnerRefsAnnotation) + assert.Equal(t, "existing-value", obj.Annotations["existing-annotation"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetSingleSoftOwner(tt.obj, tt.owner) + tt.validate(t, tt.obj) + }) + } +} + +//nolint:thelper +func TestSetMultipleSoftOwners(t *testing.T) { + tests := []struct { + name string + obj *corev1.Secret + ownerKind string + owners []types.NamespacedName + validate func(t *testing.T, obj *corev1.Secret, err error) + }{ + { + name: "sets multiple owners on empty object", + obj: &corev1.Secret{}, + ownerKind: "TestKind", + owners: []types.NamespacedName{ + {Namespace: "ns1", Name: "owner1"}, + {Namespace: "ns2", Name: "owner2"}, + }, + validate: func(t *testing.T, obj *corev1.Secret, err error) { + require.NoError(t, err) + assert.Equal(t, "TestKind", obj.Labels[SoftOwnerKindLabel]) + assert.NotContains(t, obj.Labels, SoftOwnerNameLabel) + assert.NotContains(t, obj.Labels, SoftOwnerNamespaceLabel) + + var ownerRefs []string + err = json.Unmarshal([]byte(obj.Annotations[SoftOwnerRefsAnnotation]), &ownerRefs) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"ns1/owner1", "ns2/owner2"}, ownerRefs) + }, + }, + { + name: "removes single-owner labels when setting multiple owners", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + SoftOwnerNameLabel: "old-owner", + SoftOwnerNamespaceLabel: "old-namespace", + }, + }, + }, + ownerKind: "TestKind", + owners: []types.NamespacedName{ + {Namespace: "ns1", Name: "owner1"}, + }, + validate: func(t *testing.T, obj *corev1.Secret, err error) { + require.NoError(t, err) + assert.NotContains(t, obj.Labels, SoftOwnerNameLabel) + assert.NotContains(t, obj.Labels, SoftOwnerNamespaceLabel) + }, + }, + { + name: "deduplicates owners", + obj: &corev1.Secret{}, + ownerKind: "TestKind", + owners: []types.NamespacedName{ + {Namespace: "ns1", Name: "owner1"}, + {Namespace: "ns1", Name: "owner1"}, // duplicate + {Namespace: "ns2", Name: "owner2"}, + }, + validate: func(t *testing.T, obj *corev1.Secret, err error) { + require.NoError(t, err) + var ownerRefs []string + err = json.Unmarshal([]byte(obj.Annotations[SoftOwnerRefsAnnotation]), &ownerRefs) + require.NoError(t, err) + assert.Len(t, ownerRefs, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SetMultipleSoftOwners(tt.obj, tt.ownerKind, tt.owners) + tt.validate(t, tt.obj, err) + }) + } +} + +//nolint:thelper +func TestRemoveSoftOwner(t *testing.T) { + tests := []struct { + name string + obj *corev1.Secret + owner types.NamespacedName + wantCount int + wantErr bool + validate func(t *testing.T, obj *corev1.Secret) + }{ + { + name: "removes owner from multi-owner with remaining owners", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + }, + Annotations: map[string]string{ + SoftOwnerRefsAnnotation: `["ns1/owner1","ns2/owner2","ns3/owner3"]`, + }, + }, + }, + owner: types.NamespacedName{Namespace: "ns2", Name: "owner2"}, + wantCount: 2, + validate: func(t *testing.T, obj *corev1.Secret) { + var ownerRefs []string + err := json.Unmarshal([]byte(obj.Annotations[SoftOwnerRefsAnnotation]), &ownerRefs) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"ns1/owner1", "ns3/owner3"}, ownerRefs) + assert.Equal(t, "TestKind", obj.Labels[SoftOwnerKindLabel]) + }, + }, + { + name: "removes last owner and cleans up annotation", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + }, + Annotations: map[string]string{ + SoftOwnerRefsAnnotation: `["ns1/owner1"]`, + "other-annotation": "preserved", + }, + }, + }, + owner: types.NamespacedName{Namespace: "ns1", Name: "owner1"}, + wantCount: 0, + validate: func(t *testing.T, obj *corev1.Secret) { + assert.NotContains(t, obj.Annotations, SoftOwnerRefsAnnotation) + assert.Equal(t, "preserved", obj.Annotations["other-annotation"]) + }, + }, + { + name: "removes matching single-owner", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + SoftOwnerNameLabel: "single-owner", + SoftOwnerNamespaceLabel: "single-namespace", + "other-label": "preserved", + }, + }, + }, + owner: types.NamespacedName{Namespace: "single-namespace", Name: "single-owner"}, + wantCount: 0, + validate: func(t *testing.T, obj *corev1.Secret) { + assert.NotContains(t, obj.Labels, SoftOwnerNameLabel) + assert.NotContains(t, obj.Labels, SoftOwnerNamespaceLabel) + assert.Equal(t, "preserved", obj.Labels["other-label"]) + }, + }, + { + name: "returns 1 when owner doesn't match single-owner", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + SoftOwnerNameLabel: "existing-owner", + SoftOwnerNamespaceLabel: "existing-namespace", + }, + }, + }, + owner: types.NamespacedName{Namespace: "different-namespace", Name: "different-owner"}, + wantCount: 1, + validate: func(t *testing.T, obj *corev1.Secret) { + assert.Equal(t, "existing-owner", obj.Labels[SoftOwnerNameLabel]) + assert.Equal(t, "existing-namespace", obj.Labels[SoftOwnerNamespaceLabel]) + assert.Equal(t, "TestKind", obj.Labels[SoftOwnerKindLabel]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + count, err := RemoveSoftOwner(tt.obj, tt.owner) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantCount, count) + if tt.validate != nil { + tt.validate(t, tt.obj) + } + }) + } +} + +//nolint:thelper +func TestIsSoftOwnedBy(t *testing.T) { + tests := []struct { + name string + obj *corev1.Secret + ownerKind string + owner types.NamespacedName + want bool + wantErr bool + }{ + { + name: "returns true for multi-owner match", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + }, + Annotations: map[string]string{ + SoftOwnerRefsAnnotation: `["ns1/owner1","ns2/owner2"]`, + }, + }, + }, + ownerKind: "TestKind", + owner: types.NamespacedName{Namespace: "ns1", Name: "owner1"}, + want: true, + }, + { + name: "returns false for multi-owner non-match", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + }, + Annotations: map[string]string{ + SoftOwnerRefsAnnotation: `["ns1/owner1","ns2/owner2"]`, + }, + }, + }, + ownerKind: "TestKind", + owner: types.NamespacedName{Namespace: "ns3", Name: "owner3"}, + want: false, + }, + { + name: "returns true for single-owner match", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + SoftOwnerNameLabel: "single-owner", + SoftOwnerNamespaceLabel: "single-namespace", + }, + }, + }, + ownerKind: "TestKind", + owner: types.NamespacedName{Namespace: "single-namespace", Name: "single-owner"}, + want: true, + }, + { + name: "returns false for single-owner non-match", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "TestKind", + SoftOwnerNameLabel: "single-owner", + SoftOwnerNamespaceLabel: "single-namespace", + }, + }, + }, + ownerKind: "TestKind", + owner: types.NamespacedName{Namespace: "different-namespace", Name: "different-owner"}, + want: false, + }, + { + name: "returns false for wrong kind", + obj: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + SoftOwnerKindLabel: "DifferentKind", + SoftOwnerNameLabel: "owner", + SoftOwnerNamespaceLabel: "namespace", + }, + }, + }, + ownerKind: "TestKind", + owner: types.NamespacedName{Namespace: "namespace", Name: "owner"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsSoftOwnedBy(tt.obj, tt.ownerKind, tt.owner) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/controller/elasticsearch/filesettings/secret.go b/pkg/controller/elasticsearch/filesettings/secret.go index 790f322d61d..74676479052 100644 --- a/pkg/controller/elasticsearch/filesettings/secret.go +++ b/pkg/controller/elasticsearch/filesettings/secret.go @@ -21,7 +21,6 @@ import ( commonannotation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/annotation" commonlabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/metadata" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ) @@ -118,16 +117,6 @@ func hasChanged(settingsSecret corev1.Secret, newSettings Settings) bool { return settingsSecret.Annotations[commonannotation.SettingsHashAnnotationName] != newSettings.hash() } -// SetSoftOwner sets the given StackConfigPolicy as soft owner of the Settings Secret using the "softOwned" labels. -func SetSoftOwner(settingsSecret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { - if settingsSecret.Labels == nil { - settingsSecret.Labels = map[string]string{} - } - settingsSecret.Labels[reconciler.SoftOwnerNamespaceLabel] = policy.GetNamespace() - settingsSecret.Labels[reconciler.SoftOwnerNameLabel] = policy.GetName() - settingsSecret.Labels[reconciler.SoftOwnerKindLabel] = policyv1alpha1.Kind -} - // setSecureSettings stores the SecureSettings Secret sources referenced in the given StackConfigPolicy in the annotation of the Settings Secret. func setSecureSettings(settingsSecret *corev1.Secret, secretSources []commonv1.NamespacedSecretSource) error { if len(secretSources) == 0 { diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index ac5beaedc5c..55ca07e0702 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -626,8 +626,7 @@ func resetOrphanSoftOwnedFileSettingSecrets( owned, err := isPolicySoftOwner(&s, softOwner) if err != nil { return err - } - if !owned { + } else if !owned { continue } configuredApplicationType := s.Labels[commonv1.TypeLabelName] @@ -712,8 +711,7 @@ func deleteOrphanSoftOwnedSecrets( owned, err := isPolicySoftOwner(&secret, softOwner) if err != nil { return err - } - if !owned { + } else if !owned { continue } diff --git a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go index 8b64b8ed820..42ca88d38c9 100644 --- a/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go +++ b/pkg/controller/stackconfigpolicy/elasticsearch_config_settings.go @@ -19,7 +19,6 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/hash" commonlabels "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" eslabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" @@ -103,7 +102,7 @@ func reconcileSecretMounts(ctx context.Context, c k8s.Client, es esv1.Elasticsea } // Set stackconfigpolicy as a softowner - filesettings.SetSoftOwner(&expected, *policy) + setSingleSoftOwner(&expected, *policy) // Set the secret to be deleted when the stack config policy is deleted. expected.Labels[commonlabels.StackConfigPolicyOnDeleteLabelName] = commonlabels.OrphanSecretDeleteOnPolicyDelete diff --git a/pkg/controller/stackconfigpolicy/ownership.go b/pkg/controller/stackconfigpolicy/ownership.go index 60a56468d52..d5b430b991a 100644 --- a/pkg/controller/stackconfigpolicy/ownership.go +++ b/pkg/controller/stackconfigpolicy/ownership.go @@ -5,179 +5,53 @@ package stackconfigpolicy import ( - "encoding/json" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" - "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ) // setSingleSoftOwner marks a Secret as soft-owned by a single StackConfigPolicy. -// This uses labels (reconciler.SoftOwnerKindLabel, reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) -// to store the ownership relationship, allowing the policy to manage -// the Secret's lifecycle without using Kubernetes OwnerReferences. func setSingleSoftOwner(secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { if secret == nil { return } - if secret.Annotations != nil { - // Remove multi-owner annotation if it exists - delete(secret.Annotations, reconciler.SoftOwnerRefsAnnotation) - } - - filesettings.SetSoftOwner(secret, policy) + reconciler.SetSingleSoftOwner(secret, reconciler.SoftOwnerRef{ + Namespace: policy.GetNamespace(), + Name: policy.GetName(), + Kind: policyv1alpha1.Kind, + }) } // setMultipleSoftOwners marks a Secret as soft-owned by multiple StackConfigPolicies. -// Unlike single ownership (which uses labels), multiple ownership stores a JSON-encoded -// map of owner references in annotations to accommodate multiple policies. -// -// The function sets: -// - The label reconciler.SoftOwnerKindLabel indicating the soft owner kind to policyv1alpha1.Kind -// - The annotation reconciler.SoftOwnerRefsAnnotation containing a JSON map of all owner namespaced names -// -// Returns an error if JSON marshaling fails. func setMultipleSoftOwners(secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy) error { if secret == nil { return nil } - - if secret.Labels == nil { - secret.Labels = map[string]string{} - } else { - // Remove single owner labels if they exist - delete(secret.Labels, reconciler.SoftOwnerNamespaceLabel) - delete(secret.Labels, reconciler.SoftOwnerNameLabel) - } - - // Mark this Secret as being soft-owned by StackConfigPolicy resources - secret.Labels[reconciler.SoftOwnerKindLabel] = policyv1alpha1.Kind - - if secret.Annotations == nil { - secret.Annotations = map[string]string{} - } - - // Build a set of owner references using namespaced names as keys. - ownerRefs := sets.Set[string]{} - for _, p := range policies { - ownerRefs.Insert(k8s.ExtractNamespacedName(&p).String()) - } - // Store the owner references as a JSON-encoded annotation - ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) - if err != nil { - return err + ownersNsn := make([]types.NamespacedName, len(policies)) + for idx, p := range policies { + ownersNsn[idx] = k8s.ExtractNamespacedName(&p) } - - secret.Annotations[reconciler.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) - return nil + return reconciler.SetMultipleSoftOwners(secret, policyv1alpha1.Kind, ownersNsn) } // isPolicySoftOwner checks if the given StackConfigPolicy is a soft owner of the Secret. -// It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) -// and multi-owner (reconciler.SoftOwnerRefsAnnotation) scenarios. -// Returns true or false depending on whether the policy is an owner of the secret -// and an error if there's a problem unmarshalling the owner references func isPolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (bool, error) { if secret == nil { return false, nil } - - // Check if this Secret is soft-owned by a StackConfigPolicy - if ownerKind := secret.Labels[reconciler.SoftOwnerKindLabel]; ownerKind != policyv1alpha1.Kind { - // Not a policy soft-owned secret - return false, nil - } - - // Check for multi-policy ownership (annotation-based) - if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { - // Multi-policy soft owned secret - parse the JSON map of owners - var ownerRefsSlice []string - if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { - return false, err - } - - ownerRefs := sets.New(ownerRefsSlice...) - return ownerRefs.Has(types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()), nil - } - - // Fall back to single-policy ownership (label-based) - currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(secret.Labels) - if !referenced { - // No soft owner found in labels - return false, nil - } - - // Check if the single owner matches the given policy - return currentOwner.Name == policyNsn.Name && currentOwner.Namespace == policyNsn.Namespace, nil + return reconciler.IsSoftOwnedBy(secret, policyv1alpha1.Kind, policyNsn) } -// removePolicySoftOwner removes a StackConfigPolicy if it is soft owning the given secret. -// It handles both single-owner (reconciler.SoftOwnerNameLabel, reconciler.SoftOwnerNamespaceLabel) -// and multi-owner (reconciler.SoftOwnerRefsAnnotation) scenarios. -// -// For single-owner secrets: -// - If the owner matches, removes all soft owner labels/annotations -// - If the owner doesn't match, leaves the secret unchanged -// -// For multi-owner secrets: -// - Removes the policy from the JSON map in annotations -// - Updates the annotation with the remaining owners or removes the annotation if no owners remain -// -// Returns the number of remaining owners after removal and an error if there's a problem with JSON marshaling/unmarshalling +// removePolicySoftOwner removes a StackConfigPolicy from the soft owners of the given secret. +// Returns the number of remaining owners after removal. func removePolicySoftOwner(secret *corev1.Secret, policyNsn types.NamespacedName) (int, error) { if secret == nil { return 0, nil } - // Check for multi-policy ownership (annotation-based) - if ownerRefsBytes, exists := secret.Annotations[reconciler.SoftOwnerRefsAnnotation]; exists { - // Multi-policy soft owned secret - parse and update the set - var ownerRefsSlice []string - if err := json.Unmarshal([]byte(ownerRefsBytes), &ownerRefsSlice); err != nil { - return 0, err - } - - ownerRefs := sets.New(ownerRefsSlice...) - // Remove the specified policy from the set - ownerRefs.Delete(types.NamespacedName{Name: policyNsn.Name, Namespace: policyNsn.Namespace}.String()) - if ownerRefs.Len() == 0 { - // No owners remain, remove the annotation - delete(secret.Annotations, reconciler.SoftOwnerRefsAnnotation) - return 0, nil - } - - // Marshal the updated owner list back to JSON - ownerRefsBytes, err := json.Marshal(sets.List(ownerRefs)) - if err != nil { - return 0, err - } - - // Update the annotation with the new owner list - secret.Annotations[reconciler.SoftOwnerRefsAnnotation] = string(ownerRefsBytes) - return len(ownerRefs), nil - } - - // Handle single-policy ownership (label-based) - currentOwner, referenced := reconciler.SoftOwnerRefFromLabels(secret.Labels) - if !referenced { - // No soft owner found - return 0, nil - } - - // Check if the single owner matches the policy to be removed - if currentOwner.Name == policyNsn.Name && currentOwner.Namespace == policyNsn.Namespace { - // Remove the soft owner labels since this was the only owner - delete(secret.Labels, reconciler.SoftOwnerNamespaceLabel) - delete(secret.Labels, reconciler.SoftOwnerNameLabel) - return 0, nil - } - - // The policy to remove doesn't match the current owner, so no change - return 1, nil + return reconciler.RemoveSoftOwner(secret, policyNsn) } diff --git a/pkg/controller/stackconfigpolicy/ownership_test.go b/pkg/controller/stackconfigpolicy/ownership_test.go index 109f98c92ab..2d3b5ec60eb 100644 --- a/pkg/controller/stackconfigpolicy/ownership_test.go +++ b/pkg/controller/stackconfigpolicy/ownership_test.go @@ -5,7 +5,6 @@ package stackconfigpolicy import ( - "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -20,532 +19,104 @@ import ( //nolint:thelper func Test_setSingleSoftOwner(t *testing.T) { - tests := []struct { - name string - secret *corev1.Secret - policy policyv1alpha1.StackConfigPolicy - validate func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) - }{ - { - name: "overwrites existing soft owner labels", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: "old-kind", - reconciler.SoftOwnerNameLabel: "old-policy", - reconciler.SoftOwnerNamespaceLabel: "old-namespace", - "existing-label": "existing-value", - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: "{}", - "existing-annotation": "existing-value", - }, - }, - }, - policy: policyv1alpha1.StackConfigPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: "new-policy", - Namespace: "new-namespace", - }, - }, - validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { - assert.NotNil(t, secret.Labels) - assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) - assert.Equal(t, "new-policy", secret.Labels[reconciler.SoftOwnerNameLabel]) - assert.Equal(t, "new-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) - assert.Equal(t, "existing-value", secret.Labels["existing-label"]) - assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) - assert.NotContains(t, secret.Annotations, reconciler.SoftOwnerRefsAnnotation) - }, - }, - { - name: "returns nil for nil secret", - secret: nil, - validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { - assert.Nil(t, secret) - }, + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", }, - { - name: "secret with nil labels and annotations", - secret: &corev1.Secret{}, - validate: func(t *testing.T, secret *corev1.Secret, policy policyv1alpha1.StackConfigPolicy) { - assert.NotNil(t, secret) - }, + } + policy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-policy", + Namespace: "policy-namespace", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - setSingleSoftOwner(tt.secret, tt.policy) - tt.validate(t, tt.secret, tt.policy) - }) - } + setSingleSoftOwner(secret, policy) + + assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) + assert.Equal(t, "test-policy", secret.Labels[reconciler.SoftOwnerNameLabel]) + assert.Equal(t, "policy-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) } -//nolint:thelper func Test_setMultipleSoftOwners(t *testing.T) { - tests := []struct { - name string - secret *corev1.Secret - policies []policyv1alpha1.StackConfigPolicy - validate func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) - }{ - { - name: "removes single-owner labels and sets multi-owner annotation", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - reconciler.SoftOwnerNameLabel: "old-single-policy", - reconciler.SoftOwnerNamespaceLabel: "old-namespace", - "existing-label": "existing-value", - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: "replaced-value", - "existing-annotation": "existing-value", - }, - }, - }, - policies: []policyv1alpha1.StackConfigPolicy{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "policy-1", - Namespace: "namespace-1", - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: "policy-2", - Namespace: "namespace-2", - }, - }, - { - // should be deduplicated - ObjectMeta: metav1.ObjectMeta{ - Name: "policy-2", - Namespace: "namespace-2", - }, - }, - }, - validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { - require.NoError(t, err) - - // Verify single-owner labels are removed - assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) - assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) - - // Verify kind label is still set - assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) - - // Verify existing label is preserved - assert.Equal(t, "existing-value", secret.Labels["existing-label"]) - - // Verify existing annotation is preserved - assert.Equal(t, "existing-value", secret.Annotations["existing-annotation"]) - - // Verify multi-owner annotation is set with both policies - ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] - assert.NotEmpty(t, ownerRefsJSON) - - var ownerRefs []string - err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) - require.NoError(t, err) - assert.EqualValues(t, []string{ - types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}.String(), - types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}.String(), - }, ownerRefs) - }, - }, - { - name: "returns nil for nil secret", - secret: nil, - validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { - assert.Nil(t, err) - assert.Nil(t, secret) - assert.Len(t, policies, 0) - }, - }, - { - name: "secret with nil labels and annotations", - secret: &corev1.Secret{}, - validate: func(t *testing.T, secret *corev1.Secret, policies []policyv1alpha1.StackConfigPolicy, err error) { - assert.Nil(t, err) - assert.NotNil(t, secret) - assert.Len(t, policies, 0) - }, + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := setMultipleSoftOwners(tt.secret, tt.policies) - tt.validate(t, tt.secret, tt.policies, err) - }) - } -} - -//nolint:thelper -func Test_removePolicySoftOwner(t *testing.T) { - tests := []struct { - name string - secret *corev1.Secret - policyToRemove types.NamespacedName - validate func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) - }{ + policies := []policyv1alpha1.StackConfigPolicy{ { - name: "removes policy from multi-owner with remaining owners", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2","namespace-3/policy-3"]`, - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "policy-2", Namespace: "namespace-2"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 2, remainingCount) - - // Verify the annotation still exists with remaining owners - ownerRefsJSON := secret.Annotations[reconciler.SoftOwnerRefsAnnotation] - assert.NotEmpty(t, ownerRefsJSON) - - var ownerRefs []string - err = json.Unmarshal([]byte(ownerRefsJSON), &ownerRefs) - require.NoError(t, err) - assert.Len(t, ownerRefs, 2) - - // Verify policy-2 was removed - assert.EqualValues(t, []string{ - "namespace-1/policy-1", - "namespace-3/policy-3", - }, ownerRefs) + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-1", + Namespace: "namespace-1", }, }, { - name: "removes last policy from multi-owner and cleans up annotation", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1"]`, - "other-annotation": "preserved", - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 0, remainingCount) - - // Verify the annotation was removed - assert.NotContains(t, secret.Annotations, reconciler.SoftOwnerRefsAnnotation) - - // Verify other annotations are preserved - assert.Equal(t, "preserved", secret.Annotations["other-annotation"]) + ObjectMeta: metav1.ObjectMeta{ + Name: "policy-2", + Namespace: "namespace-2", }, }, - { - name: "removes matching single-owner and cleans up labels", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - reconciler.SoftOwnerNameLabel: "single-policy", - reconciler.SoftOwnerNamespaceLabel: "single-namespace", - "other-label": "preserved", - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "single-policy", Namespace: "single-namespace"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 0, remainingCount) - - // Verify all soft owner labels were removed - assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) - assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) + } - // Verify other labels are preserved - assert.Equal(t, "preserved", secret.Labels["other-label"]) - }, - }, - { - name: "returns 1 when policy doesn't match single-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - reconciler.SoftOwnerNameLabel: "existing-policy", - reconciler.SoftOwnerNamespaceLabel: "existing-namespace", - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "different-policy", Namespace: "different-namespace"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 1, remainingCount) + err := setMultipleSoftOwners(secret, policies) + require.NoError(t, err) - // Verify labels remain unchanged - assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) - assert.Equal(t, "existing-policy", secret.Labels[reconciler.SoftOwnerNameLabel]) - assert.Equal(t, "existing-namespace", secret.Labels[reconciler.SoftOwnerNamespaceLabel]) - }, - }, - { - name: "returns 0 for nil secret", - secret: nil, - policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 0, remainingCount) - }, - }, - { - name: "returns 0 for non-owned secret", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - "some-label": "some-value", - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 0, remainingCount) - }, - }, - { - name: "returns error for invalid JSON in annotation", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `invalid-json`, - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.Error(t, err) - assert.Equal(t, 0, remainingCount) - }, - }, - { - name: "returns 0 when removing non-existent policy from multi-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, - }, - }, - }, - policyToRemove: types.NamespacedName{Name: "non-existent", Namespace: "namespace-3"}, - validate: func(t *testing.T, secret *corev1.Secret, remainingCount int, err error) { - require.NoError(t, err) - assert.Equal(t, 2, remainingCount) + assert.Equal(t, policyv1alpha1.Kind, secret.Labels[reconciler.SoftOwnerKindLabel]) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) + assert.NotEmpty(t, secret.Annotations[reconciler.SoftOwnerRefsAnnotation]) +} - // Verify both original policies remain - var ownerRefs []string - err = json.Unmarshal([]byte(secret.Annotations[reconciler.SoftOwnerRefsAnnotation]), &ownerRefs) - require.NoError(t, err) - assert.Len(t, ownerRefs, 2) +func Test_isPolicySoftOwner(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "test-policy", + reconciler.SoftOwnerNamespaceLabel: "policy-namespace", }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - remainingCount, err := removePolicySoftOwner(tt.secret, tt.policyToRemove) - tt.validate(t, tt.secret, remainingCount, err) - }) - } + isOwner, err := isPolicySoftOwner(secret, types.NamespacedName{ + Namespace: "policy-namespace", + Name: "test-policy", + }) + require.NoError(t, err) + assert.True(t, isOwner) + + isOwner, err = isPolicySoftOwner(secret, types.NamespacedName{ + Namespace: "different-namespace", + Name: "different-policy", + }) + require.NoError(t, err) + assert.False(t, isOwner) } -//nolint:thelper -func Test_isPolicySoftOwner(t *testing.T) { - tests := []struct { - name string - secret *corev1.Secret - policyNsn types.NamespacedName - validate func(t *testing.T, isOwner bool, err error) - }{ - { - name: "returns true when policy is owner in multi-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, - }, - }, - }, - policyNsn: types.NamespacedName{Name: "policy-1", Namespace: "namespace-1"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.True(t, isOwner) - }, - }, - { - name: "returns false when policy is not owner in multi-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `["namespace-1/policy-1","namespace-2/policy-2"]`, - }, - }, - }, - policyNsn: types.NamespacedName{Name: "policy-3", Namespace: "namespace-3"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.False(t, isOwner) - }, - }, - { - name: "returns true when policy matches single-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - reconciler.SoftOwnerNameLabel: "single-policy", - reconciler.SoftOwnerNamespaceLabel: "single-namespace", - }, - }, - }, - policyNsn: types.NamespacedName{Name: "single-policy", Namespace: "single-namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.True(t, isOwner) - }, - }, - { - name: "returns false when policy doesn't match single-owner", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - reconciler.SoftOwnerNameLabel: "single-policy", - reconciler.SoftOwnerNamespaceLabel: "single-namespace", - }, - }, - }, - policyNsn: types.NamespacedName{Name: "different-policy", Namespace: "different-namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.False(t, isOwner) - }, - }, - { - name: "returns false for nil secret", - secret: nil, - policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.False(t, isOwner) - }, - }, - { - name: "returns false for non-policy-owned secret", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - "some-other-label": "some-value", - }, - }, - }, - policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.False(t, isOwner) - }, - }, - { - name: "returns error for invalid JSON in annotation", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - Annotations: map[string]string{ - reconciler.SoftOwnerRefsAnnotation: `invalid-json`, - }, - }, - }, - policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.Error(t, err) - assert.False(t, isOwner) - }, - }, - { - name: "returns false when secret has kind label but no owner references", - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "test-namespace", - Labels: map[string]string{ - reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, - }, - }, - }, - policyNsn: types.NamespacedName{Name: "policy", Namespace: "namespace"}, - validate: func(t *testing.T, isOwner bool, err error) { - require.NoError(t, err) - assert.False(t, isOwner) +func Test_removePolicySoftOwner(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + reconciler.SoftOwnerKindLabel: policyv1alpha1.Kind, + reconciler.SoftOwnerNameLabel: "test-policy", + reconciler.SoftOwnerNamespaceLabel: "policy-namespace", }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - isOwner, err := isPolicySoftOwner(tt.secret, tt.policyNsn) - tt.validate(t, isOwner, err) - }) - } + remainingCount, err := removePolicySoftOwner(secret, types.NamespacedName{ + Namespace: "policy-namespace", + Name: "test-policy", + }) + require.NoError(t, err) + assert.Equal(t, 0, remainingCount) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNameLabel) + assert.NotContains(t, secret.Labels, reconciler.SoftOwnerNamespaceLabel) } From e3639c5ddccb7364853d7f589b2dd2f7ba8ba6df Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 11:51:22 +0200 Subject: [PATCH 16/19] fix: reword secret mounts merge conflict errs --- pkg/controller/stackconfigpolicy/stackconfigpolicy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 50eae7846c9..6d1c6d77d46 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -302,13 +302,13 @@ func (s *secretMountsAggregator) mergeInto( for _, secretMount := range src { if existingPolicy, exists := s.policiesBySecretName[secretMount.SecretName]; exists { existingPolicyNsn := k8s.ExtractNamespacedName(existingPolicy) - err := fmt.Errorf("%w: secret with name %q is defined in policies %q, %q", errMergeConflict, secretMount.SecretName, + err := fmt.Errorf("%w: secret with name %q is defined in policy %q, %q", errMergeConflict, secretMount.SecretName, srcPolicyNsn.String(), existingPolicyNsn.String()) return nil, err } if existingPolicy, exists := s.policiesByMountPath[secretMount.MountPath]; exists { existingPolicyNsn := k8s.ExtractNamespacedName(existingPolicy) - err := fmt.Errorf("%w: secret mount path %q is defined in policies %q, %q", errMergeConflict, secretMount.MountPath, + err := fmt.Errorf("%w: secret mount path %q is defined in policy %q, %q", errMergeConflict, secretMount.MountPath, srcPolicyNsn.String(), existingPolicyNsn.String()) return nil, err } From ad6e3b1bcdf0613c30643afc5da6cb4b2012cfb5 Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 11:51:22 +0200 Subject: [PATCH 17/19] fix: include the deprecated field of secure settings in merging --- pkg/controller/stackconfigpolicy/stackconfigpolicy.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go index 6d1c6d77d46..c96873c6c00 100644 --- a/pkg/controller/stackconfigpolicy/stackconfigpolicy.go +++ b/pkg/controller/stackconfigpolicy/stackconfigpolicy.go @@ -115,7 +115,11 @@ func getConfigPolicyForElasticsearch(es *esv1.Elasticsearch, allPolicies []polic return err } - c.SecretSources = mergeSecretSources(c.SecretSources, srcPolicy.Spec.Elasticsearch.SecureSettings, srcPolicy) + var secureSettings []commonv1.SecretSource + secureSettings = append(secureSettings, srcPolicy.Spec.SecureSettings...) //nolint:staticcheck // keep supporting deprecated SecureSettings until it is completely removed + secureSettings = append(secureSettings, srcPolicy.Spec.Elasticsearch.SecureSettings...) + + c.SecretSources = mergeSecretSources(c.SecretSources, secureSettings, srcPolicy) return nil }, } From 8406a6c3b138f61fbdccf6fa0e26be46e298fa6c Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 12:10:16 +0200 Subject: [PATCH 18/19] fix: make linter happy --- pkg/controller/common/reconciler/secret_test.go | 1 - pkg/controller/stackconfigpolicy/ownership_test.go | 1 - 2 files changed, 2 deletions(-) diff --git a/pkg/controller/common/reconciler/secret_test.go b/pkg/controller/common/reconciler/secret_test.go index cc85eeb1aee..b4b4cfb88d8 100644 --- a/pkg/controller/common/reconciler/secret_test.go +++ b/pkg/controller/common/reconciler/secret_test.go @@ -966,7 +966,6 @@ func TestRemoveSoftOwner(t *testing.T) { } } -//nolint:thelper func TestIsSoftOwnedBy(t *testing.T) { tests := []struct { name string diff --git a/pkg/controller/stackconfigpolicy/ownership_test.go b/pkg/controller/stackconfigpolicy/ownership_test.go index 2d3b5ec60eb..6beafe6b6c4 100644 --- a/pkg/controller/stackconfigpolicy/ownership_test.go +++ b/pkg/controller/stackconfigpolicy/ownership_test.go @@ -17,7 +17,6 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" ) -//nolint:thelper func Test_setSingleSoftOwner(t *testing.T) { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ From e199c4444de78dc6ce9d9fe27e96df65808ce79f Mon Sep 17 00:00:00 2001 From: Panos Koutsovasilis Date: Mon, 1 Dec 2025 16:54:21 +0200 Subject: [PATCH 19/19] ci: add e2e tests --- test/e2e/es/stackconfigpolicy_test.go | 221 ++++++++++++++++++++++++++ test/e2e/kb/stackconfigpolicy_test.go | 187 ++++++++++++++++++++++ 2 files changed, 408 insertions(+) diff --git a/test/e2e/es/stackconfigpolicy_test.go b/test/e2e/es/stackconfigpolicy_test.go index 01f48ef358e..d81eeeba910 100644 --- a/test/e2e/es/stackconfigpolicy_test.go +++ b/test/e2e/es/stackconfigpolicy_test.go @@ -336,6 +336,227 @@ func TestStackConfigPolicy(t *testing.T) { test.Sequence(nil, steps, esWithlicense).RunSequential(t) } +// TestStackConfigPolicyMultipleWeights tests multiple StackConfigPolicies with different weights. +func TestStackConfigPolicyMultipleWeights(t *testing.T) { + // only execute this test if we have a test license to work with + if test.Ctx().TestLicense == "" { + t.SkipNow() + } + + // StackConfigPolicy is supported for ES versions with file-based settings feature + stackVersion := version.MustParse(test.Ctx().ElasticStackVersion) + if !stackVersion.GTE(filesettings.FileBasedSettingsMinPreVersion) { + t.SkipNow() + } + + es := elasticsearch.NewBuilder("test-es-scp-multi"). + WithESMasterDataNodes(1, elasticsearch.DefaultResources). + WithLabel("app", "elasticsearch") + + namespace := test.Ctx().ManagedNamespace(0) + + // Policy with weight 20 (lower priority) - sets cluster.name + lowPriorityPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("low-priority-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "elasticsearch"}, + }, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "cluster.name": "low-priority-cluster", + }, + }, + ClusterSettings: &commonv1.Config{ + Data: map[string]interface{}{ + "indices": map[string]interface{}{ + "recovery.max_bytes_per_sec": "50mb", + }, + }, + }, + }, + }, + } + + // Policy with weight 10 (higher priority) - should override cluster.name and settings + highPriorityPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("high-priority-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 10, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "elasticsearch"}, + }, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "cluster": map[string]interface{}{ + "name": "high-priority-cluster", + }, + }, + }, + ClusterSettings: &commonv1.Config{ + Data: map[string]interface{}{ + "indices": map[string]interface{}{ + "recovery": map[string]interface{}{ + "max_bytes_per_sec": "200mb", + }, + }, + }, + }, + }, + }, + } + + // Policy with same weight 20 but different selector (should not conflict) + nonConflictingPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("non-conflicting-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, // Different selector + }, + Elasticsearch: policyv1alpha1.ElasticsearchConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "cluster.name": "should-not-apply", + }, + }, + }, + }, + } + + esWithlicense := test.LicenseTestBuilder(es) + + steps := func(k *test.K8sClient) test.StepList { + return test.StepList{ + test.Step{ + Name: "Create low priority StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&lowPriorityPolicy) + }), + }, + test.Step{ + Name: "Create high priority StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&highPriorityPolicy) + }), + }, + test.Step{ + Name: "Create non-conflicting StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&nonConflictingPolicy) + }), + }, + test.Step{ + Name: "High priority cluster name should be applied", + Test: test.Eventually(func() error { + esClient, err := elasticsearch.NewElasticsearchClient(es.Elasticsearch, k) + if err != nil { + return err + } + + var apiResponse ClusterInfoResponse + if _, _, err = request(esClient, http.MethodGet, "/", nil, &apiResponse); err != nil { + return err + } + + if apiResponse.ClusterName != "high-priority-cluster" { + return fmt.Errorf("expected cluster name 'high-priority-cluster', got '%s'", apiResponse.ClusterName) + } + return nil + }), + }, + test.Step{ + Name: "High priority cluster settings should be applied", + Test: test.Eventually(func() error { + esClient, err := elasticsearch.NewElasticsearchClient(es.Elasticsearch, k) + if err != nil { + return err + } + + var settings ClusterSettings + _, _, err = request(esClient, http.MethodGet, "/_cluster/settings", nil, &settings) + if err != nil { + return err + } + + if settings.Persistent.Indices.Recovery.MaxBytesPerSec != "200mb" { + return fmt.Errorf("expected max_bytes_per_sec '200mb', got '%s'", settings.Persistent.Indices.Recovery.MaxBytesPerSec) + } + return nil + }), + }, + test.Step{ + Name: "Delete high priority policy - low priority should take effect", + Test: test.Eventually(func() error { + return k.Client.Delete(context.Background(), &highPriorityPolicy) + }), + }, + test.Step{ + Name: "Low priority cluster name should now be applied", + Test: test.Eventually(func() error { + esClient, err := elasticsearch.NewElasticsearchClient(es.Elasticsearch, k) + if err != nil { + return err + } + + var apiResponse ClusterInfoResponse + if _, _, err = request(esClient, http.MethodGet, "/", nil, &apiResponse); err != nil { + return err + } + + if apiResponse.ClusterName != "low-priority-cluster" { + return fmt.Errorf("expected cluster name 'low-priority-cluster', got '%s'", apiResponse.ClusterName) + } + return nil + }), + }, + test.Step{ + Name: "Low priority cluster settings should now be applied", + Test: test.Eventually(func() error { + esClient, err := elasticsearch.NewElasticsearchClient(es.Elasticsearch, k) + if err != nil { + return err + } + + var settings ClusterSettings + _, _, err = request(esClient, http.MethodGet, "/_cluster/settings", nil, &settings) + if err != nil { + return err + } + + if settings.Persistent.Indices.Recovery.MaxBytesPerSec != "50mb" { + return fmt.Errorf("expected max_bytes_per_sec '50mb', got '%s'", settings.Persistent.Indices.Recovery.MaxBytesPerSec) + } + return nil + }), + }, + test.Step{ + Name: "Clean up remaining policies", + Test: test.Eventually(func() error { + if err := k.Client.Delete(context.Background(), &lowPriorityPolicy); err != nil { + return err + } + return k.Client.Delete(context.Background(), &nonConflictingPolicy) + }), + }, + } + } + + test.Sequence(nil, steps, esWithlicense).RunSequential(t) +} + func checkAPIStatusCode(esClient client.Client, url string, expectedStatusCode int) error { var items map[string]interface{} _, actualStatusCode, _ := request(esClient, http.MethodGet, url, nil, &items) diff --git a/test/e2e/kb/stackconfigpolicy_test.go b/test/e2e/kb/stackconfigpolicy_test.go index a8a2644d845..704db02c6da 100644 --- a/test/e2e/kb/stackconfigpolicy_test.go +++ b/test/e2e/kb/stackconfigpolicy_test.go @@ -125,3 +125,190 @@ func TestStackConfigPolicyKibana(t *testing.T) { test.Sequence(nil, steps, esWithlicense, kbBuilder).RunSequential(t) } + +// TestStackConfigPolicyKibanaMultipleWeights tests multiple StackConfigPolicies with different weights for Kibana. +func TestStackConfigPolicyKibanaMultipleWeights(t *testing.T) { + // only execute this test if we have a test license to work with + if test.Ctx().TestLicense == "" { + t.SkipNow() + } + + namespace := test.Ctx().ManagedNamespace(0) + // set up a 1-node Kibana deployment + name := "test-kb-scp-multi" + esBuilder := elasticsearch.NewBuilder(name). + WithESMasterDataNodes(1, elasticsearch.DefaultResources) + kbBuilder := kibana.NewBuilder(name). + WithElasticsearchRef(esBuilder.Ref()). + WithNodeCount(1).WithLabel("app", "kibana") + + kbPodListOpts := test.KibanaPodListOptions(kbBuilder.Kibana.Namespace, kbBuilder.Kibana.Name) + + // Policy with weight 20 (lower priority) + lowPriorityPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("low-priority-kb-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "server.customResponseHeaders": map[string]interface{}{ + "priority": "low", + "test-header": "low-priority-value", + }, + }, + }, + SecureSettings: []commonv1.SecretSource{ + {SecretName: fmt.Sprintf("low-priority-secret-%s", rand.String(4))}, + }, + }, + }, + } + + // Policy with weight 10 (higher priority) - should override lower priority settings + highPriorityPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("high-priority-kb-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 10, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "kibana"}, + }, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "server.customResponseHeaders": map[string]interface{}{ + "priority": "high", + "test-header": "high-priority-value", + }, + }, + }, + SecureSettings: []commonv1.SecretSource{ + {SecretName: fmt.Sprintf("high-priority-secret-%s", rand.String(4))}, + }, + }, + }, + } + + // Policy with same weight 20 but different selector (should not conflict) + nonConflictingPolicy := policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: fmt.Sprintf("non-conflicting-kb-scp-%s", rand.String(4)), + }, + Spec: policyv1alpha1.StackConfigPolicySpec{ + Weight: 20, + ResourceSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "elasticsearch"}, // Different selector + }, + Kibana: policyv1alpha1.KibanaConfigPolicySpec{ + Config: &commonv1.Config{ + Data: map[string]interface{}{ + "server.customResponseHeaders": map[string]interface{}{ + "priority": "should-not-apply", + }, + }, + }, + }, + }, + } + + // Create secure settings secrets + lowPrioritySecret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: lowPriorityPolicy.Spec.Kibana.SecureSettings[0].SecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + "elasticsearch.pingTimeout": []byte("30000"), + }, + } + + highPrioritySecret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: highPriorityPolicy.Spec.Kibana.SecureSettings[0].SecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + "elasticsearch.requestTimeout": []byte("30000"), + }, + } + + esWithlicense := test.LicenseTestBuilder(esBuilder) + + steps := func(k *test.K8sClient) test.StepList { + kibanaChecks := kibana.KbChecks{ + Client: k, + } + return test.StepList{ + test.Step{ + Name: "Create secure settings secrets", + Test: test.Eventually(func() error { + if err := k.CreateOrUpdate(&lowPrioritySecret); err != nil { + return err + } + return k.CreateOrUpdate(&highPrioritySecret) + }), + }, + test.Step{ + Name: "Create low priority StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&lowPriorityPolicy) + }), + }, + test.Step{ + Name: "Create high priority StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&highPriorityPolicy) + }), + }, + test.Step{ + Name: "Create non-conflicting StackConfigPolicy", + Test: test.Eventually(func() error { + return k.CreateOrUpdate(&nonConflictingPolicy) + }), + }, + // High priority settings should be applied + kibanaChecks.CheckHeaderForKey(kbBuilder, "priority", "high"), + kibanaChecks.CheckHeaderForKey(kbBuilder, "test-header", "high-priority-value"), + // High priority secure settings should be in keystore + test.CheckKeystoreEntries(k, KibanaKeystoreCmd, []string{"elasticsearch.pingTimeout", "elasticsearch.requestTimeout"}, kbPodListOpts...), + test.Step{ + Name: "Delete high priority policy - low priority should take effect", + Test: test.Eventually(func() error { + return k.Client.Delete(context.Background(), &highPriorityPolicy) + }), + }, + // Low priority settings should now be applied + kibanaChecks.CheckHeaderForKey(kbBuilder, "priority", "low"), + kibanaChecks.CheckHeaderForKey(kbBuilder, "test-header", "low-priority-value"), + // Low priority secure settings should be in keystore + test.CheckKeystoreEntries(k, KibanaKeystoreCmd, []string{"elasticsearch.pingTimeout"}, kbPodListOpts...), + test.Step{ + Name: "Clean up remaining policies and secrets", + Test: test.Eventually(func() error { + if err := k.Client.Delete(context.Background(), &lowPriorityPolicy); err != nil { + return err + } + if err := k.Client.Delete(context.Background(), &nonConflictingPolicy); err != nil { + return err + } + if err := k.Client.Delete(context.Background(), &lowPrioritySecret); err != nil { + return err + } + return k.Client.Delete(context.Background(), &highPrioritySecret) + }), + }, + } + } + + test.Sequence(nil, steps, esWithlicense, kbBuilder).RunSequential(t) +}