@@ -29,11 +29,13 @@ import (
2929 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/hash"
3030 commonlabels "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/labels"
3131 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license"
32+ "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator"
3233 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler"
3334 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches"
3435 esclient "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/client"
3536 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/filesettings"
3637 "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label"
38+ eslabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label"
3739 "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s"
3840 "github.com/elastic/cloud-on-k8s/v3/pkg/utils/net"
3941)
@@ -676,6 +678,279 @@ func TestReconcileStackConfigPolicy_Reconcile(t *testing.T) {
676678 }
677679}
678680
681+ func TestReconcileStackConfigPolicy_MultipleStackConfigPolicies (t * testing.T ) {
682+ // Setup: Create an Elasticsearch cluster and multiple StackConfigPolicies with different weights
683+ esFixture := esv1.Elasticsearch {
684+ ObjectMeta : metav1.ObjectMeta {
685+ Namespace : "ns" ,
686+ Name : "test-es" ,
687+ Labels : map [string ]string {"env" : "prod" },
688+ },
689+ Spec : esv1.ElasticsearchSpec {Version : "8.6.1" },
690+ }
691+
692+ // Policy with weight 10 (applied first)
693+ policy1 := policyv1alpha1.StackConfigPolicy {
694+ ObjectMeta : metav1.ObjectMeta {
695+ Namespace : "ns" ,
696+ Name : "policy-low" ,
697+ },
698+ Spec : policyv1alpha1.StackConfigPolicySpec {
699+ Weight : 10 ,
700+ ResourceSelector : metav1.LabelSelector {MatchLabels : map [string ]string {"env" : "prod" }},
701+ Elasticsearch : policyv1alpha1.ElasticsearchConfigPolicySpec {
702+ ClusterSettings : & commonv1.Config {Data : map [string ]interface {}{
703+ "indices.recovery.max_bytes_per_sec" : "40mb" ,
704+ }},
705+ Config : & commonv1.Config {Data : map [string ]interface {}{
706+ "logger.org.elasticsearch.discovery" : "INFO" ,
707+ }},
708+ },
709+ },
710+ }
711+
712+ // Policy with weight 20 (applied second, overrides policy1)
713+ policy2 := policyv1alpha1.StackConfigPolicy {
714+ ObjectMeta : metav1.ObjectMeta {
715+ Namespace : "ns" ,
716+ Name : "policy-high" ,
717+ },
718+ Spec : policyv1alpha1.StackConfigPolicySpec {
719+ Weight : 20 ,
720+ ResourceSelector : metav1.LabelSelector {MatchLabels : map [string ]string {"env" : "prod" }},
721+ Elasticsearch : policyv1alpha1.ElasticsearchConfigPolicySpec {
722+ ClusterSettings : & commonv1.Config {Data : map [string ]interface {}{
723+ "indices.recovery.max_bytes_per_sec" : "50mb" , // Overrides policy1
724+ }},
725+ Config : & commonv1.Config {Data : map [string ]interface {}{
726+ "logger.org.elasticsearch.gateway" : "DEBUG" , // Additional setting
727+ }},
728+ SecretMounts : []policyv1alpha1.SecretMount {
729+ {
730+ SecretName : "test-secret" ,
731+ MountPath : "/usr/test" ,
732+ },
733+ },
734+ },
735+ },
736+ }
737+
738+ // Policy with same weight as policy2 (should cause conflict)
739+ policy3Conflicting := policyv1alpha1.StackConfigPolicy {
740+ ObjectMeta : metav1.ObjectMeta {
741+ Namespace : "ns" ,
742+ Name : "policy-conflict" ,
743+ },
744+ Spec : policyv1alpha1.StackConfigPolicySpec {
745+ Weight : 20 , // Same weight as policy2
746+ ResourceSelector : metav1.LabelSelector {MatchLabels : map [string ]string {"env" : "prod" }},
747+ Elasticsearch : policyv1alpha1.ElasticsearchConfigPolicySpec {
748+ ClusterSettings : & commonv1.Config {Data : map [string ]interface {}{
749+ "indices.recovery.max_bytes_per_sec" : "60mb" ,
750+ }},
751+ },
752+ },
753+ }
754+
755+ // Initial empty file settings secret (will be populated by controller)
756+ esFileSettingsSecret := corev1.Secret {
757+ ObjectMeta : metav1.ObjectMeta {
758+ Namespace : "ns" ,
759+ Name : "test-es-es-file-settings" ,
760+ Labels : map [string ]string {
761+ commonv1 .TypeLabelName : "elasticsearch" ,
762+ eslabel .ClusterNameLabelName : "test-es" ,
763+ commonlabels .StackConfigPolicyOnDeleteLabelName : commonlabels .OrphanSecretResetOnPolicyDelete ,
764+ },
765+ },
766+ 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":{}}}}` )},
767+ }
768+
769+ esPod := getEsPod ("ns" , map [string ]string {})
770+
771+ // Source secret that will be mounted (exists in policy namespace)
772+ sourceSecret := corev1.Secret {
773+ ObjectMeta : metav1.ObjectMeta {
774+ Name : "test-secret" ,
775+ Namespace : "ns" ,
776+ },
777+ Data : map [string ][]byte {
778+ "key1" : []byte ("value1" ),
779+ },
780+ }
781+
782+ tests := []struct {
783+ name string
784+ policies []policyv1alpha1.StackConfigPolicy
785+ reconcilePolicy string // Which policy to reconcile
786+ wantResources int
787+ wantReady int
788+ wantPhase policyv1alpha1.PolicyPhase
789+ wantErr bool
790+ wantRequeueAfter bool
791+ validateSettings func (t * testing.T , r ReconcileStackConfigPolicy )
792+ }{
793+ {
794+ name : "Multiple policies with different weights merge successfully" ,
795+ policies : []policyv1alpha1.StackConfigPolicy {policy1 , policy2 },
796+ reconcilePolicy : "policy-low" ,
797+ wantResources : 1 ,
798+ wantReady : 0 ,
799+ wantPhase : policyv1alpha1 .ApplyingChangesPhase ,
800+ wantErr : false ,
801+ wantRequeueAfter : true ,
802+ validateSettings : func (t * testing.T , r ReconcileStackConfigPolicy ) {
803+ // Verify the file settings secret was updated
804+ esNsn := k8s .ExtractNamespacedName (& esFixture )
805+
806+ settingsSecretNsn := types.NamespacedName {
807+ Namespace : esNsn .Namespace ,
808+ Name : esv1 .FileSettingsSecretName (esNsn .Name ),
809+ }
810+
811+ var secret corev1.Secret
812+ err := r .Client .Get (context .Background (), settingsSecretNsn , & secret )
813+ require .NoError (t , err )
814+
815+ // Verify the file settings secret has merged config
816+ settings := r .getSettings (t , settingsSecretNsn )
817+
818+ // Check if ClusterSettings was populated
819+ if settings .State .ClusterSettings == nil {
820+ t .Logf ("Secret data: %s" , string (secret .Data ["settings.json" ]))
821+ t .Fatal ("ClusterSettings should not be nil after reconciliation" )
822+ }
823+ require .NotNil (t , settings .State .ClusterSettings .Data , "ClusterSettings.Data should not be nil" )
824+
825+ // Should have the value from policy2 (higher weight)
826+ assert .EqualValues (t , map [string ]any {
827+ "indices" : map [string ]any {
828+ "recovery" : map [string ]any {
829+ "max_bytes_per_sec" : "40mb" ,
830+ },
831+ },
832+ }, settings .State .ClusterSettings .Data , "ClusterSettings.Data" )
833+
834+ owners , err := getSoftOwnerPolicies (& secret )
835+ assert .NoError (t , err )
836+ assert .Len (t , owners , 2 , "esConfigSecret should be owned by 2 policies" )
837+ // Verify both policies are in the owner list
838+ assert .Contains (t , owners , types.NamespacedName {Namespace : "ns" , Name : "policy-low" }, "policy-low should be an owner of esConfigSecret" )
839+ assert .Contains (t , owners , types.NamespacedName {Namespace : "ns" , Name : "policy-high" }, "policy-high should be an owner of esConfigSecret" )
840+ },
841+ },
842+ {
843+ name : "Policies with same weight cause conflict" ,
844+ policies : []policyv1alpha1.StackConfigPolicy {policy2 , policy3Conflicting },
845+ reconcilePolicy : "policy-high" ,
846+ wantResources : 1 ,
847+ wantReady : 0 ,
848+ wantPhase : policyv1alpha1 .ConflictPhase ,
849+ wantErr : false ,
850+ wantRequeueAfter : true ,
851+ validateSettings : func (t * testing.T , r ReconcileStackConfigPolicy ) {
852+ // Verify policy status shows conflict
853+ policy := r .getPolicy (t , types.NamespacedName {Namespace : "ns" , Name : "policy-high" })
854+
855+ esStatus := policy .Status .Details ["elasticsearch" ]["ns/test-es" ]
856+ assert .Equal (t , policyv1alpha1 .ConflictPhase , esStatus .Phase )
857+ },
858+ },
859+ {
860+ name : "Reconciling second policy sees merged state" ,
861+ policies : []policyv1alpha1.StackConfigPolicy {policy1 , policy2 },
862+ reconcilePolicy : "policy-high" ,
863+ wantResources : 1 ,
864+ wantReady : 0 ,
865+ wantPhase : policyv1alpha1 .ApplyingChangesPhase ,
866+ wantErr : false ,
867+ wantRequeueAfter : true ,
868+ validateSettings : func (t * testing.T , r ReconcileStackConfigPolicy ) {
869+ // Verify elasticsearch config secret exists with merged config
870+ var esConfigSecret corev1.Secret
871+ err := r .Client .Get (context .Background (), types.NamespacedName {
872+ Namespace : "ns" ,
873+ Name : esv1 .StackConfigElasticsearchConfigSecretName ("test-es" ),
874+ }, & esConfigSecret )
875+ assert .NoError (t , err )
876+
877+ // Verify elasticsearch config secret is soft-owned by multiple policies
878+ owners , err := getSoftOwnerPolicies (& esConfigSecret )
879+ assert .NoError (t , err )
880+ assert .Len (t , owners , 2 , "esConfigSecret should be owned by 2 policies" )
881+ // Verify both policies are in the owner list
882+ assert .Contains (t , owners , types.NamespacedName {Namespace : "ns" , Name : "policy-low" }, "policy-low should be an owner of esConfigSecret" )
883+ assert .Contains (t , owners , types.NamespacedName {Namespace : "ns" , Name : "policy-high" }, "policy-high should be an owner of esConfigSecret" )
884+
885+ // Verify secret mount secret exists
886+ var secretMountSecret corev1.Secret
887+ err = r .Client .Get (context .Background (), types.NamespacedName {
888+ Namespace : "ns" ,
889+ Name : esv1 .StackConfigAdditionalSecretName ("test-es" , "test-secret" ),
890+ }, & secretMountSecret )
891+ assert .NoError (t , err )
892+
893+ // Verify secret mount secret is soft-owned by SINGLE policy (the one that defines SecretMounts)
894+ // Only policy-high has SecretMounts, so it should be the only owner
895+ owners , err = getSoftOwnerPolicies (& secretMountSecret )
896+ assert .NoError (t , err )
897+ assert .Len (t , owners , 1 , "secretMountSecret should be owned by 1 policy" )
898+ // Verify only policy-high is the owner
899+ assert .Contains (t , owners , types.NamespacedName {Namespace : "ns" , Name : "policy-high" }, "policy-high should be an owner of secretMountSecret" )
900+ },
901+ },
902+ }
903+
904+ for _ , tt := range tests {
905+ t .Run (tt .name , func (t * testing.T ) {
906+ // Create client with all resources
907+ clientObjects := []client.Object {& esFixture , & esFileSettingsSecret , esPod , & sourceSecret }
908+ for i := range tt .policies {
909+ clientObjects = append (clientObjects , & tt .policies [i ])
910+ }
911+
912+ fakeRecorder := record .NewFakeRecorder (100 )
913+ reconciler := ReconcileStackConfigPolicy {
914+ Client : k8s .NewFakeClient (clientObjects ... ),
915+ esClientProvider : fakeClientProvider (esclient.FileSettings {Version : 1 }, nil ),
916+ recorder : fakeRecorder ,
917+ licenseChecker : & license.MockLicenseChecker {EnterpriseEnabled : true },
918+ params : operator.Parameters {
919+ OperatorNamespace : "elastic-system" ,
920+ },
921+ dynamicWatches : watches .NewDynamicWatches (),
922+ }
923+
924+ // Reconcile the specified policy
925+ got , err := reconciler .Reconcile (context .Background (), reconcile.Request {
926+ NamespacedName : types.NamespacedName {
927+ Namespace : "ns" ,
928+ Name : tt .reconcilePolicy ,
929+ },
930+ })
931+
932+ if (err != nil ) != tt .wantErr {
933+ t .Errorf ("Reconcile() error = %v, wantErr %v" , err , tt .wantErr )
934+ return
935+ }
936+ if (got .RequeueAfter > 0 ) != tt .wantRequeueAfter {
937+ t .Errorf ("Reconcile() got = %v, wantRequeueAfter %v" , got , tt .wantRequeueAfter )
938+ }
939+
940+ // Verify policy status
941+ policy := reconciler .getPolicy (t , types.NamespacedName {Namespace : "ns" , Name : tt .reconcilePolicy })
942+ assert .Equal (t , tt .wantResources , policy .Status .Resources )
943+ assert .Equal (t , tt .wantReady , policy .Status .Ready )
944+ assert .Equal (t , tt .wantPhase , policy .Status .Phase )
945+
946+ // Run custom validation if provided
947+ if tt .validateSettings != nil {
948+ tt .validateSettings (t , reconciler )
949+ }
950+ })
951+ }
952+ }
953+
679954func Test_cleanStackTrace (t * testing.T ) {
680955 stacktrace := "Error processing slm state change: java.lang.IllegalArgumentException: Error on validating SLM requests\n \t at [email protected] /org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.prepare(ReservedSnapshotAction.java:66)\n \t at [email protected] /org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.transform(ReservedSnapshotAction.java:77)\n \t at [email protected] /org.elasticsearch.reservedstate.service.ReservedClusterStateService.trialRun(ReservedClusterStateService.java:328)\n \t at [email protected] /org.elasticsearch.reservedstate.service.ReservedClusterStateService.process(ReservedClusterStateService.java:169)\n \t at [email protected] /org.elasticsearch.reservedstate.service.ReservedClusterStateService.process(ReservedClusterStateService.java:122)\n \t at [email protected] /org.elasticsearch.reservedstate.service.FileSettingsService.processFileSettings(FileSettingsService.java:389)\n \t at [email protected] /org.elasticsearch.reservedstate.service.FileSettingsService.lambda$startWatcher$3(FileSettingsService.java:312)\n \t at java.base/java.lang.Thread.run(Thread.java:833)\n \t Suppressed: java.lang.IllegalArgumentException: no such repository [badrepo]\n \t \t at [email protected] /org.elasticsearch.xpack.slm.SnapshotLifecycleService.validateRepositoryExists(SnapshotLifecycleService.java:244)\n \t \t at [email protected] /org.elasticsearch.xpack.slm.action.ReservedSnapshotAction.prepare(ReservedSnapshotAction.java:57)\n \t \t ... 7 more\n " 681956 err := cleanStackTrace ([]string {stacktrace })
0 commit comments