diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index ccdcb97..cd96fe9 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -1,5 +1,6 @@ /* Copyright 2025. + 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 @@ -24,8 +25,11 @@ const ( OpenStackLightspeedReadyCondition condition.Type = "OpenStackLightspeedReady" // OpenShift Lightspeed Operator Status=True condition which indicates if OpenShift Lightspeed is installed and - // operational and it can be used by OpenStack Lihgtspeed operator. + // operational and it can be used by OpenStack Lightspeed operator. OpenShiftLightspeedOperatorReadyCondition condition.Type = "OpenShiftLightspeedOperatorReady" + + // OCPRAGCondition Status=True condition which indicates the OCP RAG version resolution status + OCPRAGCondition condition.Type = "OCPRAGReady" ) // Common Messages used by API objects. @@ -42,6 +46,21 @@ const ( // OpenShiftLightspeedOperatorWaiting OpenShiftLightspeedOperatorWaiting = "Waiting for the OpenShift Lightspeed operator to deploy." - // OpenShiftLigthspeedOperatorReady + // OpenShiftLightspeedOperatorReady OpenShiftLightspeedOperatorReady = "OpenShift Lightspeed operator is ready." + + // OCPRAGDisabledMessage + OCPRAGDisabledMessage = "OCP RAG is disabled" + + // OCPRAGVersionResolvedMessage + OCPRAGVersionResolvedMessage = "OCP RAG version resolved: %s" + + // OCPRAGVersionFallbackMessage + OCPRAGVersionFallbackMessage = "Cluster version %s is not explicitly supported. Using 'latest' OCP documentation. Supported versions: %v" + + // OCPRAGDetectionFailedMessage + OCPRAGDetectionFailedMessage = "Failed to detect OCP cluster version" + + // OCPRAGOverrideInvalidMessage + OCPRAGOverrideInvalidMessage = "Invalid OCP RAG version override" ) diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 4d60a5f..3a6e4cc 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -37,6 +37,16 @@ type OpenStackLightspeedSpec struct { // +kubebuilder:validation:Optional // ContainerImage for the OpenStack Lightspeed RAG container (will be set to environmental default if empty) RAGImage string `json:"ragImage"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + // Enables automatic OCP documentation based on cluster version + EnableOCPRAG bool `json:"enableOCPRAG,omitempty"` + + // +kubebuilder:validation:Optional + // Allows forcing a specific OCP version instead of auto-detection. + // Format should be like "4.15", "4.16", etc. + OCPRAGVersionOverride string `json:"ocpVersionOverride,omitempty"` } // OpenStackLightspeedCore defines the desired state of OpenStackLightspeed @@ -106,6 +116,11 @@ type OpenStackLightspeedStatus struct { // ObservedGeneration - the most recent generation observed for this object. ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +optional + // ActiveOCPRAGVersion contains the OCP version being used for RAG configuration + // Will be one of: "4.16", "4.18", "latest", or empty if OCP RAG is disabled + ActiveOCPRAGVersion string `json:"activeOCPRAGVersion,omitempty"` } // +kubebuilder:object:root=true diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index 733a61c..81d5118 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -58,6 +58,11 @@ spec: description: Namespace where the CatalogSource containing the OLS operator is located type: string + enableOCPRAG: + default: false + description: Enables automatic OCP documentation based on cluster + version + type: boolean feedbackDisabled: description: Disable feedback collection type: boolean @@ -99,6 +104,11 @@ spec: description: Name of the model to use at the API endpoint provided in LLMEndpoint type: string + ocpVersionOverride: + description: |- + Allows forcing a specific OCP version instead of auto-detection. + Format should be like "4.15", "4.16", etc. + type: string ragImage: description: ContainerImage for the OpenStack Lightspeed RAG container (will be set to environmental default if empty) @@ -118,6 +128,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + activeOCPRAGVersion: + description: |- + ActiveOCPRAGVersion contains the OCP version being used for RAG configuration + Will be one of: "4.16", "4.18", "latest", or empty if OCP RAG is disabled + type: string conditions: description: Conditions items: diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index e4f0649..53ab6ce 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -24,7 +24,7 @@ metadata: } ] capabilities: Basic Install - createdAt: "2026-01-16T13:55:22Z" + createdAt: "2026-02-03T12:43:44Z" operatorframework.io/suggested-namespace: openshift-lightspeed operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 @@ -35,37 +35,15 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: - - kind: OpenStackLightspeed + - description: OpenStackLightspeed is the Schema for the openstacklightspeeds + API + displayName: Open Stack Lightspeed + kind: OpenStackLightspeed name: openstacklightspeeds.lightspeed.openstack.org specDescriptors: - - description: URL pointing to the LLM - displayName: LLMEndpoint - path: llmEndpoint - description: Type of the provider serving the LLM - displayName: LLMEndpointType + displayName: Provider Type path: llmEndpointType - - description: Name of the model to use at the API endpoint - displayName: ModelName - path: modelName - - description: Secret name containing API token for the LLMEndpoint - displayName: LLMCredentials - path: llmCredentials - x-descriptors: - - urn:alm:descriptor:io.kubernetes:Secret - - description: Configmap name containing a CA Certificates bundle - displayName: TLSCACertBundle - path: tlsCACertBundle - x-descriptors: - - urn:alm:descriptor:io.kubernetes:ConfigMap - - description: MaxTokensForResponse defines the maximum number of tokens to - be used for the response generation - displayName: MaxTokensForResponse - path: maxTokensForResponse - x-descriptors: - - urn:alm:descriptor:com.tectonic.ui:advanced - statusDescriptors: - - displayName: Conditions - path: conditions version: v1beta1 description: |- OpenStack Lightspeed is a generative AI-based virtual assistant for Red Hat OpenStack Services on OpenShift (RHOSO) users which integrates into the OpenShift Lightspeed. @@ -81,6 +59,14 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - config.openshift.io + resources: + - clusterversions + verbs: + - get + - list + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -187,6 +173,8 @@ spec: valueFrom: fieldRef: fieldPath: metadata.annotations['olm.targetNamespaces'] + - name: RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT + value: quay.io/openstack-lightspeed/rag-content:os-docs-2025.2 image: quay.io/openstack-lightspeed/operator:latest livenessProbe: httpGet: @@ -307,7 +295,11 @@ spec: - email: Lukas Piwowarski name: lpiwowar@redhat.com maturity: alpha + minKubeVersion: 1.31.0 provider: name: Red Hat url: https://github.com/openstack-lightspeed/operator + relatedImages: + - image: quay.io/openstack-lightspeed/rag-content:os-docs-2025.2 + name: openstack-lightspeed-image-url-default version: 0.0.1 diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index 5823341..270315f 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -58,6 +58,11 @@ spec: description: Namespace where the CatalogSource containing the OLS operator is located type: string + enableOCPRAG: + default: false + description: Enables automatic OCP documentation based on cluster + version + type: boolean feedbackDisabled: description: Disable feedback collection type: boolean @@ -99,6 +104,11 @@ spec: description: Name of the model to use at the API endpoint provided in LLMEndpoint type: string + ocpVersionOverride: + description: |- + Allows forcing a specific OCP version instead of auto-detection. + Format should be like "4.15", "4.16", etc. + type: string ragImage: description: ContainerImage for the OpenStack Lightspeed RAG container (will be set to environmental default if empty) @@ -118,6 +128,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + activeOCPRAGVersion: + description: |- + ActiveOCPRAGVersion contains the OCP version being used for RAG configuration + Will be one of: "4.16", "4.18", "latest", or empty if OCP RAG is disabled + type: string conditions: description: Conditions items: diff --git a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml index 10f2d5a..c5db191 100644 --- a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -12,37 +12,15 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: - - kind: OpenStackLightspeed + - description: OpenStackLightspeed is the Schema for the openstacklightspeeds + API + displayName: Open Stack Lightspeed + kind: OpenStackLightspeed name: openstacklightspeeds.lightspeed.openstack.org specDescriptors: - - description: URL pointing to the LLM - displayName: LLMEndpoint - path: llmEndpoint - description: Type of the provider serving the LLM - displayName: LLMEndpointType + displayName: Provider Type path: llmEndpointType - - description: Name of the model to use at the API endpoint - displayName: ModelName - path: modelName - - description: Secret name containing API token for the LLMEndpoint - displayName: LLMCredentials - path: llmCredentials - x-descriptors: - - urn:alm:descriptor:io.kubernetes:Secret - - description: Configmap name containing a CA Certificates bundle - displayName: TLSCACertBundle - path: tlsCACertBundle - x-descriptors: - - urn:alm:descriptor:io.kubernetes:ConfigMap - - description: MaxTokensForResponse defines the maximum number of tokens to - be used for the response generation - displayName: MaxTokensForResponse - path: maxTokensForResponse - x-descriptors: - - urn:alm:descriptor:com.tectonic.ui:advanced - statusDescriptors: - - displayName: Conditions - path: conditions version: v1beta1 description: |- OpenStack Lightspeed is a generative AI-based virtual assistant for Red Hat OpenStack Services on OpenShift (RHOSO) users which integrates into the OpenShift Lightspeed. @@ -83,6 +61,7 @@ spec: - email: Lukas Piwowarski name: lpiwowar@redhat.com maturity: alpha + minKubeVersion: 1.31.0 provider: name: Red Hat url: https://github.com/openstack-lightspeed/operator diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3a31a91..0c78574 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,14 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - config.openshift.io + resources: + - clusterversions + verbs: + - get + - list + - watch - apiGroups: - lightspeed.openstack.org resources: diff --git a/internal/controller/funcs.go b/internal/controller/funcs.go index a0431b8..2be67ed 100644 --- a/internal/controller/funcs.go +++ b/internal/controller/funcs.go @@ -18,7 +18,6 @@ package controller import ( "context" - "encoding/json" "fmt" "math/rand" "strconv" @@ -110,7 +109,14 @@ func RemoveOLSConfig( return false, err } - return true, nil + _, err = GetOLSConfig(ctx, helper) + if err != nil && k8s_errors.IsNotFound(err) { + return true, nil + } else if err != nil { + return false, err + } + + return false, nil } // GetOLSConfig returns OLSConfig if there is one present in the cluster. @@ -137,6 +143,30 @@ func GetOLSConfig(ctx context.Context, helper *common_helper.Helper) (uns.Unstru "OLSConfig") } +// BuildRAGConfigs builds the RAG configuration array. +// OpenStack RAG is always included first. +// OCP RAG is added if ocpVersion is provided. +func BuildRAGConfigs(instance *apiv1beta1.OpenStackLightspeed, ocpVersion string) []interface{} { + rags := []interface{}{ + // OpenStack RAG + map[string]interface{}{ + "image": instance.Spec.RAGImage, + "indexPath": OpenStackLightspeedVectorDBPath, + }, + } + + // Add OCP RAG if enabled + if ocpVersion != "" { + rags = append(rags, map[string]interface{}{ + "image": instance.Spec.RAGImage, + "indexPath": GetOCPVectorDBPath(ocpVersion), + "indexID": GetOCPIndexName(ocpVersion), + }) + } + + return rags +} + // PatchOLSConfig patches OLSConfig with information from OpenStackLightspeed instance. func PatchOLSConfig( helper *common_helper.Helper, @@ -187,17 +217,10 @@ func PatchOLSConfig( } // Patch the RAG section - // NOTE(lucasagomes): We don't need indexID here because the tag on our RAG images - // already matches the indexID that the Vector DB used when it was built. OLS leverages - // that to set the right index. - openstackRAG := []interface{}{ - map[string]interface{}{ - "image": instance.Spec.RAGImage, - "indexPath": OpenStackLightspeedVectorDBPath, - }, - } + // Build RAG array with priorities using BuildRAGConfigs + ragConfigs := BuildRAGConfigs(instance, instance.Status.ActiveOCPRAGVersion) - if err := uns.SetNestedSlice(olsConfig.Object, openstackRAG, "spec", "ols", "rag"); err != nil { + if err := uns.SetNestedSlice(olsConfig.Object, ragConfigs, "spec", "ols", "rag"); err != nil { return err } @@ -267,36 +290,20 @@ func PatchOLSConfig( return nil } -// IsOLSConfigReady returns true if required conditions are true for OLSConfig +// IsOLSConfigReady returns true if OLSConfig's overallStatus is Ready func IsOLSConfigReady(ctx context.Context, helper *common_helper.Helper) (bool, error) { olsConfig, err := GetOLSConfig(ctx, helper) if err != nil { return false, err } - olsConfigStatusList, found, err := uns.NestedSlice(olsConfig.Object, "status", "conditions") - if !found { - return false, err - } - - jsonData, err := json.Marshal(olsConfigStatusList) + overallStatus, found, err := uns.NestedString(olsConfig.Object, "status", "overallStatus") if err != nil { - return false, fmt.Errorf("failed to marshal OLSConfig status: %w", err) - } - - var OLSConfigConditions []metav1.Condition - err = json.Unmarshal(jsonData, &OLSConfigConditions) - if err != nil { - return false, fmt.Errorf("failed to unmarshal JSON containing condition.Conditions: %w", err) + return false, err } - requiredConditionTypes := []string{"ConsolePluginReady", "CacheReady", "ApiReady", "Reconciled"} - for _, OLSConfigCondition := range OLSConfigConditions { - for _, requiredConditionType := range requiredConditionTypes { - if OLSConfigCondition.Type == requiredConditionType && OLSConfigCondition.Status != metav1.ConditionTrue { - return false, OLSConfigPing(ctx, helper) - } - } + if !found || overallStatus != "Ready" { + return false, OLSConfigPing(ctx, helper) } return true, nil diff --git a/internal/controller/ocp_version.go b/internal/controller/ocp_version.go new file mode 100644 index 0000000..7fde573 --- /dev/null +++ b/internal/controller/ocp_version.go @@ -0,0 +1,154 @@ +/* +Copyright 2025. + +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 ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // OpenStackLightspeedOCPVectorDBPath - base path for OCP vector databases + OpenStackLightspeedOCPVectorDBPath = "/rag/ocp_vector_db/ocp" + + // OpenStackLightspeedOCPIndexPrefix - prefix for OCP index names + OpenStackLightspeedOCPIndexPrefix = "ocp-product-docs" + + // Supported OCP versions in the RAG database + OCPVersion416 = "4.16" + OCPVersion418 = "4.18" + OCPVersionLatest = "latest" +) + +// SupportedOCPVersions lists the OCP versions available in the RAG database +var SupportedOCPVersions = []string{OCPVersion416, OCPVersion418, OCPVersionLatest} + +// DetectOCPVersion detects the OpenShift cluster version +func DetectOCPVersion(ctx context.Context, helper *common_helper.Helper) (string, error) { + // Use raw client to access cluster-scoped resources + rawClient, err := GetRawClient(helper) + if err != nil { + return "", fmt.Errorf("failed to get raw client: %w", err) + } + + // Get ClusterVersion object + clusterVersion := &uns.Unstructured{} + clusterVersion.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "config.openshift.io", + Version: "v1", + Kind: "ClusterVersion", + }) + + err = rawClient.Get(ctx, client.ObjectKey{Name: "version"}, clusterVersion) + if err != nil { + return "", fmt.Errorf("failed to get ClusterVersion: %w", err) + } + + // Extract version from status.desired.version + // NOTE: We intentionally use desired.version rather than history[0].version because: + // - During OCP upgrades, desired.version reflects the target version + // - Users troubleshooting upgrade issues need docs for the NEW version + // - This provides proactive access to relevant documentation + version, found, err := uns.NestedString(clusterVersion.Object, "status", "desired", "version") + if err != nil { + return "", fmt.Errorf("failed to extract version from ClusterVersion: %w", err) + } + if !found { + return "", fmt.Errorf("version field not found in ClusterVersion status.desired.version") + } + + // Parse version to get major.minor (e.g., "4.15.0" -> "4.15") + majorMinor, err := ParseMajorMinorVersion(version) + if err != nil { + return "", fmt.Errorf("failed to parse version %s: %w", version, err) + } + + return majorMinor, nil +} + +// ParseMajorMinorVersion extracts major.minor version from full version string +// Example: "4.15.0-0.nightly-2024-01-15-123456" -> "4.15" +func ParseMajorMinorVersion(fullVersion string) (string, error) { + // Match major.minor pattern at the start + re := regexp.MustCompile(`^(\d+\.\d+)`) + matches := re.FindStringSubmatch(fullVersion) + + if len(matches) < 2 { + return "", fmt.Errorf("invalid version format: %s", fullVersion) + } + + return matches[1], nil +} + +// GetOCPIndexName converts version to index name format +// Example: "4.16" -> "ocp-product-docs-4_16" +// +// "latest" -> "ocp-product-docs-latest" +func GetOCPIndexName(version string) string { + // Replace dots with underscores (no-op for "latest") + versionFormatted := strings.ReplaceAll(version, ".", "_") + return fmt.Sprintf("%s-%s", OpenStackLightspeedOCPIndexPrefix, versionFormatted) +} + +// GetOCPVectorDBPath returns the full path to OCP vector DB for given version +// Example: "4.16" -> "/rag/ocp_vector_db/ocp-4.16" +// +// "latest" -> "/rag/ocp_vector_db/ocp-latest" +func GetOCPVectorDBPath(version string) string { + return fmt.Sprintf("%s-%s", OpenStackLightspeedOCPVectorDBPath, version) +} + +// IsSupportedOCPVersion checks if the version is explicitly supported in RAG DB +func IsSupportedOCPVersion(version string) bool { + return slices.Contains(SupportedOCPVersions, version) +} + +// ResolveOCPVersion determines the OCP version to use for RAG configuration +// Returns (version, isFallback, error) +// - version: The version to use (might be "latest" as fallback) +// - isFallback: true if falling back to "latest" for unsupported version +// - error: any error during version resolution +func ResolveOCPVersion(detectedVersion, overrideVersion string, enableOCPRAG bool) (string, bool, error) { + if !enableOCPRAG { + return "", false, nil + } + + // Use override if provided + if overrideVersion != "" { + return overrideVersion, false, nil + } + + if detectedVersion == "" { + return "", false, fmt.Errorf("no OCP version detected") + } + + // Check if detected version is supported + if IsSupportedOCPVersion(detectedVersion) { + return detectedVersion, false, nil + } + + // Fallback to latest for unsupported versions + return OCPVersionLatest, true, nil +} diff --git a/internal/controller/ocp_version_test.go b/internal/controller/ocp_version_test.go new file mode 100644 index 0000000..f10f978 --- /dev/null +++ b/internal/controller/ocp_version_test.go @@ -0,0 +1,412 @@ +/* +Copyright 2025. + +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" + + apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" +) + +const ( + // testRAGImage is the test image used in unit tests + testRAGImage = "test-image:latest" +) + +func TestGetOCPIndexName(t *testing.T) { + tests := []struct { + name string + version string + expected string + }{ + { + name: "Version 4.16", + version: "4.16", + expected: "ocp-product-docs-4_16", + }, + { + name: "Version 4.18", + version: "4.18", + expected: "ocp-product-docs-4_18", + }, + { + name: "Latest version", + version: "latest", + expected: "ocp-product-docs-latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetOCPIndexName(tt.version) + if result != tt.expected { + t.Errorf("GetOCPIndexName(%s) = %s, want %s", tt.version, result, tt.expected) + } + }) + } +} + +func TestGetOCPVectorDBPath(t *testing.T) { + tests := []struct { + name string + version string + expected string + }{ + { + name: "Version 4.16", + version: "4.16", + expected: "/rag/ocp_vector_db/ocp-4.16", + }, + { + name: "Version 4.18", + version: "4.18", + expected: "/rag/ocp_vector_db/ocp-4.18", + }, + { + name: "Latest version", + version: "latest", + expected: "/rag/ocp_vector_db/ocp-latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetOCPVectorDBPath(tt.version) + if result != tt.expected { + t.Errorf("GetOCPVectorDBPath(%s) = %s, want %s", tt.version, result, tt.expected) + } + }) + } +} + +func TestParseMajorMinorVersion(t *testing.T) { + tests := []struct { + name string + fullVersion string + expected string + shouldError bool + }{ + { + name: "Standard version", + fullVersion: "4.16.0", + expected: "4.16", + shouldError: false, + }, + { + name: "Version with build", + fullVersion: "4.18.0-0.nightly-2024-01-15-123456", + expected: "4.18", + shouldError: false, + }, + { + name: "Invalid version", + fullVersion: "invalid", + expected: "", + shouldError: true, + }, + { + name: "Empty version", + fullVersion: "", + expected: "", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseMajorMinorVersion(tt.fullVersion) + if tt.shouldError { + if err == nil { + t.Errorf("ParseMajorMinorVersion(%s) expected error, got nil", tt.fullVersion) + } + } else { + if err != nil { + t.Errorf("ParseMajorMinorVersion(%s) unexpected error: %v", tt.fullVersion, err) + } + if result != tt.expected { + t.Errorf("ParseMajorMinorVersion(%s) = %s, want %s", tt.fullVersion, result, tt.expected) + } + } + }) + } +} + +func TestResolveOCPVersion(t *testing.T) { + tests := []struct { + name string + detected string + override string + enableOCPRAG bool + expectedVer string + expectedFallback bool + shouldError bool + }{ + { + name: "OCP RAG disabled", + detected: "4.16", + override: "", + enableOCPRAG: false, + expectedVer: "", + expectedFallback: false, + shouldError: false, + }, + { + name: "Supported version detected", + detected: "4.16", + override: "", + enableOCPRAG: true, + expectedVer: "4.16", + expectedFallback: false, + shouldError: false, + }, + { + name: "Unsupported version - fallback", + detected: "4.17", + override: "", + enableOCPRAG: true, + expectedVer: "latest", + expectedFallback: true, + shouldError: false, + }, + { + name: "Version override", + detected: "4.18", + override: "4.16", + enableOCPRAG: true, + expectedVer: "4.16", + expectedFallback: false, + shouldError: false, + }, + { + name: "Custom override (any version allowed)", + detected: "4.16", + override: "4.99", + enableOCPRAG: true, + expectedVer: "4.99", + expectedFallback: false, + shouldError: false, + }, + { + name: "No version detected", + detected: "", + override: "", + enableOCPRAG: true, + expectedVer: "", + expectedFallback: false, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, isFallback, err := ResolveOCPVersion(tt.detected, tt.override, tt.enableOCPRAG) + if tt.shouldError { + if err == nil { + t.Errorf("ResolveOCPVersion expected error, got nil") + } + } else { + if err != nil { + t.Errorf("ResolveOCPVersion unexpected error: %v", err) + } + if version != tt.expectedVer { + t.Errorf("ResolveOCPVersion version = %s, want %s", version, tt.expectedVer) + } + if isFallback != tt.expectedFallback { + t.Errorf("ResolveOCPVersion isFallback = %v, want %v", isFallback, tt.expectedFallback) + } + } + }) + } +} + +func TestBuildRAGConfigs(t *testing.T) { + t.Run("OCP RAG disabled (empty version)", func(t *testing.T) { + instance := &apiv1beta1.OpenStackLightspeed{ + Spec: apiv1beta1.OpenStackLightspeedSpec{ + RAGImage: testRAGImage, + }, + } + + configs := BuildRAGConfigs(instance, "") + + if len(configs) != 1 { + t.Errorf("Expected 1 RAG config, got %d", len(configs)) + } + + // Type assert to map[string]interface{} + firstConfig, ok := configs[0].(map[string]interface{}) + if !ok { + t.Fatalf("Expected config to be map[string]interface{}, got %T", configs[0]) + } + + if firstConfig["image"] != testRAGImage { + t.Errorf("Expected image test-image:latest, got %v", firstConfig["image"]) + } + + if firstConfig["indexPath"] != OpenStackLightspeedVectorDBPath { + t.Errorf("Expected indexPath %s, got %v", OpenStackLightspeedVectorDBPath, firstConfig["indexPath"]) + } + + // Verify priority field is NOT present + if _, hasPriority := firstConfig["priority"]; hasPriority { + t.Errorf("Expected no priority field, but it was present") + } + }) + + t.Run("OCP RAG enabled", func(t *testing.T) { + instance := &apiv1beta1.OpenStackLightspeed{ + Spec: apiv1beta1.OpenStackLightspeedSpec{ + RAGImage: testRAGImage, + }, + } + + configs := BuildRAGConfigs(instance, "4.16") + + if len(configs) != 2 { + t.Errorf("Expected 2 RAG configs, got %d", len(configs)) + } + + // Check OpenStack RAG (first config) + osConfig, ok := configs[0].(map[string]interface{}) + if !ok { + t.Fatalf("Expected first config to be map[string]interface{}, got %T", configs[0]) + } + + if osConfig["image"] != testRAGImage { + t.Errorf("OpenStack RAG image = %v, want test-image:latest", osConfig["image"]) + } + + if osConfig["indexPath"] != OpenStackLightspeedVectorDBPath { + t.Errorf("OpenStack RAG indexPath = %v, want %s", osConfig["indexPath"], OpenStackLightspeedVectorDBPath) + } + + // Verify priority field is NOT present in OpenStack config + if _, hasPriority := osConfig["priority"]; hasPriority { + t.Errorf("Expected no priority field in OpenStack config, but it was present") + } + + // Check OCP RAG (second config) + ocpConfig, ok := configs[1].(map[string]interface{}) + if !ok { + t.Fatalf("Expected second config to be map[string]interface{}, got %T", configs[1]) + } + + if ocpConfig["image"] != testRAGImage { + t.Errorf("OCP RAG image = %v, want test-image:latest", ocpConfig["image"]) + } + + ocpPath, ok := ocpConfig["indexPath"].(string) + if !ok { + t.Fatalf("Expected indexPath to be string, got %T", ocpConfig["indexPath"]) + } + if ocpPath != "/rag/ocp_vector_db/ocp-4.16" { + t.Errorf("OCP indexPath = %s, want /rag/ocp_vector_db/ocp-4.16", ocpPath) + } + + ocpIndexID, ok := ocpConfig["indexID"].(string) + if !ok { + t.Fatalf("Expected indexID to be string, got %T", ocpConfig["indexID"]) + } + if ocpIndexID != "ocp-product-docs-4_16" { + t.Errorf("OCP indexID = %s, want ocp-product-docs-4_16", ocpIndexID) + } + + // Verify priority field is NOT present in OCP config + if _, hasPriority := ocpConfig["priority"]; hasPriority { + t.Errorf("Expected no priority field in OCP config, but it was present") + } + }) + + t.Run("OCP RAG with latest version", func(t *testing.T) { + instance := &apiv1beta1.OpenStackLightspeed{ + Spec: apiv1beta1.OpenStackLightspeedSpec{ + RAGImage: testRAGImage, + }, + } + + configs := BuildRAGConfigs(instance, "latest") + + if len(configs) != 2 { + t.Errorf("Expected 2 RAG configs, got %d", len(configs)) + } + + // Check OCP RAG with latest version + ocpConfig, ok := configs[1].(map[string]interface{}) + if !ok { + t.Fatalf("Expected second config to be map[string]interface{}, got %T", configs[1]) + } + + ocpPath, ok := ocpConfig["indexPath"].(string) + if !ok { + t.Fatalf("Expected indexPath to be string, got %T", ocpConfig["indexPath"]) + } + if ocpPath != "/rag/ocp_vector_db/ocp-latest" { + t.Errorf("OCP indexPath = %s, want /rag/ocp_vector_db/ocp-latest", ocpPath) + } + + ocpIndexID, ok := ocpConfig["indexID"].(string) + if !ok { + t.Fatalf("Expected indexID to be string, got %T", ocpConfig["indexID"]) + } + if ocpIndexID != "ocp-product-docs-latest" { + t.Errorf("OCP indexID = %s, want ocp-product-docs-latest", ocpIndexID) + } + }) +} + +func TestIsSupportedOCPVersion(t *testing.T) { + tests := []struct { + name string + version string + expected bool + }{ + { + name: "Supported version 4.16", + version: "4.16", + expected: true, + }, + { + name: "Supported version 4.18", + version: "4.18", + expected: true, + }, + { + name: "Supported version latest", + version: "latest", + expected: true, + }, + { + name: "Unsupported version 4.17", + version: "4.17", + expected: false, + }, + { + name: "Unsupported version 4.19", + version: "4.19", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSupportedOCPVersion(tt.version) + if result != tt.expected { + t.Errorf("IsSupportedOCPVersion(%s) = %v, want %v", tt.version, result, tt.expected) + } + }) + } +} diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 2813f35..67c32dd 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -63,6 +63,7 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,namespace=openshift-lightspeed,verbs=update;patch;delete // +kubebuilder:rbac:groups=operators.coreos.com,resources=subscriptions,namespace=openshift-lightspeed,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=operators.coreos.com,resources=installplans,namespace=openshift-lightspeed,verbs=get;list;watch;update;delete +// +kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -94,6 +95,9 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. r.Scheme, Log, ) + if err != nil { + return ctrl.Result{}, err + } // Save a copy of the conditions so that we can restore the LastTransitionTime // when a condition's state doesn't change. @@ -139,6 +143,9 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. instance.Status.Conditions.Init(&cl) instance.Status.ObservedGeneration = instance.Generation + // OCP Version Detection and Resolution - must be done early so status field is always set + r.resolveOCPVersion(ctx, helper, instance) + if !instance.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, helper, instance) } @@ -251,6 +258,94 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } +// resolveOCPVersion detects and resolves the OCP version to use for RAG configuration. +// Returns the active OCP version to use (or empty string if OCP RAG is disabled). +func (r *OpenStackLightspeedReconciler) resolveOCPVersion( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) string { + Log := helper.GetLogger() + + // If OCP RAG is disabled, mark condition as True with "disabled" message + if !instance.Spec.EnableOCPRAG { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OCPRAGCondition, + apiv1beta1.OCPRAGDisabledMessage, + ) + instance.Status.ActiveOCPRAGVersion = "" + return "" + } + + // Step 1: Detect cluster version + detectedVersion, err := DetectOCPVersion(ctx, helper) + + if err != nil { + Log.Info("Failed to detect OCP version, disabling OCP RAG", "error", err) + cond := condition.FalseCondition( + apiv1beta1.OCPRAGCondition, + condition.ErrorReason, + condition.SeverityError, + apiv1beta1.OCPRAGDetectionFailedMessage, + ) + cond.Message = fmt.Sprintf("%s: %s", apiv1beta1.OCPRAGDetectionFailedMessage, err.Error()) + instance.Status.Conditions.Set(cond) + instance.Status.ActiveOCPRAGVersion = "" + return "" + } + + Log.Info("Detected OCP cluster version", "version", detectedVersion) + + // Step 2: Resolve which version to use (with override and fallback) + activeVersion, isFallback, err := ResolveOCPVersion( + detectedVersion, + instance.Spec.OCPRAGVersionOverride, + instance.Spec.EnableOCPRAG, + ) + + if err != nil { + // Invalid override + Log.Error(err, "Invalid OCP version configuration") + cond := condition.FalseCondition( + apiv1beta1.OCPRAGCondition, + condition.ErrorReason, + condition.SeverityError, + apiv1beta1.OCPRAGOverrideInvalidMessage, + ) + cond.Message = fmt.Sprintf("%s: %s", apiv1beta1.OCPRAGOverrideInvalidMessage, err.Error()) + instance.Status.Conditions.Set(cond) + instance.Status.ActiveOCPRAGVersion = "" + return "" + } + + // Step 3: Update status and conditions based on resolution + instance.Status.ActiveOCPRAGVersion = activeVersion + + if isFallback { + Log.Info("Using 'latest' OCP documentation as fallback", + "detectedVersion", detectedVersion, + "supportedVersions", SupportedOCPVersions) + + cond := condition.TrueCondition( + apiv1beta1.OCPRAGCondition, + "Fallback", + ) + cond.Message = fmt.Sprintf(apiv1beta1.OCPRAGVersionFallbackMessage, + detectedVersion, SupportedOCPVersions) + instance.Status.Conditions.Set(cond) + } else { + Log.Info("Using OCP RAG documentation", "version", activeVersion) + cond := condition.TrueCondition( + apiv1beta1.OCPRAGCondition, + "Resolved", + ) + cond.Message = fmt.Sprintf(apiv1beta1.OCPRAGVersionResolvedMessage, activeVersion) + instance.Status.Conditions.Set(cond) + } + + return activeVersion +} + // reconcileDelete reconciles the deletion of OpenStackLightspeed instance func (r *OpenStackLightspeedReconciler) reconcileDelete( ctx context.Context, @@ -284,6 +379,15 @@ func (r *OpenStackLightspeedReconciler) reconcileDelete( // SetupWithManager sets up the controller with the Manager. func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Create an unstructured ClusterVersion for watching + // This triggers reconciliation when OCP is upgraded (e.g., 4.16 -> 4.18) + clusterVersion := &uns.Unstructured{} + clusterVersion.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "config.openshift.io", + Version: "v1", + Kind: "ClusterVersion", + }) + return ctrl.NewControllerManagedBy(mgr). For(&apiv1beta1.OpenStackLightspeed{}). Owns(&operatorsv1alpha1.ClusterServiceVersion{}). @@ -293,20 +397,34 @@ func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error handler.EnqueueRequestsFromMapFunc(r.NotifyAllOpenStackLightspeeds), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). + Watches( + clusterVersion, + handler.EnqueueRequestsFromMapFunc(r.NotifyAllOpenStackLightspeeds), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Complete(r) } -// NotifyAllOpenStackLightspeeds returns a list of reconcile requests for all OpenStackLightspeed objects -// in the same namespace as the given InstallPlan. This is used to trigger reconciliation on all -// OpenStackLightspeed resources when an InstallPlan in their namespace changes. +// NotifyAllOpenStackLightspeeds returns a list of reconcile requests for all OpenStackLightspeed objects. +// For namespace-scoped resources (like InstallPlan), it lists in the same namespace as the triggering object. +// For cluster-scoped resources (like ClusterVersion), it lists in all namespaces the operator can access. func (r *OpenStackLightspeedReconciler) NotifyAllOpenStackLightspeeds(ctx context.Context, obj client.Object) []ctrl.Request { - // Pre-allocate requests slice with the capacity equal to the number of OpenStackLightspeed objects var lightspeedList apiv1beta1.OpenStackLightspeedList - if err := r.List(ctx, &lightspeedList, client.InNamespace(obj.GetNamespace())); err != nil { + var err error + + // For cluster-scoped resources (no namespace), list without namespace filter + // The operator's cache is already restricted to the watch namespace, so this is safe + if obj.GetNamespace() == "" { + err = r.List(ctx, &lightspeedList) + } else { + err = r.List(ctx, &lightspeedList, client.InNamespace(obj.GetNamespace())) + } + + if err != nil { return nil } - requests := make([]ctrl.Request, 0, len(lightspeedList.Items)) + requests := make([]ctrl.Request, 0, len(lightspeedList.Items)) for _, item := range lightspeedList.Items { requests = append(requests, ctrl.Request{ NamespacedName: client.ObjectKey{ diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml index e57cefb..608fcf6 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml @@ -96,9 +96,7 @@ status: - type: ApiReady status: "True" reason: Available - - type: Reconciled - status: "True" - reason: Available + overallStatus: Ready --- apiVersion: lightspeed.openstack.org/v1beta1 kind: OpenStackLightspeed @@ -115,12 +113,17 @@ spec: tlsCACertBundle: openstack-lightspeed-cert llmProjectID: test-project-id llmDeploymentName: test-deployment-name + llmAPIVersion: v1 status: conditions: - type: Ready status: "True" reason: Ready message: Setup complete + - type: OCPRAGReady + status: "True" + reason: Ready + message: OCP RAG is disabled - type: OpenShiftLightspeedOperatorReady status: "True" reason: Ready diff --git a/test/kuttl/common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml index 17479b0..b67be12 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml @@ -13,3 +13,4 @@ spec: llmProjectID: test-project-id llmDeploymentName: test-deployment-name llmAPIVersion: v1 + enableOCPRAG: false diff --git a/test/kuttl/tests/update-openstacklightspeed/04-update-openstack-lightspeed-instance.yaml b/test/kuttl/tests/update-openstacklightspeed/04-update-openstack-lightspeed-instance.yaml index 2c61d41..d3eec83 100644 --- a/test/kuttl/tests/update-openstacklightspeed/04-update-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/04-update-openstack-lightspeed-instance.yaml @@ -15,3 +15,5 @@ spec: llmAPIVersion: v1.1 feedbackDisabled: true transcriptsDisabled: true + enableOCPRAG: true + ocpVersionOverride: "4.16" diff --git a/test/kuttl/tests/update-openstacklightspeed/05-assert-olsconfig-update.yaml b/test/kuttl/tests/update-openstacklightspeed/05-assert-olsconfig-update.yaml index 1a06ac5..8d11a88 100644 --- a/test/kuttl/tests/update-openstacklightspeed/05-assert-olsconfig-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/05-assert-olsconfig-update.yaml @@ -29,6 +29,9 @@ spec: - image: quay.io/openstack-lightspeed/rag-content:os-docs-2025.2 indexID: "" indexPath: /rag/vector_db/os_product_docs + - image: quay.io/openstack-lightspeed/rag-content:os-docs-2025.2 + indexID: ocp-product-docs-4_16 + indexPath: /rag/ocp_vector_db/ocp-4.16 userDataCollection: feedbackDisabled: true transcriptsDisabled: true diff --git a/test/kuttl/tests/update-openstacklightspeed/06-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/06-assert-openstacklightspeed-update.yaml new file mode 100644 index 0000000..e594631 --- /dev/null +++ b/test/kuttl/tests/update-openstacklightspeed/06-assert-openstacklightspeed-update.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openshift-lightspeed +status: + activeOCPRAGVersion: "4.16" + conditions: + - type: Ready + status: "True" + - type: OCPRAGReady + status: "True" + - type: OpenShiftLightspeedOperatorReady + status: "True" + - type: OpenStackLightspeedReady + status: "True" diff --git a/test/kuttl/tests/update-openstacklightspeed/06-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/update-openstacklightspeed/07-cleanup-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/update-openstacklightspeed/06-cleanup-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/update-openstacklightspeed/07-cleanup-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/update-openstacklightspeed/07-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/update-openstacklightspeed/08-errors-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/update-openstacklightspeed/07-errors-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/update-openstacklightspeed/08-errors-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/update-openstacklightspeed/08-cleanup-mock-objects.yaml b/test/kuttl/tests/update-openstacklightspeed/09-cleanup-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/update-openstacklightspeed/08-cleanup-mock-objects.yaml rename to test/kuttl/tests/update-openstacklightspeed/09-cleanup-mock-objects.yaml diff --git a/test/kuttl/tests/update-openstacklightspeed/09-errors-mock-objects.yaml b/test/kuttl/tests/update-openstacklightspeed/10-errors-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/update-openstacklightspeed/09-errors-mock-objects.yaml rename to test/kuttl/tests/update-openstacklightspeed/10-errors-mock-objects.yaml