From 09d7a946b8480b2a041281c9216b471233038dc8 Mon Sep 17 00:00:00 2001 From: blublinsky Date: Thu, 21 May 2026 19:08:30 +0100 Subject: [PATCH] Add Solr hybrid RAG via RHOKP sidecar --- .gitignore | 6 +- Makefile | 6 +- api/v1alpha1/olsconfig_types.go | 27 +++- api/v1alpha1/zz_generated.deepcopy.go | 99 ++++++------- cmd/main.go | 11 +- .../bases/ols.openshift.io_olsconfigs.yaml | 30 ++-- config/default/deployment-patch.yaml | 3 + hack/image_placeholders.json | 5 + internal/controller/appserver/assets.go | 35 ++++- internal/controller/appserver/assets_test.go | 131 +++++++++++++++++- internal/controller/appserver/deployment.go | 69 ++++++++- .../controller/appserver/deployment_test.go | 69 +++++++++ internal/controller/appserver/rhokp.go | 46 ++++++ internal/controller/appserver/rhokp_test.go | 27 ++++ internal/controller/appserver/suite_test.go | 1 + internal/controller/olsconfig_helpers.go | 4 + internal/controller/reconciler/interface.go | 5 +- internal/controller/utils/constants.go | 28 +++- .../utils/resource_defaults_test.go | 11 +- internal/controller/utils/testing.go | 8 +- internal/controller/utils/types.go | 17 +++ internal/controller/utils/utils.go | 8 ++ 22 files changed, 560 insertions(+), 86 deletions(-) create mode 100644 internal/controller/appserver/rhokp.go create mode 100644 internal/controller/appserver/rhokp_test.go diff --git a/.gitignore b/.gitignore index 7b1d757a7..6168d9303 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,11 @@ *.so *.dylib bin/ -.idea/" +.idea/ + +# Local scan / experiment artifacts +leaktk-scan-*.log + main # Test binary, built with `go test -c` diff --git a/Makefile b/Makefile index 8c1483eee..06922b380 100644 --- a/Makefile +++ b/Makefile @@ -235,7 +235,8 @@ build: manifests generate fmt vet ## Build manager binary. dev-setup: manifests kustomize install ## Setup RBAC and resources for local development (idempotent, safe to run multiple times) @echo "๐Ÿ“ฆ Setting up local development environment..." @$(KUBECTL) create namespace openshift-lightspeed --dry-run=client -o yaml | $(KUBECTL) apply -f - 2>/dev/null || true - @$(KUBECTL) apply -f bundle/manifests/lightspeed-operator-metrics-reader_v1_serviceaccount.yaml 2>/dev/null || true + @$(KUBECTL) apply -n openshift-lightspeed -f bundle/manifests/lightspeed-operator-metrics-reader_v1_serviceaccount.yaml 2>/dev/null || true + @$(KUBECTL) apply -n openshift-lightspeed -f config/dev/metrics-reader-token.yaml 2>/dev/null || true @$(KUBECTL) apply -f bundle/manifests/lightspeed-operator-ols-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml 2>/dev/null || true @$(KUBECTL) apply -f bundle/manifests/lightspeed-operator-ols-metrics-reader_rbac.authorization.k8s.io_v1_clusterrolebinding.yaml 2>/dev/null || true @$(KUBECTL) apply -k config/user-access/ 2>/dev/null || true @@ -246,7 +247,8 @@ dev-teardown: uninstall ## Teardown local development environment (removes RBAC, @echo "๐Ÿงน Cleaning up local development environment..." @$(KUBECTL) delete -f bundle/manifests/lightspeed-operator-ols-metrics-reader_rbac.authorization.k8s.io_v1_clusterrolebinding.yaml --ignore-not-found=true 2>/dev/null || true @$(KUBECTL) delete -f bundle/manifests/lightspeed-operator-ols-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml --ignore-not-found=true 2>/dev/null || true - @$(KUBECTL) delete -f bundle/manifests/lightspeed-operator-metrics-reader_v1_serviceaccount.yaml --ignore-not-found=true 2>/dev/null || true + @$(KUBECTL) delete -n openshift-lightspeed -f config/dev/metrics-reader-token.yaml --ignore-not-found=true 2>/dev/null || true + @$(KUBECTL) delete -n openshift-lightspeed -f bundle/manifests/lightspeed-operator-metrics-reader_v1_serviceaccount.yaml --ignore-not-found=true 2>/dev/null || true @$(KUBECTL) delete -k config/user-access/ --ignore-not-found=true 2>/dev/null || true @$(KUBECTL) delete namespace openshift-lightspeed --ignore-not-found=true 2>/dev/null || true @echo "โœ… Development environment cleaned up." diff --git a/api/v1alpha1/olsconfig_types.go b/api/v1alpha1/olsconfig_types.go index 9fcdddab3..f289f2976 100644 --- a/api/v1alpha1/olsconfig_types.go +++ b/api/v1alpha1/olsconfig_types.go @@ -218,10 +218,14 @@ type OLSSpec struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Proxy Settings",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} // +kubebuilder:validation:Optional ProxyConfig *ProxyConfig `json:"proxyConfig,omitempty"` - // RAG databases + // BYOK RAG databases (bring-your-own container images with FAISS vector indexes). // +kubebuilder:validation:Optional - // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="RAG Databases",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="BYOK RAG Databases",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} RAG []RAGSpec `json:"rag,omitempty"` + // Solr hybrid RAG (portal-rag /hybrid-search). When set, RHOKP sidecar and solr_hybrid are configured; local FAISS indexes remain for readiness until removed. Ignored when byokRAGOnly is true. + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Solr Hybrid RAG",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + SolrHybrid *SolrHybridSettings `json:"solrHybrid,omitempty"` // LLM Token Quota Configuration // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="LLM Token Quota Configuration" QuotaHandlersConfig *QuotaHandlersConfig `json:"quotaHandlersConfig,omitempty"` @@ -229,7 +233,7 @@ type OLSSpec struct { // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Persistent Storage Configuration",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} Storage *Storage `json:"storage,omitempty"` - // Only use BYOK RAG sources, ignore the OpenShift documentation RAG + // Only use BYOK RAG sources, ignore the OpenShift documentation RAG and Solr hybrid RAG // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Only use BYOK RAG sources",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} ByokRAGOnly bool `json:"byokRAGOnly,omitempty"` @@ -277,23 +281,32 @@ type MCPKubeServerConfiguration struct { Timeout int `json:"timeout,omitempty"` } -// RAGSpec defines how to retrieve RAG databases. +// RAGSpec defines a BYOK RAG database (container image and index path). type RAGSpec struct { - // The path to the RAG database inside of the container image + // The path to the BYOK RAG database inside of the container image // +kubebuilder:default="/rag/vector_db" // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Index Path in the Image" IndexPath string `json:"indexPath,omitempty"` - // The Index ID of the RAG database. Only needed if there are multiple indices in the database. + // The Index ID of the BYOK RAG database. Only needed if there are multiple indices in the database. // +kubebuilder:default="" // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Index ID" IndexID string `json:"indexID,omitempty"` - // The URL of the container image to use as a RAG source + // The URL of the container image to use as a BYOK RAG source // +kubebuilder:validation:Required // +required // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Image" Image string `json:"image"` } +// SolrHybridSettings enables Solr hybrid RAG (portal-rag /hybrid-search) using the operator RHOKP sidecar. +// Retrieval tuning and Solr URL are set in the OLS config file by the operator, not on this CR. +type SolrHybridSettings struct { + // When true, merge Solr hybrid hits into the prompt as direct RAG context. + // When false, expose documentation search via tool only. Omitted is treated as false by the operator. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Solr Direct RAG",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + SolrDirectRAG *bool `json:"solrDirectRag,omitempty"` +} + // QuotaHandlersConfig defines the token quota configuration type QuotaHandlersConfig struct { // Token quota limiters diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cceaa7b9b..8ec110dfc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -23,7 +23,7 @@ package v1alpha1 import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -36,7 +36,8 @@ func (in *Config) DeepCopyInto(out *Config) { } if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = (*in).DeepCopy() + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations @@ -54,7 +55,8 @@ func (in *Config) DeepCopyInto(out *Config) { } if in.Affinity != nil { in, out := &in.Affinity, &out.Affinity - *out = (*in).DeepCopy() + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) } if in.TopologySpreadConstraints != nil { in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints @@ -80,7 +82,8 @@ func (in *ContainerConfig) DeepCopyInto(out *ContainerConfig) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = (*in).DeepCopy() + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) } } @@ -188,7 +191,8 @@ func (in *MCPHeaderValueSource) DeepCopyInto(out *MCPHeaderValueSource) { *out = *in if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef - *out = (*in).DeepCopy() + *out = new(corev1.LocalObjectReference) + **out = **in } } @@ -421,52 +425,7 @@ func (in *OLSSpec) DeepCopyInto(out *OLSSpec) { } if in.AdditionalCAConfigMapRef != nil { in, out := &in.AdditionalCAConfigMapRef, &out.AdditionalCAConfigMapRef - *out = (*in).DeepCopy() - } - if in.TLSSecurityProfile != nil { - in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile - *out = (*in).DeepCopy() - } - if in.MCPKubeServerConfig != nil { - in, out := &in.MCPKubeServerConfig, &out.MCPKubeServerConfig - *out = new(MCPKubeServerConfiguration) - (*in).DeepCopyInto(*out) - } - if in.ProxyConfig != nil { - in, out := &in.ProxyConfig, &out.ProxyConfig - *out = new(ProxyConfig) - (*in).DeepCopyInto(*out) - } - if in.RAG != nil { - in, out := &in.RAG, &out.RAG - *out = make([]RAGSpec, len(*in)) - copy(*out, *in) - } - if in.QuotaHandlersConfig != nil { - in, out := &in.QuotaHandlersConfig, &out.QuotaHandlersConfig - *out = new(QuotaHandlersConfig) - (*in).DeepCopyInto(*out) - } - if in.Storage != nil { - in, out := &in.Storage, &out.Storage - *out = new(Storage) - (*in).DeepCopyInto(*out) - } - if in.ImagePullSecrets != nil { - in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = make([]corev1.LocalObjectReference, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ToolFilteringConfig != nil { - in, out := &in.ToolFilteringConfig, &out.ToolFilteringConfig - *out = new(ToolFilteringConfig) - **out = **in - } - if in.ToolsApprovalConfig != nil { - in, out := &in.ToolsApprovalConfig, &out.ToolsApprovalConfig - *out = new(ToolsApprovalConfig) + *out = new(corev1.LocalObjectReference) **out = **in } } @@ -520,15 +479,21 @@ func (in *PostgresSpec) DeepCopy() *PostgresSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { *out = *in - in.CredentialsSecretRef.DeepCopyInto(&out.CredentialsSecretRef) + out.CredentialsSecretRef = in.CredentialsSecretRef if in.Models != nil { in, out := &in.Models, &out.Models *out = make([]ModelSpec, len(*in)) copy(*out, *in) } - if in.TLSSecurityProfile != nil { - in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile - *out = (*in).DeepCopy() + if in.GoogleVertexConfig != nil { + in, out := &in.GoogleVertexConfig, &out.GoogleVertexConfig + *out = new(VertexConfig) + **out = **in + } + if in.GoogleVertexAnthropicConfig != nil { + in, out := &in.GoogleVertexAnthropicConfig, &out.GoogleVertexAnthropicConfig + *out = new(VertexConfig) + **out = **in } } @@ -545,7 +510,7 @@ func (in *ProviderSpec) DeepCopy() *ProviderSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProxyCACertConfigMapRef) DeepCopyInto(out *ProxyCACertConfigMapRef) { *out = *in - in.LocalObjectReference.DeepCopyInto(&out.LocalObjectReference) + out.LocalObjectReference = in.LocalObjectReference } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyCACertConfigMapRef. @@ -628,6 +593,26 @@ func (in *RAGSpec) DeepCopy() *RAGSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SolrHybridSettings) DeepCopyInto(out *SolrHybridSettings) { + *out = *in + if in.SolrDirectRAG != nil { + in, out := &in.SolrDirectRAG, &out.SolrDirectRAG + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SolrHybridSettings. +func (in *SolrHybridSettings) DeepCopy() *SolrHybridSettings { + if in == nil { + return nil + } + out := new(SolrHybridSettings) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Storage) DeepCopyInto(out *Storage) { *out = *in @@ -647,7 +632,7 @@ func (in *Storage) DeepCopy() *Storage { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { *out = *in - in.KeyCertSecretRef.DeepCopyInto(&out.KeyCertSecretRef) + out.KeyCertSecretRef = in.KeyCertSecretRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. diff --git a/cmd/main.go b/cmd/main.go index 4242daee9..c0ff83f0f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -102,6 +102,7 @@ var ( "openshift-mcp-server-image": utils.OpenShiftMCPServerImageDefault, "dataverse-exporter-image": utils.DataverseExporterImageDefault, "ocp-rag-image": utils.OcpRagImageDefault, + "rhokp-image": utils.RHOOKPImageDefault, } ) @@ -119,7 +120,7 @@ func init() { // overrideImages overrides the default images with the images provided by the user. // If an image is not provided, the default is used. -func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 string, consoleImage_419 string, postgresImage string, openshiftMCPServerImage string, dataverseExporterImage string, ocpRagImage string) map[string]string { +func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 string, consoleImage_419 string, postgresImage string, openshiftMCPServerImage string, dataverseExporterImage string, ocpRagImage string, rhokpImage string) map[string]string { res := defaultImages if serviceImage != "" { res["lightspeed-service"] = serviceImage @@ -145,6 +146,9 @@ func overrideImages(serviceImage string, consoleImage string, consoleImage_pf5 s if ocpRagImage != "" { res["ocp-rag-image"] = ocpRagImage } + if rhokpImage != "" { + res["rhokp-image"] = rhokpImage + } return res } @@ -179,6 +183,7 @@ func main() { var openshiftMCPServerImage string var dataverseExporterImage string var ocpRagImage string + var rhokpImage string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, @@ -198,6 +203,7 @@ func main() { flag.StringVar(&openshiftMCPServerImage, "openshift-mcp-server-image", utils.OpenShiftMCPServerImageDefault, "The image of the OpenShift MCP server container.") flag.StringVar(&dataverseExporterImage, "dataverse-exporter-image", utils.DataverseExporterImageDefault, "The image of the dataverse exporter container.") flag.StringVar(&ocpRagImage, "ocp-rag-image", utils.OcpRagImageDefault, "The image with the OCP RAG databases.") + flag.StringVar(&rhokpImage, "rhokp-image", utils.RHOOKPImageDefault, "The RH Offline Knowledge Portal (Solr) sidecar image for Solr hybrid RAG.") opts := zap.Options{ Development: true, } @@ -210,7 +216,7 @@ func main() { namespace = getWatchNamespace() } - imagesMap := overrideImages(serviceImage, consoleImage, consoleImage_pf5, consoleImage_419, postgresImage, openshiftMCPServerImage, dataverseExporterImage, ocpRagImage) + imagesMap := overrideImages(serviceImage, consoleImage, consoleImage_pf5, consoleImage_419, postgresImage, openshiftMCPServerImage, dataverseExporterImage, ocpRagImage, rhokpImage) setupLog.Info("Images setting loaded", "images", listImages()) setupLog.Info("Starting the operator", "metricsAddr", metricsAddr, "probeAddr", probeAddr, "certDir", certDir, "certName", certName, "keyName", keyName, "namespace", namespace) @@ -424,6 +430,7 @@ func main() { LightspeedServicePostgresImage: imagesMap["postgres-image"], OpenShiftMCPServerImage: imagesMap["openshift-mcp-server-image"], DataverseExporterImage: imagesMap["dataverse-exporter-image"], + RHOOKPImage: imagesMap["rhokp-image"], Namespace: namespace, PrometheusAvailable: prometheusAvailable, }, diff --git a/config/crd/bases/ols.openshift.io_olsconfigs.yaml b/config/crd/bases/ols.openshift.io_olsconfigs.yaml index 869e1da55..bbaf3fabd 100644 --- a/config/crd/bases/ols.openshift.io_olsconfigs.yaml +++ b/config/crd/bases/ols.openshift.io_olsconfigs.yaml @@ -440,7 +440,7 @@ spec: x-kubernetes-map-type: atomic byokRAGOnly: description: Only use BYOK RAG sources, ignore the OpenShift documentation - RAG + RAG and Solr hybrid RAG type: boolean conversationCache: description: Conversation cache settings @@ -4450,28 +4450,42 @@ spec: type: array type: object rag: - description: RAG databases + description: BYOK RAG databases (bring-your-own container images + with FAISS vector indexes). items: - description: RAGSpec defines how to retrieve RAG databases. + description: RAGSpec defines a BYOK RAG database (container + image and index path). properties: image: description: The URL of the container image to use as a - RAG source + BYOK RAG source type: string indexID: default: "" - description: The Index ID of the RAG database. Only needed - if there are multiple indices in the database. + description: The Index ID of the BYOK RAG database. Only + needed if there are multiple indices in the database. type: string indexPath: default: /rag/vector_db - description: The path to the RAG database inside of the - container image + description: The path to the BYOK RAG database inside of + the container image type: string required: - image type: object type: array + solrHybrid: + description: Solr hybrid RAG (portal-rag /hybrid-search). When + set, RHOKP sidecar and solr_hybrid are configured; local FAISS + indexes remain for readiness until removed. Ignored when byokRAGOnly + is true. + properties: + solrDirectRag: + description: |- + When true, merge Solr hybrid hits into the prompt as direct RAG context. + When false, expose documentation search via tool only. Omitted is treated as false by the operator. + type: boolean + type: object storage: description: Persistent Storage Configuration properties: diff --git a/config/default/deployment-patch.yaml b/config/default/deployment-patch.yaml index bd465e805..e106c836f 100644 --- a/config/default/deployment-patch.yaml +++ b/config/default/deployment-patch.yaml @@ -24,6 +24,9 @@ - op: add path: /spec/template/spec/containers/0/args/- value: --ocp-rag-image=__REPLACE_LIGHTSPEED_OCP_RAG__ +- op: add + path: /spec/template/spec/containers/0/args/- + value: --rhokp-image=__REPLACE_RHOKP__ - op: replace path: /spec/template/spec/containers/0/image value: __REPLACE_LIGHTSPEED_OPERATOR__ diff --git a/hack/image_placeholders.json b/hack/image_placeholders.json index dfdf31ced..5d5c6dbd0 100644 --- a/hack/image_placeholders.json +++ b/hack/image_placeholders.json @@ -43,5 +43,10 @@ "name": "lightspeed-postgresql", "placeholder": "__REPLACE_LIGHTSPEED_POSTGRESQL__", "target": "args" + }, + { + "name": "rhokp", + "placeholder": "__REPLACE_RHOKP__", + "target": "args" } ] diff --git a/internal/controller/appserver/assets.go b/internal/controller/appserver/assets.go index 6297a49b3..27e4ca18a 100644 --- a/internal/controller/appserver/assets.go +++ b/internal/controller/appserver/assets.go @@ -293,7 +293,8 @@ func buildOLSConfig(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha } referenceIndexes = append(referenceIndexes, referenceIndex) } - // Add OCP documentation index unless BYOK-only mode is enabled + // Add OCP documentation (FAISS) index unless BYOK-only. Kept with Solr hybrid for readiness + // until FAISS is removed; solr_hybrid remains the primary docs path for queries. if !cr.Spec.OLSConfig.ByokRAGOnly { ocpReferenceIndex := utils.ReferenceIndex{ ProductDocsIndexPath: "/app-root/vector_db/ocp_product_docs/" + r.GetOpenShiftMajor() + "." + r.GetOpenshiftMinor(), @@ -302,6 +303,11 @@ func buildOLSConfig(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha } referenceIndexes = append(referenceIndexes, ocpReferenceIndex) } + if solrHybridEnabled(cr.Spec.OLSConfig) { + for i := range referenceIndexes { + referenceIndexes[i].ByokIndex = true + } + } // Assemble the main OLS configuration olsConfig := utils.OLSConfig{ @@ -348,9 +354,36 @@ func buildOLSConfig(r reconciler.Reconciler, ctx context.Context, cr *olsv1alpha Ciphers: utiltls.TLSCiphers(tlsProfileSpec), } + if solrHybridEnabled(cr.Spec.OLSConfig) { + olsConfig.SolrHybrid = buildSolrHybridSettings(cr.Spec.OLSConfig.SolrHybrid) + } + return olsConfig, nil } +// solrHybridEnabled reports whether the operator should configure Solr hybrid RAG. +// byokRAGOnly takes precedence: Solr hybrid is ignored when that flag is true. +func solrHybridEnabled(ols olsv1alpha1.OLSSpec) bool { + return ols.SolrHybrid != nil && !ols.ByokRAGOnly +} + +// buildSolrHybridSettings maps CR Solr hybrid enablement to the OLS config file (snake_case keys). +// Solr URL and retrieval tuning use operator defaults (RHOKP sidecar on localhost:RHOOKPHTTPPort). +func buildSolrHybridSettings(spec *olsv1alpha1.SolrHybridSettings) *utils.SolrHybridSettings { + if spec == nil { + return nil + } + return &utils.SolrHybridSettings{ + SolrHTTPBase: fmt.Sprintf("http://localhost:%d", utils.RHOOKPHTTPPort), + MaxResults: utils.SolrHybridMaxResultsDefault, + HybridVectorBoost: utils.SolrHybridVectorBoostDefault, + HybridPoolDocs: utils.SolrHybridPoolDocsDefault, + HybridScoreThreshold: utils.SolrHybridScoreThresholdDefault, + HybridSolrTimeoutSeconds: utils.SolrHybridSolrTimeoutSecondsDefault, + SolrDirectRAG: utils.BoolDeref(spec.SolrDirectRAG, false), + } +} + // generateMCPServerConfigs builds MCP (Model Context Protocol) server configurations. // It adds the built-in OpenShift MCP server if introspection is enabled, and any user-defined // MCP servers with their authentication headers (Kubernetes, client, or secret-based). diff --git a/internal/controller/appserver/assets_test.go b/internal/controller/appserver/assets_test.go index 682974b96..f1faeefca 100644 --- a/internal/controller/appserver/assets_test.go +++ b/internal/controller/appserver/assets_test.go @@ -526,6 +526,52 @@ var _ = Describe("App server assets", func() { }))) }) + It("should generate configmap with solrHybrid defaults when Solr hybrid RAG is enabled", func() { + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + + var appSrvConfigFile utils.AppSrvConfigFile + err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &appSrvConfigFile) + Expect(err).NotTo(HaveOccurred()) + Expect(appSrvConfigFile.OLSConfig.SolrHybrid).NotTo(BeNil()) + Expect(appSrvConfigFile.OLSConfig.SolrHybrid).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "SolrHTTPBase": Equal(fmt.Sprintf("http://localhost:%d", utils.RHOOKPHTTPPort)), + "MaxResults": Equal(utils.SolrHybridMaxResultsDefault), + "HybridVectorBoost": Equal(utils.SolrHybridVectorBoostDefault), + "HybridPoolDocs": Equal(utils.SolrHybridPoolDocsDefault), + "HybridScoreThreshold": Equal(utils.SolrHybridScoreThresholdDefault), + "HybridSolrTimeoutSeconds": Equal(utils.SolrHybridSolrTimeoutSecondsDefault), + "SolrDirectRAG": BeFalse(), + }))) + Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("solr_direct_rag: false")) + }) + + It("should generate configmap with solr_direct_rag when set on Solr hybrid CR", func() { + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{ + SolrDirectRAG: utils.BoolPtr(true), + } + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + + var appSrvConfigFile utils.AppSrvConfigFile + err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &appSrvConfigFile) + Expect(err).NotTo(HaveOccurred()) + Expect(appSrvConfigFile.OLSConfig.SolrHybrid).NotTo(BeNil()) + Expect(appSrvConfigFile.OLSConfig.SolrHybrid.SolrDirectRAG).To(BeTrue()) + Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("solr_direct_rag: true")) + }) + + It("should omit solr_hybrid from configmap when Solr hybrid RAG is not configured", func() { + cr.Spec.OLSConfig.SolrHybrid = nil + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Data[utils.OLSConfigFilename]).NotTo(ContainSubstring("solr_hybrid:")) + }) + It("should skip MCP server with missing header secret during config generation", func() { cr.Spec.FeatureGates = []olsv1alpha1.FeatureGate{utils.FeatureGateMCPServer} // Note: We don't create the secret - config generation doesn't validate secrets @@ -1180,6 +1226,89 @@ var _ = Describe("App server assets", func() { }) + It("should include OCP and BYOK FAISS indexes with byok_index when solrHybrid is enabled", func() { + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + cr.Spec.OLSConfig.RAG = []olsv1alpha1.RAGSpec{ + { + IndexPath: "/rag/vector_db/ansible_docs/2.18", + IndexID: "ansible-docs-2_18", + Image: "rag-ansible-docs:2.18", + }, + } + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + var olsconfigGenerated utils.AppSrvConfigFile + err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &olsconfigGenerated) + Expect(err).NotTo(HaveOccurred()) + + Expect(olsconfigGenerated.OLSConfig.ReferenceContent.Indexes).To(Equal([]utils.ReferenceIndex{ + { + ProductDocsIndexId: "ansible-docs-2_18", + ProductDocsIndexPath: utils.RAGVolumeMountPath + "/rag-0", + ProductDocsOrigin: "rag-ansible-docs:2.18", + ByokIndex: true, + }, + { + ProductDocsIndexId: "ocp-product-docs-123_456", + ProductDocsIndexPath: "/app-root/vector_db/ocp_product_docs/123.456", + ProductDocsOrigin: "Red Hat OpenShift 123.456 documentation", + ByokIndex: true, + }, + })) + Expect(olsconfigGenerated.OLSConfig.SolrHybrid).NotTo(BeNil()) + Expect(cm.Data[utils.OLSConfigFilename]).To(ContainSubstring("byok_index: true")) + }) + + It("should include OCP FAISS index with byok_index when only solrHybrid is enabled", func() { + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + cr.Spec.OLSConfig.RAG = nil + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + var olsconfigGenerated utils.AppSrvConfigFile + err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &olsconfigGenerated) + Expect(err).NotTo(HaveOccurred()) + + Expect(olsconfigGenerated.OLSConfig.ReferenceContent.Indexes).To(Equal([]utils.ReferenceIndex{ + { + ProductDocsIndexId: "ocp-product-docs-123_456", + ProductDocsIndexPath: "/app-root/vector_db/ocp_product_docs/123.456", + ProductDocsOrigin: "Red Hat OpenShift 123.456 documentation", + ByokIndex: true, + }, + })) + Expect(olsconfigGenerated.OLSConfig.SolrHybrid).NotTo(BeNil()) + }) + + It("should ignore solrHybrid when byokRAGOnly is true", func() { + cr.Spec.OLSConfig.ByokRAGOnly = true + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + cr.Spec.OLSConfig.RAG = []olsv1alpha1.RAGSpec{ + { + IndexPath: "/rag/vector_db/ansible_docs/2.18", + IndexID: "ansible-docs-2_18", + Image: "rag-ansible-docs:2.18", + }, + } + + cm, err := GenerateOLSConfigMap(testReconcilerInstance, context.TODO(), cr) + Expect(err).NotTo(HaveOccurred()) + var olsconfigGenerated utils.AppSrvConfigFile + err = yaml.Unmarshal([]byte(cm.Data[utils.OLSConfigFilename]), &olsconfigGenerated) + Expect(err).NotTo(HaveOccurred()) + + Expect(olsconfigGenerated.OLSConfig.SolrHybrid).To(BeNil()) + Expect(cm.Data[utils.OLSConfigFilename]).NotTo(ContainSubstring("solr_hybrid:")) + Expect(olsconfigGenerated.OLSConfig.ReferenceContent.Indexes).To(Equal([]utils.ReferenceIndex{ + { + ProductDocsIndexId: "ansible-docs-2_18", + ProductDocsIndexPath: utils.RAGVolumeMountPath + "/rag-0", + ProductDocsOrigin: "rag-ansible-docs:2.18", + }, + })) + }) + // This test covers ByokRAGOnly == true. ByokRAGOnly == false is covered by the previous test. It("should not include the OCP docs RAG when byokRAGOnly is true", func() { cr.Spec.OLSConfig.ByokRAGOnly = true @@ -2256,7 +2385,7 @@ var _ = Describe("Helper function unit tests", func() { Expect(err).NotTo(HaveOccurred()) Expect(servers).To(HaveLen(1)) Expect(servers[0].Name).To(Equal("openshift")) - Expect(servers[0].URL).To(ContainSubstring("8080")) + Expect(servers[0].URL).To(Equal(fmt.Sprintf(utils.OpenShiftMCPServerURL, utils.OpenShiftMCPServerPort))) Expect(servers[0].Headers).To(HaveKey(utils.K8S_AUTH_HEADER)) }) diff --git a/internal/controller/appserver/deployment.go b/internal/controller/appserver/deployment.go index 9a51e52e5..736a3b26b 100644 --- a/internal/controller/appserver/deployment.go +++ b/internal/controller/appserver/deployment.go @@ -56,6 +56,46 @@ func getOLSMCPServerResources(cr *olsv1alpha1.OLSConfig) *corev1.ResourceRequire ) } +func rhokpHTTPProbeHandler() corev1.ProbeHandler { + return corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: utils.RHOOKPReadinessHTTPPath, + Port: intstr.FromInt32(utils.RHOOKPHTTPPort), + Scheme: corev1.URISchemeHTTP, + }, + } +} + +func rhokpHealthProbe() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: rhokpHTTPProbeHandler(), + InitialDelaySeconds: utils.RHOOKPProbeInitialDelaySeconds, + PeriodSeconds: utils.RHOOKPProbePeriodSeconds, + TimeoutSeconds: utils.RHOOKPProbeTimeoutSeconds, + FailureThreshold: utils.RHOOKPProbeFailureThreshold, + SuccessThreshold: 1, + } +} + +func getRHOOKPResources() *corev1.ResourceRequirements { + // RHOKP recommended pod sizing per product docs (2 CPU, 2 GiB memory, 75 GiB disk). + return utils.GetResourcesOrDefault( + nil, + &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("75Gi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("75Gi"), + }, + }, + ) +} + func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) (*appsv1.Deployment, error) { ctx := context.Background() const OLSConfigVolumeName = "cm-olsconfig" @@ -363,6 +403,7 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( ols_server_resources := getOLSServerResources(cr) data_collector_resources := getOLSDataCollectorResources(cr) mcp_server_resources := getOLSMCPServerResources(cr) + rhokp_resources := getRHOOKPResources() // Get ResourceVersions for tracking - these resources should already exist // If they don't exist (NotFound), we'll get empty strings which is fine for initial creation @@ -472,6 +513,7 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( // Add additional containers in a consistent order: // 1. Data collector container (if enabled) // 2. MCP server container (if enabled) + // 3. RHOKP Solr sidecar (if Solr hybrid RAG is configured) if dataCollectorEnabled { // Add data exporter container @@ -507,9 +549,8 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( // preventing secret data from reaching the LLM. if utils.BoolDeref(cr.Spec.OLSConfig.IntrospectionEnabled, true) { configVolume, configMount := utils.GetOpenShiftMCPServerConfigVolumeAndMount() - openshiftMCPServerSidecarContainer := corev1.Container{ - Name: "openshift-mcp-server", + Name: utils.OpenShiftMCPServerContainerName, Image: r.GetOpenShiftMCPServerImage(), ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: utils.RestrictedContainerSecurityContext(), @@ -525,6 +566,30 @@ func GenerateOLSDeployment(r reconciler.Reconciler, cr *olsv1alpha1.OLSConfig) ( deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, openshiftMCPServerSidecarContainer) } + if solrHybridEnabled(cr.Spec.OLSConfig) { + rhokpSidecarContainer := corev1.Container{ + Name: utils.RHOOKPContainerName, + Image: r.GetRHOOKPImage(), + ImagePullPolicy: corev1.PullIfNotPresent, + Command: rhokpContainerCommand(), + Args: rhokpContainerArgs(), + SecurityContext: utils.RHOOKPContainerSecurityContext(), + Env: generateRHOOKPEnv(), + Ports: []corev1.ContainerPort{ + { + ContainerPort: utils.RHOOKPHTTPPort, + Name: "solr-http", + Protocol: corev1.ProtocolTCP, + }, + }, + // HTTP on :8080 once Apache/Solr are up; initial delay avoids liveness restarts during Solr startup. + ReadinessProbe: rhokpHealthProbe(), + LivenessProbe: rhokpHealthProbe(), + Resources: *rhokp_resources, + } + deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, rhokpSidecarContainer) + } + return &deployment, nil } diff --git a/internal/controller/appserver/deployment_test.go b/internal/controller/appserver/deployment_test.go index e6f8b57d0..4407e5f90 100644 --- a/internal/controller/appserver/deployment_test.go +++ b/internal/controller/appserver/deployment_test.go @@ -652,6 +652,75 @@ var _ = Describe("App server deployment generation", func() { )) }) + It("should add RHOKP sidecar when solrHybrid is configured", func() { + cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) + cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ + FeedbackDisabled: true, + TranscriptsDisabled: true, + } + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + + dep, err := GenerateOLSDeployment(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + + var rhokpContainer *corev1.Container + for i := range dep.Spec.Template.Spec.Containers { + if dep.Spec.Template.Spec.Containers[i].Name == utils.RHOOKPContainerName { + rhokpContainer = &dep.Spec.Template.Spec.Containers[i] + break + } + } + Expect(rhokpContainer).NotTo(BeNil(), "expected RHOKP sidecar container") + Expect(rhokpContainer.Image).To(Equal(testReconcilerInstance.GetRHOOKPImage())) + Expect(rhokpContainer.Ports).To(ContainElement( + MatchFields(IgnoreExtras, Fields{ + "ContainerPort": Equal(int32(utils.RHOOKPHTTPPort)), + "Name": Equal("solr-http"), + "Protocol": Equal(corev1.ProtocolTCP), + }), + )) + Expect(rhokpContainer.Command).To(Equal(rhokpContainerCommand())) + Expect(rhokpContainer.Args).To(Equal(rhokpContainerArgs())) + Expect(rhokpContainer.StartupProbe).To(BeNil()) + Expect(rhokpContainer.ReadinessProbe).To(Equal(rhokpHealthProbe())) + Expect(rhokpContainer.LivenessProbe).To(Equal(rhokpHealthProbe())) + Expect(rhokpContainer.SecurityContext.ReadOnlyRootFilesystem).NotTo(BeNil()) + Expect(*rhokpContainer.SecurityContext.ReadOnlyRootFilesystem).To(BeFalse()) + Expect(rhokpContainer.Env).To(ContainElement( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("ACCESS_KEY"), + "Value": BeEmpty(), + "ValueFrom": PointTo(MatchFields(IgnoreExtras, Fields{ + "SecretKeyRef": PointTo(MatchFields(IgnoreExtras, Fields{ + "LocalObjectReference": Equal(corev1.LocalObjectReference{Name: utils.RHOOKPAccessKeySecretName}), + "Key": Equal(utils.RHOOKPAccessKeySecretKey), + "Optional": PointTo(BeTrue()), + })), + })), + }), + )) + for _, ic := range dep.Spec.Template.Spec.InitContainers { + Expect(ic.Name).NotTo(HavePrefix("rhokp"), "RHOKP must not use an init container") + } + }) + + It("should not add RHOKP sidecar when byokRAGOnly is true even if solrHybrid is set", func() { + cr.Spec.OLSConfig.IntrospectionEnabled = utils.BoolPtr(false) + cr.Spec.OLSConfig.UserDataCollection = olsv1alpha1.UserDataCollectionSpec{ + FeedbackDisabled: true, + TranscriptsDisabled: true, + } + cr.Spec.OLSConfig.ByokRAGOnly = true + cr.Spec.OLSConfig.SolrHybrid = &olsv1alpha1.SolrHybridSettings{} + + dep, err := GenerateOLSDeployment(testReconcilerInstance, cr) + Expect(err).NotTo(HaveOccurred()) + + for i := range dep.Spec.Template.Spec.Containers { + Expect(dep.Spec.Template.Spec.Containers[i].Name).NotTo(Equal(utils.RHOOKPContainerName)) + } + }) + }) }) diff --git a/internal/controller/appserver/rhokp.go b/internal/controller/appserver/rhokp.go new file mode 100644 index 000000000..78c2ab6bc --- /dev/null +++ b/internal/controller/appserver/rhokp.go @@ -0,0 +1,46 @@ +package appserver + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +// rhokpStartupScript disables the stock RHOKP Apache HTTPS listener on 8443 (conflicts with the +// app server in the shared pod network) then runs the image entrypoint. +func rhokpStartupScript() string { + return fmt.Sprintf( + "sed -i 's/^Listen 0.0.0.0:8443/# disabled for Lightspeed sidecar: &/' %s && exec %s %s", + utils.RHOOKPHTTPDSSLConfPath, + utils.RHOOKPContainerEntrypoint, + utils.RHOOKPMainCommand, + ) +} + +func rhokpContainerCommand() []string { + return []string{"/bin/sh", "-c"} +} + +func rhokpContainerArgs() []string { + return []string{rhokpStartupScript()} +} + +func generateRHOOKPEnv() []corev1.EnvVar { + optional := true + return []corev1.EnvVar{ + { + Name: "ACCESS_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: utils.RHOOKPAccessKeySecretName, + }, + Key: utils.RHOOKPAccessKeySecretKey, + Optional: &optional, + }, + }, + }, + } +} diff --git a/internal/controller/appserver/rhokp_test.go b/internal/controller/appserver/rhokp_test.go new file mode 100644 index 000000000..eebaec27f --- /dev/null +++ b/internal/controller/appserver/rhokp_test.go @@ -0,0 +1,27 @@ +package appserver + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/lightspeed-operator/internal/controller/utils" +) + +var _ = Describe("RHOKP sidecar assets", func() { + It("should disable Apache Listen 8443 before starting the RHOKP entrypoint", func() { + Expect(rhokpContainerCommand()).To(Equal([]string{"/bin/sh", "-c"})) + Expect(rhokpContainerArgs()[0]).To(ContainSubstring(utils.RHOOKPHTTPDSSLConfPath)) + Expect(rhokpContainerArgs()[0]).To(ContainSubstring("Listen 0.0.0.0:8443")) + Expect(rhokpContainerArgs()[0]).To(ContainSubstring(utils.RHOOKPContainerEntrypoint)) + Expect(rhokpContainerArgs()[0]).To(ContainSubstring(utils.RHOOKPMainCommand)) + }) + + It("should wire optional ACCESS_KEY from rhokp-access-key secret", func() { + env := generateRHOOKPEnv() + Expect(env).To(HaveLen(1)) + Expect(env[0].Name).To(Equal("ACCESS_KEY")) + Expect(env[0].ValueFrom.SecretKeyRef.Name).To(Equal(utils.RHOOKPAccessKeySecretName)) + Expect(env[0].ValueFrom.SecretKeyRef.Key).To(Equal(utils.RHOOKPAccessKeySecretKey)) + Expect(*env[0].ValueFrom.SecretKeyRef.Optional).To(BeTrue()) + }) +}) diff --git a/internal/controller/appserver/suite_test.go b/internal/controller/appserver/suite_test.go index d05c91d15..4c5d001a9 100644 --- a/internal/controller/appserver/suite_test.go +++ b/internal/controller/appserver/suite_test.go @@ -149,6 +149,7 @@ var _ = BeforeSuite(func() { tr.AppServerImage = utils.OLSAppServerImageDefault tr.DataverseExporter = utils.DataverseExporterImageDefault tr.McpServerImage = utils.OpenShiftMCPServerImageDefault + tr.RhokpImage = utils.RHOOKPImageDefault tr.PrometheusAvailable = true } diff --git a/internal/controller/olsconfig_helpers.go b/internal/controller/olsconfig_helpers.go index 07f50c085..083ecc152 100644 --- a/internal/controller/olsconfig_helpers.go +++ b/internal/controller/olsconfig_helpers.go @@ -69,6 +69,10 @@ func (r *OLSConfigReconciler) GetDataverseExporterImage() string { return r.Options.DataverseExporterImage } +func (r *OLSConfigReconciler) GetRHOOKPImage() string { + return r.Options.RHOOKPImage +} + func (r *OLSConfigReconciler) IsPrometheusAvailable() bool { return r.Options.PrometheusAvailable } diff --git a/internal/controller/reconciler/interface.go b/internal/controller/reconciler/interface.go index c525b1e84..402a54d0e 100644 --- a/internal/controller/reconciler/interface.go +++ b/internal/controller/reconciler/interface.go @@ -61,9 +61,12 @@ type Reconciler interface { // GetOpenShiftMCPServerImage returns the OpenShift MCP server image to use GetOpenShiftMCPServerImage() string - // GetDataverseExporterImage returns the OpenShift MCP server image to use + // GetDataverseExporterImage returns the dataverse exporter image to use GetDataverseExporterImage() string + // GetRHOOKPImage returns the RH Offline Knowledge Portal (Solr) sidecar image + GetRHOOKPImage() string + // IsPrometheusAvailable returns whether Prometheus Operator CRDs are available IsPrometheusAvailable() bool diff --git a/internal/controller/utils/constants.go b/internal/controller/utils/constants.go index 85c34a88a..b5d4ca28f 100644 --- a/internal/controller/utils/constants.go +++ b/internal/controller/utils/constants.go @@ -293,8 +293,29 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' MetricsReaderServiceAccountName = "lightspeed-operator-metrics-reader" // MCP server URL OpenShiftMCPServerURL = "http://localhost:%d/mcp" - // MCP server port - OpenShiftMCPServerPort = 8080 + // MCP server port (8081 so RH OKP can use 8080 in the same pod network namespace). + OpenShiftMCPServerPort = 8081 + // RHOOKPHTTPPort is the Solr HTTP proxy port for the RH OKP sidecar (Apache on 8080 per RHOKP image). + RHOOKPHTTPPort = 8080 + // RHOOKPReadinessHTTPPath is probed once Apache is up (Solr logs "Happy searching!" shortly after). + RHOOKPReadinessHTTPPath = "/" + // RHOOKPProbeInitialDelaySeconds allows Solr to finish startup before liveness can restart the sidecar. + RHOOKPProbeInitialDelaySeconds = 60 + RHOOKPProbePeriodSeconds = 10 + RHOOKPProbeTimeoutSeconds = 5 + RHOOKPProbeFailureThreshold = 3 + RHOOKPAccessKeySecretName = "rhokp-access-key" // #nosec G101 -- user-created secret for RHOKP portal access + RHOOKPAccessKeySecretKey = "ACCESS_KEY" + // RHOKP image paths: disable Apache SSL Listen on 8443 so lightspeed-service-api can use 8443 in the same pod. + RHOOKPHTTPDSSLConfPath = "/etc/httpd/conf.d/ssl.conf" + RHOOKPContainerEntrypoint = "/usr/bin/container-entrypoint" + RHOOKPMainCommand = "/usr/local/bin/mel" + // Solr hybrid defaults written to the OLS config file (not exposed on OLSConfig CR). + SolrHybridMaxResultsDefault = 5 + SolrHybridVectorBoostDefault = 8.0 + SolrHybridPoolDocsDefault = 100 + SolrHybridScoreThresholdDefault = 0.0 + SolrHybridSolrTimeoutSecondsDefault = 60.0 // MCP server timeout, sec OpenShiftMCPServerTimeout = 60 // MCP server SSE read timeout, sec @@ -348,6 +369,8 @@ ssl_ca_file = '/etc/certs/cm-olspostgresca/service-ca.crt' PostgresContainerName = "lightspeed-postgres-server" // OpenShiftMCPServerContainerName is the name of the OpenShift MCP server container OpenShiftMCPServerContainerName = "openshift-mcp-server" + // RHOOKPContainerName is the RH Offline Knowledge Portal (Solr) sidecar container name. + RHOOKPContainerName = "rhokp" // OLSConfigMapResourceVersionAnnotation is the annotation key for tracking OLS ConfigMap ResourceVersion OLSConfigMapResourceVersionAnnotation = "ols.openshift.io/olsconfig-configmap-version" // OpenShiftMCPServerConfigMapResourceVersionAnnotation is the annotation key for tracking MCP Server ConfigMap ResourceVersion @@ -374,4 +397,5 @@ var ( OpenShiftMCPServerImageDefault = relatedimages.GetDefaultImage("openshift-mcp-server") DataverseExporterImageDefault = relatedimages.GetDefaultImage("lightspeed-to-dataverse-exporter") OcpRagImageDefault = relatedimages.GetDefaultImage("lightspeed-ocp-rag") + RHOOKPImageDefault = relatedimages.GetDefaultImage("rhokp") ) diff --git a/internal/controller/utils/resource_defaults_test.go b/internal/controller/utils/resource_defaults_test.go index 98c0b004d..33f5ed737 100644 --- a/internal/controller/utils/resource_defaults_test.go +++ b/internal/controller/utils/resource_defaults_test.go @@ -44,6 +44,14 @@ var _ = Describe("Resource defaults and test reconciler", func() { Expect(sc.Capabilities.Drop).To(ConsistOf(corev1.Capability("ALL"))) }) + It("RHOOKPContainerSecurityContext allows a writable root filesystem", func() { + sc := RHOOKPContainerSecurityContext() + Expect(sc).NotTo(BeNil()) + Expect(*sc.ReadOnlyRootFilesystem).To(BeFalse()) + Expect(*sc.RunAsNonRoot).To(BeTrue()) + Expect(*sc.AllowPrivilegeEscalation).To(BeFalse()) + }) + It("TestReconciler getters and watcher config reflect NewTestReconciler defaults", func() { sch := runtime.NewScheme() utilruntime.Must(corev1.AddToScheme(sch)) @@ -59,8 +67,9 @@ var _ = Describe("Resource defaults and test reconciler", func() { Expect(r.GetOpenShiftMajor()).To(Equal("123")) Expect(r.GetOpenshiftMinor()).To(Equal("456")) Expect(r.GetAppServerImage()).To(Equal(OLSAppServerImageDefault)) - Expect(r.GetOpenShiftMCPServerImage()).To(Equal(OLSAppServerImageDefault)) + Expect(r.GetOpenShiftMCPServerImage()).To(Equal(OpenShiftMCPServerImageDefault)) Expect(r.GetDataverseExporterImage()).To(Equal(DataverseExporterImageDefault)) + Expect(r.GetRHOOKPImage()).To(Equal(RHOOKPImageDefault)) Expect(r.IsPrometheusAvailable()).To(BeTrue()) Expect(r.GetWatcherConfig()).To(BeNil()) diff --git a/internal/controller/utils/testing.go b/internal/controller/utils/testing.go index 37136fdb3..eb3376c4f 100644 --- a/internal/controller/utils/testing.go +++ b/internal/controller/utils/testing.go @@ -21,6 +21,7 @@ type TestReconciler struct { AppServerImage string McpServerImage string DataverseExporter string + RhokpImage string openShiftMajor string openShiftMinor string PrometheusAvailable bool @@ -67,6 +68,10 @@ func (r *TestReconciler) GetDataverseExporterImage() string { return r.DataverseExporter } +func (r *TestReconciler) GetRHOOKPImage() string { + return r.RhokpImage +} + func (r *TestReconciler) IsPrometheusAvailable() bool { return r.PrometheusAvailable } @@ -94,8 +99,9 @@ func NewTestReconciler( PostgresImage: PostgresServerImageDefault, ConsoleImage: ConsoleUIImageDefault, AppServerImage: OLSAppServerImageDefault, - McpServerImage: OLSAppServerImageDefault, + McpServerImage: OpenShiftMCPServerImageDefault, DataverseExporter: DataverseExporterImageDefault, + RhokpImage: RHOOKPImageDefault, openShiftMajor: "123", openShiftMinor: "456", PrometheusAvailable: true, // Default to true for tests to maintain backward compatibility diff --git a/internal/controller/utils/types.go b/internal/controller/utils/types.go index 791993931..855d55e21 100644 --- a/internal/controller/utils/types.go +++ b/internal/controller/utils/types.go @@ -23,6 +23,7 @@ type OLSConfigReconcilerOptions struct { ConsoleUIImage string DataverseExporterImage string OpenShiftMCPServerImage string + RHOOKPImage string Namespace string PrometheusAvailable bool } @@ -199,6 +200,20 @@ type OLSConfig struct { ToolFiltering *ToolFilteringConfig `json:"tool_filtering,omitempty"` // Tool execution approval configuration ToolsApproval *ToolsApprovalConfig `json:"tools_approval,omitempty"` + // Solr hybrid RAG (portal-rag /hybrid-search); mirrors lightspeed-service solr_hybrid + SolrHybrid *SolrHybridSettings `json:"solr_hybrid,omitempty"` +} + +// SolrHybridSettings configures Solr hybrid RAG retrieval for the OLS application config file. +type SolrHybridSettings struct { + SolrHTTPBase string `json:"solr_http_base,omitempty"` + MaxResults int `json:"max_results,omitempty"` + ChunkFilterQuery string `json:"chunk_filter_query,omitempty"` + HybridVectorBoost float64 `json:"hybrid_vector_boost,omitempty"` + HybridPoolDocs int `json:"hybrid_pool_docs,omitempty"` + HybridScoreThreshold float64 `json:"hybrid_score_threshold,omitempty"` + HybridSolrTimeoutSeconds float64 `json:"hybrid_solr_timeout_s,omitempty"` + SolrDirectRAG bool `json:"solr_direct_rag"` } type TLSSecurityProfileConfig struct { @@ -321,6 +336,8 @@ type ReferenceIndex struct { ProductDocsIndexId string `json:"product_docs_index_id,omitempty"` // Where the database was copied from, i.e. BYOK image name. ProductDocsOrigin string `json:"product_docs_origin,omitempty"` + // Required by lightspeed-service when solr_hybrid is enabled alongside local FAISS indexes. + ByokIndex bool `json:"byok_index,omitempty"` } type ReferenceContent struct { diff --git a/internal/controller/utils/utils.go b/internal/controller/utils/utils.go index 9993602d6..1ac5c168d 100644 --- a/internal/controller/utils/utils.go +++ b/internal/controller/utils/utils.go @@ -81,6 +81,14 @@ func RestrictedContainerSecurityContext() *corev1.SecurityContext { } } +// RHOOKPContainerSecurityContext is restricted PSS except readOnlyRootFilesystem: the RHOKP image +// writes Solr pid files, logs, and httpd config patches at startup. +func RHOOKPContainerSecurityContext() *corev1.SecurityContext { + sc := RestrictedContainerSecurityContext() + sc.ReadOnlyRootFilesystem = &[]bool{false}[0] + return sc +} + // ApplyPodDeploymentConfig applies PodDeploymentConfig settings to a Deployment. // This centralizes the logic for applying pod-level configurations (NodeSelector, Tolerations, etc.) // to avoid code duplication across different deployment generators.