From 5ef9c772e14833b0ec1f0adaeb9e9ba50ab271fa Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Fri, 26 Jun 2026 11:22:15 +0100 Subject: [PATCH] OKP: Filter by product version Update the default OKP chunk_filter_query to include version-specific filters for both OpenStack and OpenShift. For OCP version detection, the code reuses the DetectOCPVersion() and for OpenStack a new detectOpenStackVersion() was introduced. The way the OpenStack version is determined is by mapping the OCP versions accordingly to RHOSO. For example, OCP <= 4.21 maps to OpenStack 18.0. In the future, once RHOSO 19.0 is out, we just need to add a new map entry for it. If no entry is mapped the code log a message and safely return a default version for RHOSO (right now, 18.0). The dev.okpChunkFilterQuery override continues to bypass detection entirely. Kuttl tests pin the query via that override to keep assertions deterministic across cluster environments. Unit-tests were added to cover those kuttl gaps. Signed-off-by: Lucas Alvares Gomes --- api/v1beta1/openstacklightspeed_types.go | 2 +- go.mod | 2 +- internal/controller/common.go | 17 ++- internal/controller/constants.go | 18 +-- internal/controller/lcore_config.go | 22 +-- internal/controller/lcore_reconciler.go | 2 +- internal/controller/llama_stack_config.go | 12 +- internal/controller/openstack_version.go | 64 +++++++++ internal/controller/openstack_version_test.go | 132 ++++++++++++++++++ .../02-create-okp-resources.yaml | 1 + 10 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 internal/controller/openstack_version.go create mode 100644 internal/controller/openstack_version_test.go diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 33844ee..f5d22bc 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -55,7 +55,7 @@ const ( // // Supported fields: // - featureFlags: list of experimental feature flags to enable (e.g. ["okp"]) -// - okpChunkFilterQuery: Solr filter query for OKP searches (default: "product:(*openstack* OR *openshift*)") +// - okpChunkFilterQuery: Solr filter query for OKP searches (default: version-aware query combining detected OpenStack and OCP versions) // - okpRagOnly: when true, only OKP is used as a RAG source (default: false) type DevSpec struct { FeatureFlags []string `json:"featureFlags,omitempty"` diff --git a/go.mod b/go.mod index c832f9a..509bba4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/openstack-k8s-operators/lightspeed-operator go 1.24.6 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 @@ -22,7 +23,6 @@ replace github.com/openshift/api => github.com/openshift/api v0.0.0-202507112000 require ( cel.dev/expr v0.24.0 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect diff --git a/internal/controller/common.go b/internal/controller/common.go index 2755a38..da9b623 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -148,13 +148,24 @@ func isOKPEnabled(instance *apiv1beta1.OpenStackLightspeed) bool { return slices.Contains(config.FeatureFlags, "okp") } -// getOKPChunkFilterQuery returns the chunk filter query from the dev config, or the default. -func getOKPChunkFilterQuery(instance *apiv1beta1.OpenStackLightspeed) string { +// getOKPChunkFilterQuery returns the chunk filter query from the dev config, or a version-aware default. +func getOKPChunkFilterQuery(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) string { config, _ := parseDevConfig(instance) if config.OKPChunkFilterQuery != "" { return config.OKPChunkFilterQuery } - return OKPDefaultChunkFilterQuery + + logger := h.GetLogger() + + ocpVersion, err := DetectOCPVersion(ctx, h) + if err != nil { + logger.Error(err, "Failed to detect OCP version, using default", "default", OKPDefaultOCPVersion) + ocpVersion = OKPDefaultOCPVersion + } + + osVersion := detectRHOSOVersion(ocpVersion, logger) + + return fmt.Sprintf(OKPChunkFilterQueryFmt, osVersion, ocpVersion) } // getDeployment retrieves deployment from the cluster diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 7e42faf..088d7ff 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -120,14 +120,16 @@ const ( ServiceIDRHOSO = "rhos-lightspeed" // OKP (Offline Knowledge Portal) - OKPContainerName = "okp" - OKPContainerPort = int32(8080) - OKPDeploymentName = "lightspeed-okp-server" - OKPServiceName = "lightspeed-okp-server" - OKPServicePort = int32(8080) - OKPAccessKeySecretKey = "access_key" - OKPDefaultChunkFilterQuery = "product:(*openstack* OR *openshift*)" - ExternalProvidersDir = "/app-root/providers.d" + OKPContainerName = "okp" + OKPContainerPort = int32(8080) + OKPDeploymentName = "lightspeed-okp-server" + OKPServiceName = "lightspeed-okp-server" + OKPServicePort = int32(8080) + OKPAccessKeySecretKey = "access_key" + OKPDefaultOCPVersion = "4.21" + OKPDefaultRHOSOVersion = "18.0" + OKPChunkFilterQueryFmt = "((product:*openstack* AND product_version:%s) OR (product:*openshift* AND product_version:%s))" + ExternalProvidersDir = "/app-root/providers.d" // Console Plugin ConsoleUIConfigMapName = "lightspeed-console-plugin" diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index 5729570..f1981a0 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -17,6 +17,7 @@ limitations under the License. package controller import ( + "context" _ "embed" "fmt" @@ -203,25 +204,26 @@ ingress_connection_timeout: 30 } } -func buildOKPConfig(instance *apiv1beta1.OpenStackLightspeed) map[string]interface{} { +func buildOKPConfig(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) map[string]interface{} { offline := true if instance.Spec.OKP != nil && instance.Spec.OKP.Offline != nil { offline = *instance.Spec.OKP.Offline } - okpConfig := map[string]interface{}{ - "rhokp_url": "${env.RH_SERVER_OKP}", - "offline": offline, + return map[string]interface{}{ + "rhokp_url": "${env.RH_SERVER_OKP}", + "offline": offline, + "chunk_filter_query": getOKPChunkFilterQuery(ctx, h, instance), } - okpConfig["chunk_filter_query"] = getOKPChunkFilterQuery(instance) - return okpConfig } // buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML. // NOTE: MCP servers, quota handlers, and tools approval features are disabled for OpenStack Lightspeed. -func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { +func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { + okpEnabled := isOKPEnabled(instance) + ragInline := []interface{}{} - if isOKPEnabled(instance) { + if okpEnabled { ragInline = append(ragInline, "okp") } ragConfig := map[string]interface{}{ @@ -243,8 +245,8 @@ func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStac "rag": ragConfig, } - if isOKPEnabled(instance) { - config["okp"] = buildOKPConfig(instance) + if okpEnabled { + config["okp"] = buildOKPConfig(ctx, h, instance) } // Convert to YAML diff --git a/internal/controller/lcore_reconciler.go b/internal/controller/lcore_reconciler.go index 6f7228e..74dfbea 100644 --- a/internal/controller/lcore_reconciler.go +++ b/internal/controller/lcore_reconciler.go @@ -217,7 +217,7 @@ func reconcileLcoreConfigMap(h *common_helper.Helper, ctx context.Context, insta logger := h.GetLogger() // Build the YAML data - yamlData, err := buildLCoreConfigYAML(h, instance) + yamlData, err := buildLCoreConfigYAML(ctx, h, instance) if err != nil { return fmt.Errorf("%w: %v", ErrGenerateAPIConfigmap, err) } diff --git a/internal/controller/llama_stack_config.go b/internal/controller/llama_stack_config.go index f0d054a..b8941cd 100644 --- a/internal/controller/llama_stack_config.go +++ b/internal/controller/llama_stack_config.go @@ -254,16 +254,16 @@ func buildLlamaStackVectorDB(_ *common_helper.Helper, _ *apiv1beta1.OpenStackLig } } -func buildLlamaStackVectorIO(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) []interface{} { +func buildLlamaStackVectorIO(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed, chunkFilterQuery string) []interface{} { providers := buildLlamaStackVectorDB(h, instance) if isOKPEnabled(instance) { - providers = append(providers, buildOKPVectorIOProvider(instance)) + providers = append(providers, buildOKPVectorIOProvider(chunkFilterQuery)) } return providers } -func buildOKPVectorIOProvider(instance *apiv1beta1.OpenStackLightspeed) map[string]interface{} { - chunkFilterQuery := "is_chunk:true AND " + getOKPChunkFilterQuery(instance) +func buildOKPVectorIOProvider(chunkFilterQuery string) map[string]interface{} { + chunkFilterQuery = "is_chunk:true AND " + chunkFilterQuery return map[string]interface{}{ "provider_id": "okp_solr", @@ -426,8 +426,10 @@ func buildLlamaStackYAML(h *common_helper.Helper, ctx context.Context, instance return "", fmt.Errorf("failed to build inference providers: %w", err) } + okpChunkFilterQuery := "" if isOKPEnabled(instance) { config["external_providers_dir"] = ExternalProvidersDir + okpChunkFilterQuery = getOKPChunkFilterQuery(ctx, h, instance) } // Build providers map - only include providers for enabled APIs @@ -437,7 +439,7 @@ func buildLlamaStackYAML(h *common_helper.Helper, ctx context.Context, instance "inference": inferenceProviders, "safety": buildLlamaStackSafety(h, instance), "tool_runtime": buildLlamaStackToolRuntime(h, instance), - "vector_io": buildLlamaStackVectorIO(h, instance), + "vector_io": buildLlamaStackVectorIO(h, instance, okpChunkFilterQuery), } // Add top-level fields diff --git a/internal/controller/openstack_version.go b/internal/controller/openstack_version.go new file mode 100644 index 0000000..2dc9542 --- /dev/null +++ b/internal/controller/openstack_version.go @@ -0,0 +1,64 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "github.com/Masterminds/semver/v3" + "github.com/go-logr/logr" +) + +// ocpVersionBound maps an OCP version upper bound to a RHOSO version. +type ocpVersionBound struct { + maxOCPVersion string + rhosoVersion string +} + +// ocpToRHOSOVersionMap maps OCP version upper bounds to RHOSO versions. +// Entries must be in ascending order of maxOCPVersion. +// Versions above the highest bound fall back to OKPDefaultRHOSOVersion. +// Add a new entry here when a new RHOSO version's content becomes available in the knowledge base. +var ocpToRHOSOVersionMap = []ocpVersionBound{ + {"4.21", "18.0"}, + // When RHOSO 19.0 content is available, add: {"4.XX", "19.0"} +} + +// detectRHOSOVersion returns the RHOSO version corresponding to the given OCP version. +// Falls back to OKPDefaultRHOSOVersion if the version cannot be parsed or is above all defined bounds. +func detectRHOSOVersion(ocpVersion string, logger logr.Logger) string { + detected, err := semver.NewVersion(ocpVersion) + if err != nil { + logger.Info("Failed to parse OCP version, using default RHOSO version", + "ocpVersion", ocpVersion, "default", OKPDefaultRHOSOVersion) + return OKPDefaultRHOSOVersion + } + + for _, entry := range ocpToRHOSOVersionMap { + bound, err := semver.NewVersion(entry.maxOCPVersion) + if err != nil { + logger.Info("Invalid bound in RHOSO version map, using default", + "bound", entry.maxOCPVersion, "default", OKPDefaultRHOSOVersion) + return OKPDefaultRHOSOVersion + } + if detected.Compare(bound) <= 0 { + return entry.rhosoVersion + } + } + + logger.Info("OCP version above all known bounds, using default RHOSO version", + "ocpVersion", ocpVersion, "default", OKPDefaultRHOSOVersion) + return OKPDefaultRHOSOVersion +} diff --git a/internal/controller/openstack_version_test.go b/internal/controller/openstack_version_test.go new file mode 100644 index 0000000..c60eec6 --- /dev/null +++ b/internal/controller/openstack_version_test.go @@ -0,0 +1,132 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "testing" + + "github.com/go-logr/logr" +) + +func TestDetectRHOSOVersion(t *testing.T) { + logger := logr.Discard() + + tests := []struct { + name string + ocpVersion string + expected string + }{ + { + name: "Version below bound returns mapped RHOSO version", + ocpVersion: "4.16", + expected: "18.0", + }, + { + name: "Version at bound returns mapped RHOSO version", + ocpVersion: "4.21", + expected: "18.0", + }, + { + name: "Version with patch at bound returns mapped RHOSO version", + ocpVersion: "4.21.3", + expected: "18.0", + }, + { + name: "Version above all known bounds falls back to default", + ocpVersion: "4.22", + expected: OKPDefaultRHOSOVersion, + }, + { + name: "Far future version falls back to default", + ocpVersion: "5.0", + expected: OKPDefaultRHOSOVersion, + }, + { + name: "Invalid version string falls back to default", + ocpVersion: "not-a-version", + expected: OKPDefaultRHOSOVersion, + }, + { + name: "Empty version string falls back to default", + ocpVersion: "", + expected: OKPDefaultRHOSOVersion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectRHOSOVersion(tt.ocpVersion, logger) + if result != tt.expected { + t.Errorf("detectRHOSOVersion(%q) = %q, want %q", tt.ocpVersion, result, tt.expected) + } + }) + } +} + +func TestDetectRHOSOVersionMapOrdering(t *testing.T) { + logger := logr.Discard() + + // Save and restore the global map so this test is self-contained. + original := ocpToRHOSOVersionMap + t.Cleanup(func() { ocpToRHOSOVersionMap = original }) + + ocpToRHOSOVersionMap = []ocpVersionBound{ + {"4.21", "18.0"}, + {"5.99", "19.0"}, + } + + tests := []struct { + name string + ocpVersion string + expected string + }{ + { + name: "Version matched by first entry", + ocpVersion: "4.16", + expected: "18.0", + }, + { + name: "Version at boundary of first entry", + ocpVersion: "4.21", + expected: "18.0", + }, + { + name: "Version matched by second entry", + ocpVersion: "5.0", + expected: "19.0", + }, + { + name: "Version at boundary of second entry", + ocpVersion: "5.99", + expected: "19.0", + }, + { + name: "Version above all bounds falls back to default", + ocpVersion: "6.0", + expected: OKPDefaultRHOSOVersion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectRHOSOVersion(tt.ocpVersion, logger) + if result != tt.expected { + t.Errorf("detectRHOSOVersion(%q) = %q, want %q", tt.ocpVersion, result, tt.expected) + } + }) + } +} diff --git a/test/kuttl/tests/okp-configuration/02-create-okp-resources.yaml b/test/kuttl/tests/okp-configuration/02-create-okp-resources.yaml index 4df3cfd..49e9134 100644 --- a/test/kuttl/tests/okp-configuration/02-create-okp-resources.yaml +++ b/test/kuttl/tests/okp-configuration/02-create-okp-resources.yaml @@ -21,3 +21,4 @@ spec: dev: featureFlags: - okp + okpChunkFilterQuery: "product:(*openstack* OR *openshift*)"