diff --git a/api/v1beta1/solrcloud_types.go b/api/v1beta1/solrcloud_types.go index 995a9acd..83fc74b1 100644 --- a/api/v1beta1/solrcloud_types.go +++ b/api/v1beta1/solrcloud_types.go @@ -19,12 +19,13 @@ package v1beta1 import ( "fmt" - "github.com/go-logr/logr" - zkApi "github.com/pravega/zookeeper-operator/api/v1beta1" "math/rand" "strconv" "strings" + "github.com/go-logr/logr" + zkApi "github.com/pravega/zookeeper-operator/api/v1beta1" + "k8s.io/apimachinery/pkg/util/intstr" corev1 "k8s.io/api/core/v1" @@ -681,7 +682,8 @@ type ManagedUpdateOptions struct { // The maximum number of pods that can be unavailable during the update. // Value can be an absolute number (ex: 5) or a percentage of the desired number of pods (ex: 10%). // Absolute number is calculated from percentage by rounding down. - // If the provided number is 0 or negative, then all pods will be allowed to be updated in unison. + // If the provided number is 0, then all pods will be allowed to be updated in unison. + // Negatives are not allowed. // // Defaults to 25%. // @@ -691,7 +693,8 @@ type ManagedUpdateOptions struct { // The maximum number of replicas for each shard that can be unavailable during the update. // Value can be an absolute number (ex: 5) or a percentage of replicas in a shard (ex: 25%). // Absolute number is calculated from percentage by rounding down. - // If the provided number is 0 or negative, then all replicas will be allowed to be updated in unison. + // If the provided number is 0 , then all replicas will be allowed to be updated in unison. + // Negatives are not allowed. // // Defaults to 1. // @@ -1621,6 +1624,15 @@ const ( Basic AuthenticationType = "Basic" ) +type BootstrapSecurityJson struct { + SecurityJsonSecret *corev1.SecretKeySelector `json:"bootstrapSecurityJson,omitempty"` + + // Flag to indicate if the operator should overwrite an existing security.json if there are changes + // as compared to the underlying secret + // +optional + Overwrite bool `json:"probesRequireAuth,omitempty"` +} + type SolrSecurityOptions struct { // Indicates the authentication plugin type that is being used by Solr; for now only "Basic" is supported by the // Solr operator but support for other authentication plugins may be added in the future. @@ -1649,7 +1661,5 @@ type SolrSecurityOptions struct { // Configure a user-provided security.json from a secret to allow for advanced security config. // If not specified, the operator bootstraps a security.json with basic auth enabled. - // This is a bootstrapping config only; once Solr is initialized, the security config should be managed by the security API. - // +optional - BootstrapSecurityJson *corev1.SecretKeySelector `json:"bootstrapSecurityJson,omitempty"` + BootstrapSecurityJson *BootstrapSecurityJson `json:"bootstrapSecurityJson,omitempty"` } diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index d48f048e..64416651 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -64,6 +64,26 @@ func (in *BackupRecurrence) DeepCopy() *BackupRecurrence { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootstrapSecurityJson) DeepCopyInto(out *BootstrapSecurityJson) { + *out = *in + if in.SecurityJsonSecret != nil { + in, out := &in.SecurityJsonSecret, &out.SecurityJsonSecret + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootstrapSecurityJson. +func (in *BootstrapSecurityJson) DeepCopy() *BootstrapSecurityJson { + if in == nil { + return nil + } + out := new(BootstrapSecurityJson) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CollectionBackupStatus) DeepCopyInto(out *CollectionBackupStatus) { *out = *in @@ -1266,7 +1286,7 @@ func (in *SolrSecurityOptions) DeepCopyInto(out *SolrSecurityOptions) { *out = *in if in.BootstrapSecurityJson != nil { in, out := &in.BootstrapSecurityJson, &out.BootstrapSecurityJson - *out = new(v1.SecretKeySelector) + *out = new(BootstrapSecurityJson) (*in).DeepCopyInto(*out) } } diff --git a/config/crd/bases/solr.apache.org_solrclouds.yaml b/config/crd/bases/solr.apache.org_solrclouds.yaml index 38abb604..18354806 100644 --- a/config/crd/bases/solr.apache.org_solrclouds.yaml +++ b/config/crd/bases/solr.apache.org_solrclouds.yaml @@ -9385,26 +9385,34 @@ spec: description: |- Configure a user-provided security.json from a secret to allow for advanced security config. If not specified, the operator bootstraps a security.json with basic auth enabled. - This is a bootstrapping config only; once Solr is initialized, the security config should be managed by the security API. properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: + bootstrapSecurityJson: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid? + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + overwrite: description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret or its key must be - defined + Flag to indicate if the operator should overwrite an existing security.json if there are changes + as compared to the underlying secret type: boolean - required: - - key type: object - x-kubernetes-map-type: atomic probesRequireAuth: description: |- Flag to indicate if the configured HTTP endpoint(s) used for the probes require authentication; defaults @@ -9593,7 +9601,8 @@ spec: The maximum number of pods that can be unavailable during the update. Value can be an absolute number (ex: 5) or a percentage of the desired number of pods (ex: 10%). Absolute number is calculated from percentage by rounding down. - If the provided number is 0 or negative, then all pods will be allowed to be updated in unison. + If the provided number is 0, then all pods will be allowed to be updated in unison. + Negatives are not allowed. Defaults to 25%. @@ -9606,7 +9615,8 @@ spec: The maximum number of replicas for each shard that can be unavailable during the update. Value can be an absolute number (ex: 5) or a percentage of replicas in a shard (ex: 25%). Absolute number is calculated from percentage by rounding down. - If the provided number is 0 or negative, then all replicas will be allowed to be updated in unison. + If the provided number is 0 , then all replicas will be allowed to be updated in unison. + Negatives are not allowed. Defaults to 1. diff --git a/controllers/solrcloud_controller_basic_auth_test.go b/controllers/solrcloud_controller_basic_auth_test.go index c271393d..0df640d5 100644 --- a/controllers/solrcloud_controller_basic_auth_test.go +++ b/controllers/solrcloud_controller_basic_auth_test.go @@ -177,9 +177,12 @@ var _ = FDescribe("SolrCloud controller - Basic Auth", func() { solrCloud.Spec.SolrSecurity = &solrv1beta1.SolrSecurityOptions{ AuthenticationType: solrv1beta1.Basic, BasicAuthSecret: basicAuthSecretName, - BootstrapSecurityJson: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-security-json"}, - Key: util.SecurityJsonFile, + BootstrapSecurityJson: &solrv1beta1.BootstrapSecurityJson{ + SecurityJsonSecret: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-security-json"}, + Key: util.SecurityJsonFile, + }, + Overwrite: false, }, } }) @@ -324,10 +327,11 @@ func expectBasicAuthConfigOnPodTemplateWithGomega(g Gomega, solrCloud *solrv1bet // if the zookeeperRef has ACLs set, verify the env vars were set correctly for this initContainer allACL, _ := solrCloud.Spec.ZookeeperRef.GetACLs() if allACL != nil { - g.Expect(expInitContainer.Env).To(HaveLen(10), "Wrong number of env vars using ACLs and Basic Auth") - g.Expect(expInitContainer.Env[len(expInitContainer.Env)-2].Name).To(Equal("SOLR_OPTS"), "Env var SOLR_OPTS is misplaced the Solr Pod env vars") - g.Expect(expInitContainer.Env[len(expInitContainer.Env)-1].Name).To(Equal("SECURITY_JSON"), "Env var SECURITY_JSON is misplaced the Solr Pod env vars") - testACLEnvVarsWithGomega(g, expInitContainer.Env[3:len(expInitContainer.Env)-2], true) + g.Expect(expInitContainer.Env).To(HaveLen(11), "Wrong number of env vars using ACLs and Basic Auth") + g.Expect(expInitContainer.Env[len(expInitContainer.Env)-3].Name).To(Equal("SOLR_OPTS"), "Env var SOLR_OPTS is misplaced the Solr Pod env vars") + g.Expect(expInitContainer.Env[len(expInitContainer.Env)-2].Name).To(Equal("SECURITY_JSON"), "Env var SECURITY_JSON is misplaced the Solr Pod env vars") + g.Expect(expInitContainer.Env[len(expInitContainer.Env)-1].Name).To(Equal("SECURITY_JSON_OVERWRITE"), "Env var SECURITY_JSON_OVERWRITE is misplaced the Solr Pod env vars") + testACLEnvVarsWithGomega(g, expInitContainer.Env[3:len(expInitContainer.Env)-3], true) } // else this ref not using ACLs expectPutSecurityJsonInZkCmd(g, expInitContainer) @@ -353,9 +357,15 @@ func expectPutSecurityJsonInZkCmd(g Gomega, expInitContainer *corev1.Container) g.Expect(expInitContainer).To(Not(BeNil()), "Didn't find the setup-zk InitContainer in the sts!") expCmd := "solr zk cp zk:/security.json /tmp/current_security.json >/dev/null 2>&1; " + "GET_CURRENT_SECURITY_JSON_EXIT_CODE=$?; if [ ${GET_CURRENT_SECURITY_JSON_EXIT_CODE} -eq 0 ]; then " + - "if [ ! -s /tmp/current_security.json ] || grep -q '^{}$' /tmp/current_security.json ]; then " + + "if [ ! -s /tmp/current_security.json ] || grep -q '^{}$' /tmp/current_security.json; then " + "echo $SECURITY_JSON > /tmp/security.json; solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + - " echo 'Blank security.json found. Put new security.json in ZK'; fi; elif [ ${GET_CURRENT_SECURITY_JSON_EXIT_CODE} -eq 1 ]; then " + + " echo 'Blank security.json found. Put new security.json in ZK'; " + + "elif [ \"${SECURITY_JSON_OVERWRITE}\" = true ] && [ \"$(cat /tmp/current_security.json)\" != \"$(echo $SECURITY_JSON)\" ]; then " + + " echo $SECURITY_JSON > /tmp/security.json; solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + + " echo 'Diff found. Overwriting security.json in ZK'; " + + " else " + + " echo 'Not overwriting security.json'; fi; " + + "elif [ ${GET_CURRENT_SECURITY_JSON_EXIT_CODE} -eq 1 ]; then " + " echo $SECURITY_JSON > /tmp/security.json; solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + " echo 'No security.json found. Put new security.json in ZK'; fi" g.Expect(expInitContainer.Command[2]).To(ContainSubstring(expCmd), "setup-zk initContainer not configured to bootstrap security.json!") diff --git a/controllers/solrcloud_controller_tls_test.go b/controllers/solrcloud_controller_tls_test.go index 374f28f7..f706710d 100644 --- a/controllers/solrcloud_controller_tls_test.go +++ b/controllers/solrcloud_controller_tls_test.go @@ -937,7 +937,7 @@ func expectZkSetupInitContainerForTLSWithGomega(g Gomega, solrCloud *solrv1beta1 g.Expect(zkSetupInitContainer.Command[2]).To(ContainSubstring(expChrootCmd), "ZK Setup command does init the chroot") expNumVars := 3 if solrCloud.Spec.SolrSecurity != nil && solrCloud.Spec.SolrSecurity.BasicAuthSecret == "" { - expNumVars = 4 // one more for SECURITY_JSON + expNumVars = 5 // two more for SECURITY_JSON and SECURITY_JSON_OVERWRITE } g.Expect(zkSetupInitContainer.Env).To(HaveLen(expNumVars), "Wrong number of envVars for zk-setup init container") } diff --git a/controllers/util/solr_security_util.go b/controllers/util/solr_security_util.go index 2eb8679c..dd93d9c5 100644 --- a/controllers/util/solr_security_util.go +++ b/controllers/util/solr_security_util.go @@ -23,6 +23,11 @@ import ( b64 "encoding/base64" "encoding/json" "fmt" + "math/rand" + "regexp" + "strings" + "time" + solr "github.com/apache/solr-operator/api/v1beta1" "github.com/apache/solr-operator/controllers/util/solr_api" appsv1 "k8s.io/api/apps/v1" @@ -30,12 +35,8 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "math/rand" - "regexp" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "strings" - "time" ) const ( @@ -49,10 +50,11 @@ const ( // Utility struct holding security related config and objects resolved at runtime needed during reconciliation, // such as the secret holding credentials the operator should use to make calls to secure Solr type SecurityConfig struct { - SolrSecurity *solr.SolrSecurityOptions - CredentialsSecret *corev1.Secret - SecurityJson string - SecurityJsonSrc *corev1.EnvVarSource + SolrSecurity *solr.SolrSecurityOptions + CredentialsSecret *corev1.Secret + SecurityJson string + SecurityJsonSrc *corev1.EnvVarSource + SecurityJsonOverwrite bool } // Given a SolrCloud instance and an API service client, produce a SecurityConfig needed to enable Solr security @@ -117,6 +119,7 @@ func reconcileForBasicAuthWithBootstrappedSecurityJson(ctx context.Context, clie // supply the bootstrap security.json to the initContainer via a simple BASE64 encoding env var security.SecurityJson = string(bootstrapSecret.Data[SecurityJsonFile]) + security.SecurityJsonOverwrite = false basicAuthSecret = authSecret } @@ -136,6 +139,7 @@ func reconcileForBasicAuthWithBootstrappedSecurityJson(ctx context.Context, clie } else { // stash this so we can configure the setup-zk initContainer to bootstrap the security.json in ZK security.SecurityJson = string(bootstrapSecret.Data[SecurityJsonFile]) + security.SecurityJsonOverwrite = false security.SecurityJsonSrc = &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: bootstrapSecret.Name}, Key: SecurityJsonFile}} @@ -166,13 +170,14 @@ func reconcileForBasicAuthWithUserProvidedSecret(ctx context.Context, client *cl // is there a user-provided security.json in a secret? // in this config, we don't need to enforce the user providing a security.json as they can bootstrap the security.json however they want - if sec.BootstrapSecurityJson != nil { - securityJson, err := loadSecurityJsonFromSecret(ctx, client, sec.BootstrapSecurityJson, instance.Namespace) + if sec.BootstrapSecurityJson != nil && sec.BootstrapSecurityJson.SecurityJsonSecret != nil { + securityJson, err := loadSecurityJsonFromSecret(ctx, client, sec.BootstrapSecurityJson.SecurityJsonSecret, instance.Namespace) if err != nil { return nil, err } security.SecurityJson = securityJson - security.SecurityJsonSrc = &corev1.EnvVarSource{SecretKeyRef: sec.BootstrapSecurityJson} + security.SecurityJsonSrc = &corev1.EnvVarSource{SecretKeyRef: sec.BootstrapSecurityJson.SecurityJsonSecret} + security.SecurityJsonOverwrite = sec.BootstrapSecurityJson.Overwrite } // else no user-provided secret, no sweat for us return security, nil @@ -240,11 +245,16 @@ func cmdToPutSecurityJsonInZk() string { cmd := " solr zk cp zk:/security.json /tmp/current_security.json >/dev/null 2>&1; " + " GET_CURRENT_SECURITY_JSON_EXIT_CODE=$?; " + "if [ ${GET_CURRENT_SECURITY_JSON_EXIT_CODE} -eq 0 ]; then " + // JSON already exists - "if [ ! -s /tmp/current_security.json ] || grep -q '^{}$' /tmp/current_security.json ]; then " + // File doesn't exist, is empty, or is just '{}' + "if [ ! -s /tmp/current_security.json ] || grep -q '^{}$' /tmp/current_security.json; then " + // File doesn't exist, is empty, or is just '{}' " echo $SECURITY_JSON > /tmp/security.json;" + " solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + " echo 'Blank security.json found. Put new security.json in ZK'; " + - "fi; " + // TODO: Consider checking a diff and still applying over the top + "elif [ \"${SECURITY_JSON_OVERWRITE}\" = true ] && [ \"$(cat /tmp/current_security.json)\" != \"$(echo $SECURITY_JSON)\" ]; then " + // We want to overwrite the security config if there's a diff + " echo $SECURITY_JSON > /tmp/security.json;" + + " solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + + " echo 'Diff found. Overwriting security.json in ZK'; " + + " else " + + " echo 'Not overwriting security.json'; fi; " + "elif [ ${GET_CURRENT_SECURITY_JSON_EXIT_CODE} -eq 1 ]; then " + // JSON doesn't exist, but not other error types " echo $SECURITY_JSON > /tmp/security.json;" + " solr zk cp /tmp/security.json zk:/security.json >/dev/null 2>&1; " + diff --git a/controllers/util/solr_util.go b/controllers/util/solr_util.go index db4c4085..094f6823 100644 --- a/controllers/util/solr_util.go +++ b/controllers/util/solr_util.go @@ -19,6 +19,10 @@ package util import ( "fmt" + "sort" + "strconv" + "strings" + solr "github.com/apache/solr-operator/api/v1beta1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -28,9 +32,6 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/pointer" "k8s.io/utils/ptr" - "sort" - "strconv" - "strings" ) const ( @@ -1255,6 +1256,7 @@ func generateZKInteractionInitContainer(solrCloud *solr.SolrCloud, solrCloudStat if security != nil && security.SecurityJson != "" { envVars = append(envVars, corev1.EnvVar{Name: "SECURITY_JSON", ValueFrom: security.SecurityJsonSrc}) + envVars = append(envVars, corev1.EnvVar{Name: "SECURITY_JSON_OVERWRITE", Value: strconv.FormatBool(security.SecurityJsonOverwrite)}) if solrCloud.Spec.SolrZkOpts != "" { envVars = append(envVars, corev1.EnvVar{Name: "ZKCLI_JVM_FLAGS", Value: solrCloud.Spec.SolrZkOpts}) } diff --git a/docs/solr-cloud/solr-cloud-crd.md b/docs/solr-cloud/solr-cloud-crd.md index 5eaf6e9d..4b51bbe4 100644 --- a/docs/solr-cloud/solr-cloud-crd.md +++ b/docs/solr-cloud/solr-cloud-crd.md @@ -1126,9 +1126,12 @@ spec: bootstrapSecurityJson: name: my-custom-security-json key: security.json + overwrite: false ``` For `Basic` authentication, if you don't supply a `security.json` Secret, then the operator assumes you are bootstrapping the security configuration via some other means. +If `overwrite` is set to `true`, the security.json for the cluster will be updated if there is a difference between the underlying secret and the security.json in ZK. + Refer to the example `security.json` shown in the Authorization section above to help you get started crafting your own custom configuration. #### Basic Authentication diff --git a/helm/solr-operator/crds/crds.yaml b/helm/solr-operator/crds/crds.yaml index c8b3cd0c..0088e387 100644 --- a/helm/solr-operator/crds/crds.yaml +++ b/helm/solr-operator/crds/crds.yaml @@ -9646,26 +9646,34 @@ spec: description: |- Configure a user-provided security.json from a secret to allow for advanced security config. If not specified, the operator bootstraps a security.json with basic auth enabled. - This is a bootstrapping config only; once Solr is initialized, the security config should be managed by the security API. properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: + bootstrapSecurityJson: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid? + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + probesRequireAuth: description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret or its key must be - defined + Flag to indicate if the operator should overwrite an existing security.json if there are changes + as compared to the underlying secret type: boolean - required: - - key type: object - x-kubernetes-map-type: atomic probesRequireAuth: description: |- Flag to indicate if the configured HTTP endpoint(s) used for the probes require authentication; defaults @@ -9854,7 +9862,8 @@ spec: The maximum number of pods that can be unavailable during the update. Value can be an absolute number (ex: 5) or a percentage of the desired number of pods (ex: 10%). Absolute number is calculated from percentage by rounding down. - If the provided number is 0 or negative, then all pods will be allowed to be updated in unison. + If the provided number is 0, then all pods will be allowed to be updated in unison. + Negatives are not allowed. Defaults to 25%. @@ -9867,7 +9876,8 @@ spec: The maximum number of replicas for each shard that can be unavailable during the update. Value can be an absolute number (ex: 5) or a percentage of replicas in a shard (ex: 25%). Absolute number is calculated from percentage by rounding down. - If the provided number is 0 or negative, then all replicas will be allowed to be updated in unison. + If the provided number is 0 , then all replicas will be allowed to be updated in unison. + Negatives are not allowed. Defaults to 1. diff --git a/helm/solr/values.yaml b/helm/solr/values.yaml index e6addba7..70ba3b0f 100644 --- a/helm/solr/values.yaml +++ b/helm/solr/values.yaml @@ -71,6 +71,7 @@ solrOptions: # bootstrapSecurityJson: # name: my-custom-security-json-secret # key: security.json + # overwrite: false # Specify how the SolrCloud should be addressable diff --git a/tests/e2e/solrcloud_security_json_test.go b/tests/e2e/solrcloud_security_json_test.go index 1ceef5e7..5dddf1c7 100644 --- a/tests/e2e/solrcloud_security_json_test.go +++ b/tests/e2e/solrcloud_security_json_test.go @@ -19,10 +19,15 @@ package e2e import ( "context" + solrv1beta1 "github.com/apache/solr-operator/api/v1beta1" + "github.com/apache/solr-operator/controllers" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + "time" ) var _ = FDescribe("E2E - SolrCloud - Security JSON", func() { @@ -31,12 +36,12 @@ var _ = FDescribe("E2E - SolrCloud - Security JSON", func() { ) BeforeEach(func() { - solrCloud = generateBaseSolrCloudWithSecurityJSON(1) + solrCloud = generateBaseSolrCloudWithSecurityJSON(2) }) JustBeforeEach(func(ctx context.Context) { By("generating the security.json secret and basic auth secret") - generateSolrSecuritySecret(ctx, solrCloud) + generateBasicSolrSecuritySecret(ctx, solrCloud) generateSolrBasicAuthSecret(ctx, solrCloud) By("creating the SolrCloud") @@ -50,7 +55,91 @@ var _ = FDescribe("E2E - SolrCloud - Security JSON", func() { solrCloud = expectSolrCloudToBeReady(ctx, solrCloud) By("creating a first Solr Collection") - createAndQueryCollection(ctx, solrCloud, "basic", 1, 1) + createAndQueryCollection(ctx, solrCloud, "basic", 2, 1) + + By("updating security.json secret") + generateStricterSolrSecuritySecret(ctx, solrCloud) + + patchedSolrCloud := solrCloud.DeepCopy() + patchedSolrCloud.Spec.CustomSolrKubeOptions.PodOptions = &solrv1beta1.PodOptions{ + Annotations: map[string]string{ + "test": "restart-1", + }, + } + By("triggering a restart via pod annotations") + Expect(k8sClient.Patch(ctx, patchedSolrCloud, client.MergeFrom(solrCloud))).To(Succeed(), "Could not add annotation to SolrCloud pod to initiate restart") + + // Trimmed down check where we just want the rolling restart to begin then end, as we're not testing restarts here + By("waiting for the first restart to begin") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, cloud *solrv1beta1.SolrCloud) { + g.Expect(cloud.Status.UpToDateNodes).To(BeZero(), "Cloud did not get to a state with zero up-to-date replicas when rolling restart began.") + for _, nodeStatus := range cloud.Status.SolrNodes { + g.Expect(nodeStatus.SpecUpToDate).To(BeFalse(), "Node not starting as out-of-date when rolling restart begins: %s", nodeStatus.Name) + } + }) + + // After all pods are ready, make sure that the SolrCloud status is correct + By("waiting for the first restart to complete") + expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, cloud *solrv1beta1.SolrCloud) { + g.Expect(cloud.Status.UpToDateNodes).To(Equal(int32(2)), "The SolrCloud did not finish the rolling restart as not all nodes are up to date. Only reached %d", solrCloud.Status.UpToDateNodes) + g.Expect(cloud.Status.ReadyReplicas).To(Equal(int32(2)), "The SolrCloud did not finish the rolling restart as not all nodes are ready. Only reached %d", solrCloud.Status.ReadyReplicas) + }) + + By("waiting for the balanceReplicas to finish") + expectStatefulSetWithChecksAndTimeout(ctx, solrCloud, solrCloud.StatefulSetName(), time.Second*70, time.Second, func(g Gomega, found *appsv1.StatefulSet) { + clusterOp, err := controllers.GetCurrentClusterOp(found) + g.Expect(err).ToNot(HaveOccurred(), "Error occurred while finding clusterLock for SolrCloud") + g.Expect(clusterOp).To(BeNil(), "StatefulSet should not have a balanceReplicas lock after balancing is complete.") + }) + + By("Waiting for the SolrCloud to come up healthy with old security secret") + expectSolrCloudToBeReady(ctx, solrCloud) + + By("querying existing Solr Collection") + queryCollection(ctx, solrCloud, "basic", 0, 0) + + By("committing existing Solr Collection with no auth") + commitCollection(ctx, solrCloud, "basic", 0) + + finalPatchedSolrCloud := solrCloud.DeepCopy() + finalPatchedSolrCloud.Spec.SolrSecurity.BootstrapSecurityJson.Overwrite = true + finalPatchedSolrCloud.Spec.CustomSolrKubeOptions.PodOptions = &solrv1beta1.PodOptions{ + Annotations: map[string]string{ + "test": "restart-2", + }, + } + + By("triggering a restart to overwrite the security.json") + Expect(k8sClient.Patch(ctx, finalPatchedSolrCloud, client.MergeFrom(solrCloud))).To(Succeed(), "Could not update security spec for SolrCloud pod to initiate restart") + + // Trimmed down check where we just want the rolling restart to begin then end, as we're not testing restarts here + By("waiting for the second restart to begin") + newFoundCloud := expectSolrCloudWithChecks(ctx, solrCloud, func(g Gomega, cloud *solrv1beta1.SolrCloud) { + g.Expect(cloud.Status.UpToDateNodes).To(BeZero(), "Cloud did not get to a state with zero up-to-date replicas when rolling restart began.") + }) + + // After all pods are ready, make sure that the SolrCloud status is correct + By("waiting for the second restart to complete") + expectSolrCloudWithChecks(ctx, newFoundCloud, func(g Gomega, cloud *solrv1beta1.SolrCloud) { + g.Expect(cloud.Status.UpToDateNodes).To(Equal(int32(2)), "The SolrCloud did not finish the rolling restart as not all nodes are up to date. Only reached %d", solrCloud.Status.UpToDateNodes) + g.Expect(cloud.Status.ReadyReplicas).To(Equal(int32(2)), "The SolrCloud did not finish the rolling restart as not all nodes are ready. Only reached %d", solrCloud.Status.ReadyReplicas) + }) + + By("waiting for the balanceReplicas to finish") + expectStatefulSetWithChecksAndTimeout(ctx, newFoundCloud, newFoundCloud.StatefulSetName(), time.Second*70, time.Second, func(g Gomega, found *appsv1.StatefulSet) { + clusterOp, err := controllers.GetCurrentClusterOp(found) + g.Expect(err).ToNot(HaveOccurred(), "Error occurred while finding clusterLock for SolrCloud") + g.Expect(clusterOp).To(BeNil(), "StatefulSet should not have a balanceReplicas lock after balancing is complete.") + }) + + By("Waiting for the SolrCloud to come up healthy after new security applied") + expectSolrCloudToBeReady(ctx, newFoundCloud) + + By("querying existing Solr Collection with no auth") + queryCollection(ctx, newFoundCloud, "basic", 0, 0) + + By("committing existing Solr Collection with no auth and expecting to fail") + commitCollectionExpectingUnauthorized(ctx, newFoundCloud, "basic", 0) }) FContext("Provided Zookeeper", func() { diff --git a/tests/e2e/test_utils_test.go b/tests/e2e/test_utils_test.go index 203b93e3..dfb4a993 100644 --- a/tests/e2e/test_utils_test.go +++ b/tests/e2e/test_utils_test.go @@ -41,10 +41,12 @@ import ( "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/client-go/tools/remotecommand" "k8s.io/utils/pointer" @@ -291,6 +293,49 @@ func createAndQueryCollectionWithGomega(ctx context.Context, solrCloud *solrv1be queryCollectionWithGomega(ctx, solrCloud, collection, 0, g, additionalOffset) } +func commitCollectionExpectingUnauthorized(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, collection string, additionalOffset ...int) { + commitCollectionWithGomegaExpectingUnauthorized(ctx, solrCloud, collection, Default, resolveOffset(additionalOffset)) +} + +func commitCollectionWithGomegaExpectingUnauthorized(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, collection string, g Gomega, additionalOffset ...int) { + g.EventuallyWithOffset(resolveOffset(additionalOffset), func(innerG Gomega) { + response, err := curlSolrInPod( + ctx, + solrCloud, + "get", + fmt.Sprintf("/solr/%s/update", collection), + map[string]string{ + "wt": "json", + "commit": "true", + }, + ) + innerG.Expect(err).To(HaveOccurred(), "Commit should have been blocked") + innerG.Expect(response).To(HaveHTTPStatus(401), "Unexpected success occurred while committing Solr Collection '%s'", collection) + }).Within(time.Second*5).WithContext(ctx).Should(Succeed(), "Unexpectedly, succesfully committed collection: %v", fetchClusterStatus(ctx, solrCloud)) +} + +func commitCollection(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, collection string, docCount int, additionalOffset ...int) { + commitCollectionWithGomega(ctx, solrCloud, collection, docCount, Default, resolveOffset(additionalOffset)) +} + +func commitCollectionWithGomega(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, collection string, docCount int, g Gomega, additionalOffset ...int) { + g.EventuallyWithOffset(resolveOffset(additionalOffset), func(innerG Gomega) { + response, err := curlSolrInPod( + ctx, + solrCloud, + "get", + fmt.Sprintf("/solr/%s/update", collection), + map[string]string{ + "wt": "json", + "commit": "true", + }, + ) + innerG.Expect(err).ToNot(HaveOccurred(), "Error occurred while querying empty Solr Collection") + innerG.Expect(response).To(ContainSubstring("\"status\":%d", 0), "Error occurred while querying Solr Collection '%s'", collection) + }).Within(time.Second*5).WithContext(ctx).Should(Succeed(), "Could not successfully query collection: %v", fetchClusterStatus(ctx, solrCloud)) + // Only wait 5 seconds for the collection to be query-able +} + func queryCollection(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, collection string, docCount int, additionalOffset ...int) { queryCollectionWithGomega(ctx, solrCloud, collection, docCount, Default, resolveOffset(additionalOffset)) } @@ -492,6 +537,57 @@ func callSolrApiInPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, htt return runExecForContainer(ctx, util.SolrNodeContainer, solrCloud.GetRandomSolrPodName(), solrCloud.Namespace, command) } +func curlSolrInPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, httpMethod string, apiPath string, queryParams map[string]string) (response string, err error) { + var queryParamsSlice []string + for param, val := range queryParams { + queryParamsSlice = append(queryParamsSlice, param+"="+val) + } + queryParamsString := strings.Join(queryParamsSlice, "&") + if len(queryParamsString) > 0 { + queryParamsString = "?" + queryParamsString + } + + pod, err := createCurlPod(ctx, solrCloud) + + command := []string{ + "curl", + fmt.Sprintf( + "\"%s%s%s%s\"", + solrCloud.Status.InternalCommonAddress, + solrCloud.NodePortSuffix(false), + apiPath, + queryParamsString), + } + return runExecForContainer(ctx, util.SolrNodeContainer, pod.Name, pod.Namespace, command) +} + +func createCurlPod(ctx context.Context, solrCloud *solrv1beta1.SolrCloud) (pod *v1.Pod, err error) { + // Define the curl pod + curlPod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "curl-pod", + Namespace: solrCloud.Namespace, + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "curl", + Image: "curlimages/curl:latest", // Lightweight image with curl installed + Args: []string{ + "tail", + "-f", + "/dev/null", + }, + }, + }, + RestartPolicy: v1.RestartPolicyNever, + }, + } + + // Create the curl pod + return rawK8sClient.CoreV1().Pods(solrCloud.Namespace).Create(context.TODO(), curlPod, metav1.CreateOptions{}) +} + func runExecForContainer(ctx context.Context, container string, podName string, namespace string, command []string) (response string, err error) { req := rawK8sClient.CoreV1().RESTClient().Post(). Resource("pods"). @@ -584,14 +680,8 @@ func generateBaseSolrCloud(replicas int) *solrv1beta1.SolrCloud { // Uses default password from docs : SolrRocks // The hash is generated as: base64(sha256(sha256(salt+password))) base64(salt)) // See https://solr.apache.org/guide/solr/latest/deployment-guide/basic-authentication-plugin.html -func generateSolrSecuritySecret(ctx context.Context, solrCloud *solrv1beta1.SolrCloud) { - securityJsonSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: solrCloud.Name + "-security-secret", - Namespace: solrCloud.Namespace, - }, - StringData: map[string]string{ - "security.json": `{ +func generateBasicSolrSecuritySecret(ctx context.Context, solrCloud *solrv1beta1.SolrCloud) { + generateSolrSecuritySecret(ctx, solrCloud, `{ "authentication": { "blockUnknown": false, "class": "solr.BasicAuthPlugin", @@ -618,7 +708,65 @@ func generateSolrSecuritySecret(ctx context.Context, solrCloud *solrv1beta1.Solr } ] } - }`, + }`) + +} + +// Uses default password from docs : SolrRocks +// The hash is generated as: base64(sha256(sha256(salt+password))) base64(salt)) +// See https://solr.apache.org/guide/solr/latest/deployment-guide/basic-authentication-plugin.html +func generateStricterSolrSecuritySecret(ctx context.Context, solrCloud *solrv1beta1.SolrCloud) { + generateSolrSecuritySecret(ctx, solrCloud, `{ + "authentication": { + "blockUnknown": false, + "class": "solr.BasicAuthPlugin", + "credentials": { + "test-oper": "IV0EHq1OnNrj6gvRCwvFwTrZ1+z1oBbnQdiVC3otuq0= Ndd7LKvVBAaZIF0QAVi1ekCfAJXr1GGfLtRUXhgrF8c=" + }, + "realm": "Solr Basic Auth", + "forwardCredentials": false + }, + "authorization": { + "class": "solr.RuleBasedAuthorizationPlugin", + "user-role": { + "test-oper": "test-oper" + }, + "permissions": [ + { + "name": "cluster", + "role": null + }, + { + "name": "collections", + "role": null, + "collection": "*" + }, + { + "name": "update", + "role": "test-oper" + } + ] + } + }`) + +} + +func generateSolrSecuritySecret(ctx context.Context, solrCloud *solrv1beta1.SolrCloud, securityJson string) { + + existingSecret := &corev1.Secret{} + err := k8sClient.Get(ctx, client.ObjectKey{Namespace: solrCloud.Namespace, Name: solrCloud.Name + "-security-secret"}, existingSecret) + if err == nil { + By("deleting the existing secret " + existingSecret.Name) + deleteAndWait(ctx, existingSecret) + } + + securityJsonSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: solrCloud.Name + "-security-secret", + Namespace: solrCloud.Namespace, + }, + StringData: map[string]string{ + "security.json": securityJson, }, Type: corev1.SecretTypeOpaque, } @@ -649,13 +797,26 @@ func generateSolrBasicAuthSecret(ctx context.Context, solrCloud *solrv1beta1.Sol func generateBaseSolrCloudWithSecurityJSON(replicas int) *solrv1beta1.SolrCloud { solrCloud := generateBaseSolrCloud(replicas) + one := intstr.FromInt(1) + hundredPerc := intstr.FromString("100%") + solrCloud.Spec.UpdateStrategy = solrv1beta1.SolrUpdateStrategy{ + Method: "Managed", + ManagedUpdateOptions: solrv1beta1.ManagedUpdateOptions{ + MaxPodsUnavailable: &one, + MaxShardReplicasUnavailable: &hundredPerc, + }, + } // Ensure SolrSecurity is initialized if solrCloud.Spec.SolrSecurity == nil { solrCloud.Spec.SolrSecurity = &solrv1beta1.SolrSecurityOptions{} } - solrCloud.Spec.SolrSecurity.BootstrapSecurityJson = &corev1.SecretKeySelector{ + if solrCloud.Spec.SolrSecurity.BootstrapSecurityJson == nil { + solrCloud.Spec.SolrSecurity.BootstrapSecurityJson = &solrv1beta1.BootstrapSecurityJson{} + } + + solrCloud.Spec.SolrSecurity.BootstrapSecurityJson.SecurityJsonSecret = &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: solrCloud.Name + "-security-secret", }, diff --git a/tests/scripts/manage_e2e_tests.sh b/tests/scripts/manage_e2e_tests.sh index c42b92fb..890a50ce 100755 --- a/tests/scripts/manage_e2e_tests.sh +++ b/tests/scripts/manage_e2e_tests.sh @@ -130,7 +130,7 @@ function run_tests() { GINKGO_PARAMS+=("${param}" "${!envName}") fi done - GINKGO_PARAMS+=("${RAW_GINKGO[@]}") + GINKGO_PARAMS+=("${RAW_GINKGO[@]:-}") GINKGO_EDITOR_INTEGRATION=true ginkgo --randomize-all "${GINKGO_PARAMS[@]}" "${REPO_DIR}"/tests/e2e/...