Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/v1beta1/openstacklightspeed_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions internal/controller/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: I can see the DetectOCPVersion has been called multiple times , does it make sense to detect the version once and then pass it where ever call is made ?

@umago umago Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked a bit into that when i was reviewing the code but, to be honest, this is a cheap k8s API call that should be completely fine in practice. I would keep it like this for now to keep the code clearer and have buildLCoreConfigYAML() and buildLlamaStackYAML() independently (they both call this common method)

In the future I believe we should compeltely remove the buildLlamaStackYAML() cause in LCORE there are ways of generating the LlamaStack (OGX) config from the LCORE config itself and keep things consistent. We should do that instead IMHO.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if you guys think this duplication is something to address, I can do it no problem

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the OGX config generation from LCORE config for the future.

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
Expand Down
18 changes: 10 additions & 8 deletions internal/controller/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 12 additions & 10 deletions internal/controller/lcore_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package controller

import (
"context"
_ "embed"
"fmt"

Expand Down Expand Up @@ -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{}{
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/lcore_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 7 additions & 5 deletions internal/controller/llama_stack_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
64 changes: 64 additions & 0 deletions internal/controller/openstack_version.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
umago marked this conversation as resolved.
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
}
132 changes: 132 additions & 0 deletions internal/controller/openstack_version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ spec:
dev:
featureFlags:
- okp
okpChunkFilterQuery: "product:(*openstack* OR *openshift*)"
Loading