Skip to content
Open
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
22 changes: 22 additions & 0 deletions api/v1beta1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const (
// OpenShift Lightspeed Operator Status=True condition which indicates if OpenShift Lightspeed is installed and
// operational and it can be used by OpenStack Lightspeed operator.
OpenShiftLightspeedOperatorReadyCondition condition.Type = "OpenShiftLightspeedOperatorReady"

// OpenStackLightspeedMCPServerReadyCondition is set to True when the MCP server
// deployment succeeds. False indicates a failure during MCP server deployment.
OpenStackLightspeedMCPServerReadyCondition condition.Type = "OpenStackLightspeedMCPServerReady"
)

// Common Messages used by API objects.
Expand All @@ -46,6 +50,24 @@ const (
// OpenShiftLightspeedOperatorReady
OpenShiftLightspeedOperatorReady = "OpenShift Lightspeed operator is ready."

// OpenStackLightspeedMCPServerInitMessage
OpenStackLightspeedMCPServerInitMessage = "MCP server deployment has not resolved"

// OpenStackLightspeedMCPServerDeployed
OpenStackLightspeedMCPServerDeployed = "MCP server is ready"

// OpenStackLightspeedMCPServerWaitingOpenStack
OpenStackLightspeedMCPServerWaitingOpenStack = "MCP server deployed, waiting for OpenStackControlPlane to become ready"

// OpenStackLightspeedMCPServerCreatingUser
OpenStackLightspeedMCPServerCreatingUser = "Creating OpenStack service user"

// OpenStackLightspeedMCPServerWaitingAC
OpenStackLightspeedMCPServerWaitingAC = "Waiting for application credential secret"

// OpenStackLightspeedMCPServerDisabledMessage
OpenStackLightspeedMCPServerDisabledMessage = "RHOS MCP server is disabled (rhos_mcps feature flag not set)"

// DeploymentCheckFailedMessage
DeploymentCheckFailedMessage = "Failed to check deployment status: %s"

Expand Down
108 changes: 82 additions & 26 deletions api/v1beta1/openstacklightspeed_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ limitations under the License.
package v1beta1

import (
"reflect"

"github.com/openstack-k8s-operators/lib-common/modules/common/condition"
"github.com/openstack-k8s-operators/lib-common/modules/common/util"
"k8s.io/apimachinery/pkg/api/resource"
Expand Down Expand Up @@ -46,6 +48,9 @@ const (
// OKPContainerImage is the fall-back container image for OKP (Offline Knowledge Portal)
OKPContainerImage = "registry.redhat.io/offline-knowledge-portal/rhokp-rhel9:latest"

// MCPServerContainerImage is the fall-back container image for the MCP server
MCPServerContainerImage = "quay.io/openstack-lightspeed/rhos-mcps:latest"

// MaxTokensForResponseDefault is the default maximum number of tokens that should be used for response
MaxTokensForResponseDefault = 2048
)
Expand Down Expand Up @@ -95,6 +100,11 @@ type DatabaseSpec struct {
type OpenStackLightspeedSpec struct {
OpenStackLightspeedCore `json:",inline"`

// +kubebuilder:validation:Optional
// Images configures container images used by the operator.
// When omitted, each image defaults to its environment variable or hardcoded fallback.
Images OpenStackLightspeedImages `json:"images,omitempty"`

// +kubebuilder:validation:Optional
// Database configures persistent storage for PostgreSQL data.
// When omitted, an emptyDir volume is used (data is lost on pod reschedule).
Expand Down Expand Up @@ -202,6 +212,16 @@ type OpenStackLightspeedStatus struct {

// ObservedGeneration - the most recent generation observed for this object.
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// +optional
// OpenStackReady indicates whether an OpenStackControlPlane was detected and
// is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config.
OpenStackReady bool `json:"openStackReady,omitempty"`

// +optional
// ApplicationCredentialSecret is the name of the current AC secret in the
// OpenStack namespace. Tracked for rotation detection.
ApplicationCredentialSecret string `json:"applicationCredentialSecret,omitempty"`
}

// +kubebuilder:object:root=true
Expand All @@ -226,6 +246,7 @@ type OpenStackLightspeedStatus struct {
// +operator-sdk:csv:customresourcedefinitions:resources={{PersistentVolumeClaim,v1,openstack-lightspeed-database}}
// +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRole,v1,lightspeed-app-server-sar-role}}
// +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRoleBinding,v1,lightspeed-app-server-sar-role-binding}}
// +operator-sdk:csv:customresourcedefinitions:resources={{ConfigMap,v1,mcp-config}}
// +operator-sdk:csv:customresourcedefinitions:resources={{Subscription,v1alpha1}}
// +operator-sdk:csv:customresourcedefinitions:resources={{ClusterServiceVersion,v1alpha1}}
// +operator-sdk:csv:customresourcedefinitions:resources={{InstallPlan,v1alpha1}}
Expand Down Expand Up @@ -257,39 +278,74 @@ func (instance OpenStackLightspeed) IsReady() bool {
return instance.Status.Conditions.IsTrue(OpenStackLightspeedReadyCondition)
}

// OpenStackLightspeedImages groups container image URLs used by the operator.
type OpenStackLightspeedImages struct {
RAGImageURL string `json:"ragImage,omitempty"`
LCoreImageURL string `json:"lcoreImage,omitempty"`
ExporterImageURL string `json:"exporterImage,omitempty"`
PostgresImageURL string `json:"postgresImage,omitempty"`
ConsoleImageURL string `json:"consoleImage,omitempty"`
ConsoleImagePF5URL string `json:"consoleImagePF5,omitempty"`
OKPImageURL string `json:"okpImage,omitempty"`
MCPServerImageURL string `json:"mcpServerImage,omitempty"`
}

type OpenStackLightspeedDefaults struct {
RAGImageURL string
LCoreImageURL string
ExporterImageURL string
PostgresImageURL string
ConsoleImageURL string
ConsoleImagePF5URL string
OKPImageURL string
MaxTokensForResponse int
OpenStackLightspeedImages `json:",inline"`
MaxTokensForResponse int `json:"maxTokensForResponse,omitempty"`
}

var OpenStackLightspeedDefaultValues OpenStackLightspeedDefaults

// SetupDefaults - initializes OpenStackLightspeedDefaultValues with default values from env vars
// envVarDefaults holds the pristine env-var defaults set once by SetupDefaults.
// MergeDefaults copies from this so that removing dev overrides correctly
// reverts to the original values (the exported global gets overwritten each reconcile).
var envVarDefaults OpenStackLightspeedDefaults

// mergeImages applies non-zero fields from src onto dst.
func mergeImages(dst, src *OpenStackLightspeedImages) {
dstVal := reflect.ValueOf(dst).Elem()
srcVal := reflect.ValueOf(src).Elem()
for i := 0; i < srcVal.NumField(); i++ {
if !srcVal.Field(i).IsZero() {
dstVal.Field(i).Set(srcVal.Field(i))
}
}
}

// SetupDefaults initializes OpenStackLightspeedDefaultValues from env vars.
// Call once at startup; the values never change inside a container.
func SetupDefaults() {
// Acquire environmental defaults and initialize OpenStackLightspeed defaults with them
openStackLightspeedDefaults := OpenStackLightspeedDefaults{
RAGImageURL: util.GetEnvVar(
"RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT", OpenStackLightspeedContainerImage),
LCoreImageURL: util.GetEnvVar(
"RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT", LCoreContainerImage),
ExporterImageURL: util.GetEnvVar(
"RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT", ExporterContainerImage),
PostgresImageURL: util.GetEnvVar(
"RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT", PostgresContainerImage),
ConsoleImageURL: util.GetEnvVar(
"RELATED_IMAGE_CONSOLE_IMAGE_URL_DEFAULT", ConsoleContainerImage),
ConsoleImagePF5URL: util.GetEnvVar(
"RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT", ConsoleContainerImagePF5),
OKPImageURL: util.GetEnvVar(
"RELATED_IMAGE_OKP_IMAGE_URL_DEFAULT", OKPContainerImage),
envVarDefaults = OpenStackLightspeedDefaults{
OpenStackLightspeedImages: OpenStackLightspeedImages{
RAGImageURL: util.GetEnvVar(
"RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT", OpenStackLightspeedContainerImage),
LCoreImageURL: util.GetEnvVar(
"RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT", LCoreContainerImage),
ExporterImageURL: util.GetEnvVar(
"RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT", ExporterContainerImage),
PostgresImageURL: util.GetEnvVar(
"RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT", PostgresContainerImage),
ConsoleImageURL: util.GetEnvVar(
"RELATED_IMAGE_CONSOLE_IMAGE_URL_DEFAULT", ConsoleContainerImage),
ConsoleImagePF5URL: util.GetEnvVar(
"RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT", ConsoleContainerImagePF5),
OKPImageURL: util.GetEnvVar(
"RELATED_IMAGE_OKP_IMAGE_URL_DEFAULT", OKPContainerImage),
MCPServerImageURL: util.GetEnvVar(
"RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT", MCPServerContainerImage),
},
MaxTokensForResponse: MaxTokensForResponseDefault,
}
OpenStackLightspeedDefaultValues = envVarDefaults
}

OpenStackLightspeedDefaultValues = openStackLightspeedDefaults
// MergeDefaults returns a copy of the env-var defaults with the spec image
// overrides (if any) applied on top.
func MergeDefaults(specImages *OpenStackLightspeedImages) OpenStackLightspeedDefaults {
merged := envVarDefaults
if specImages != nil {
mergeImages(&merged.OpenStackLightspeedImages, specImages)
}
return merged
}
132 changes: 132 additions & 0 deletions api/v1beta1/openstacklightspeed_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
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 v1beta1

import (
"fmt"
"reflect"
"testing"
)

// TestOpenStackLightspeedImagesFieldTypes guards the mergeImages
// reflection-based implementation against future struct changes.
// mergeImages uses reflect and IsZero to copy non-zero fields; this
// only works correctly for simple types (string, int) where the zero
// value reliably means "not set". Adding an unexported field, or a
// complex type (slice, map, pointer, struct), would cause silent
// misbehavior or a panic.
func TestOpenStackLightspeedImagesFieldTypes(t *testing.T) {
allowedKinds := map[reflect.Kind]bool{
reflect.String: true,
}

typ := reflect.TypeOf(OpenStackLightspeedImages{})
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)

if !field.IsExported() {
t.Errorf("field %q is unexported; mergeImages uses reflect to set fields "+
"and cannot write unexported fields (will panic)", field.Name)
continue
}

if !allowedKinds[field.Type.Kind()] {
t.Errorf("field %q has type %s (kind %s); mergeImages relies on IsZero "+
"to detect unset values, which is only reliable for string — "+
"add handling in mergeImages before using this type",
field.Name, field.Type, field.Type.Kind())
}
}
}

func TestMergeImages(t *testing.T) {
dst := OpenStackLightspeedImages{
RAGImageURL: "original-rag",
LCoreImageURL: "original-lcore",
}
src := OpenStackLightspeedImages{
RAGImageURL: "override-rag",
ExporterImageURL: "override-exporter",
MCPServerImageURL: "override-mcp",
}

mergeImages(&dst, &src)

checks := []struct {
name string
got string
want string
}{
{"RAGImageURL (overridden)", dst.RAGImageURL, "override-rag"},
{"LCoreImageURL (kept)", dst.LCoreImageURL, "original-lcore"},
{"ExporterImageURL (set from zero)", dst.ExporterImageURL, "override-exporter"},
{"MCPServerImageURL (set from zero)", dst.MCPServerImageURL, "override-mcp"},
{"PostgresImageURL (both zero)", dst.PostgresImageURL, ""},
}
for _, tc := range checks {
if tc.got != tc.want {
t.Errorf("%s: got %q, want %q", tc.name, tc.got, tc.want)
}
}
}

func TestMergeImages_EmptySrc(t *testing.T) {
dst := OpenStackLightspeedImages{
RAGImageURL: "keep-this",
}
src := OpenStackLightspeedImages{}

mergeImages(&dst, &src)

if dst.RAGImageURL != "keep-this" {
t.Errorf("RAGImageURL changed unexpectedly to %q", dst.RAGImageURL)
}
}

func TestMergeDefaults_GlobalWriteBackDoesNotCorruptBase(t *testing.T) {
SetupDefaults()
original := OpenStackLightspeedDefaultValues

specImages := &OpenStackLightspeedImages{RAGImageURL: "custom-rag"}
merged := MergeDefaults(specImages)
OpenStackLightspeedDefaultValues = merged

reverted := MergeDefaults(nil)
if reverted.RAGImageURL != original.RAGImageURL {
t.Errorf("RAGImageURL not reverted: got %q, want %q",
reverted.RAGImageURL, original.RAGImageURL)
}
}

func TestMergeImages_AllFields(t *testing.T) {
// Verify mergeImages touches every field by setting all src fields
// to non-zero and confirming they all arrive in dst.
typ := reflect.TypeOf(OpenStackLightspeedImages{})
src := OpenStackLightspeedImages{}
srcVal := reflect.ValueOf(&src).Elem()
for i := 0; i < typ.NumField(); i++ {
f := srcVal.Field(i)
f.SetString(fmt.Sprintf("val-%d", i))
}

dst := OpenStackLightspeedImages{}
mergeImages(&dst, &src)

if !reflect.DeepEqual(dst, src) {
t.Errorf("after merging fully-populated src into zero dst, values differ:\n dst: %+v\n src: %+v", dst, src)
}
}
17 changes: 17 additions & 0 deletions api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading