From b6ccce0ea36bcb61b751cc885504101441852dc9 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 13 May 2026 17:00:49 +0200 Subject: [PATCH 1/8] Update operator-sdk to v1.41.1 Align with the version used by the openstack-k8s-operators. --- .github/workflows/build-and-push.yaml | 2 +- .github/workflows/verify-generation.yaml | 2 +- Makefile | 2 +- bundle.Dockerfile | 2 +- .../openstack-lightspeed-operator.clusterserviceversion.yaml | 2 +- bundle/metadata/annotations.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-push.yaml b/.github/workflows/build-and-push.yaml index 62f66b58..2e326e29 100644 --- a/.github/workflows/build-and-push.yaml +++ b/.github/workflows/build-and-push.yaml @@ -59,7 +59,7 @@ jobs: uses: redhat-actions/openshift-tools-installer@v1 with: source: github - operator-sdk: 1.38.0 + operator-sdk: 1.41.1 - name: Log in to Quay uses: docker/login-action@v4 diff --git a/.github/workflows/verify-generation.yaml b/.github/workflows/verify-generation.yaml index 2b4a9989..cff819be 100644 --- a/.github/workflows/verify-generation.yaml +++ b/.github/workflows/verify-generation.yaml @@ -21,7 +21,7 @@ jobs: uses: redhat-actions/openshift-tools-installer@v1 with: source: github - operator-sdk: 1.38.0 + operator-sdk: 1.41.1 - name: Verify generated files are up to date and fail if anything changed run: | diff --git a/Makefile b/Makefile index 09e511f0..07fef3b4 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ endif # Set the Operator SDK version to use. By default, what is installed on the system is used. # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. -OPERATOR_SDK_VERSION ?= v1.38.0-ocp +OPERATOR_SDK_VERSION ?= v1.41.1 # Image URL to use all building/pushing image targets IMG ?= $(IMAGE_TAG_BASE):latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. diff --git a/bundle.Dockerfile b/bundle.Dockerfile index 3b92ba18..8ebc9815 100644 --- a/bundle.Dockerfile +++ b/bundle.Dockerfile @@ -6,7 +6,7 @@ LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ LABEL operators.operatorframework.io.bundle.package.v1=openstack-lightspeed-operator LABEL operators.operatorframework.io.bundle.channels.v1=alpha -LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.38.0 +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.41.1 LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v4 diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index 51669cc7..93568487 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -38,7 +38,7 @@ metadata: features.operators.openshift.io/token-auth-azure: "false" features.operators.openshift.io/token-auth-gcp: "false" operatorframework.io/suggested-namespace: openstack-lightspeed - operators.operatorframework.io/builder: operator-sdk-v1.38.0 + operators.operatorframework.io/builder: operator-sdk-v1.41.1 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 repository: https://github.com/openstack-lightspeed/operator name: openstack-lightspeed-operator.v0.0.1 diff --git a/bundle/metadata/annotations.yaml b/bundle/metadata/annotations.yaml index 828f4f13..142fb462 100644 --- a/bundle/metadata/annotations.yaml +++ b/bundle/metadata/annotations.yaml @@ -5,7 +5,7 @@ annotations: operators.operatorframework.io.bundle.metadata.v1: metadata/ operators.operatorframework.io.bundle.package.v1: openstack-lightspeed-operator operators.operatorframework.io.bundle.channels.v1: alpha - operators.operatorframework.io.metrics.builder: operator-sdk-v1.38.0 + operators.operatorframework.io.metrics.builder: operator-sdk-v1.41.1 operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v4 From 04e87c678f68c7fb9f150749fe0280ab8df963fc Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Tue, 9 Jun 2026 17:49:13 +0200 Subject: [PATCH 2/8] Deploy MCP server as sidecar container With this commit we start deploying the MCP server as a sidecar container of the lightspeed-service container. The MCP server deployed is the one from our own rhos-mcps repository [1]. On installation the `openshift-cli` tool is enabled when OpenStack Lightspeed is configured, and the `openstack-cli` tool is enabled when the `OpenStackControlPlane` is ready. From a security perspective since we are deploying the MCP tools in the pod's network namespace not using TLS is not a real security risk. [1]: https://github.com/openstack-lightspeed/rhos-mcps Jira: OSPRH-27075 Co-Authored-By: Claude Opus 4.6 --- api/v1beta1/conditions.go | 13 + api/v1beta1/openstacklightspeed_types.go | 12 + ...ed.openstack.org_openstacklightspeeds.yaml | 5 + ...tspeed-operator.clusterserviceversion.yaml | 41 ++- cmd/main.go | 43 ++- ...ed.openstack.org_openstacklightspeeds.yaml | 5 + config/manager/manager.yaml | 6 +- ...tspeed-operator.clusterserviceversion.yaml | 3 + config/rbac/role.yaml | 30 ++ go.mod | 2 +- .../assets/mcp_server_config.yaml.tmpl | 24 ++ internal/controller/common.go | 195 ++++++++++ internal/controller/constants.go | 9 + internal/controller/lcore_config.go | 32 +- internal/controller/lcore_deployment.go | 109 ++++++ internal/controller/mcp_server.go | 348 ++++++++++++++++++ internal/controller/mcp_server_test.go | 103 ++++++ .../openstacklightspeed_controller.go | 134 ++++++- .../openstacklightspeed_controller_test.go | 4 + internal/controller/postgres_reconciler.go | 6 +- .../lightspeed-stack-okp.yaml | 5 + .../lightspeed-stack-update.yaml | 5 + .../expected-configs/lightspeed-stack.yaml | 5 + .../assert-lightspeed-stack-config.yaml | 115 ++++++ .../assert-mcp-config.yaml | 32 ++ .../assert-openstack-lightspeed-instance.yaml | 52 +++ .../errors-openstack-lightspeed-instance.yaml | 8 + .../03-assert-mcp-config.yaml | 1 + .../03-assert-okp-instance.yaml | 17 + ...-assert-openstack-lightspeed-instance.yaml | 2 + .../03-assert-mcp-config.yaml | 1 + .../08-assert-openstacklightspeed-update.yaml | 52 +++ .../11-assert-configmaps-update.yaml | 35 ++ 33 files changed, 1436 insertions(+), 18 deletions(-) create mode 100644 internal/controller/assets/mcp_server_config.yaml.tmpl create mode 100644 internal/controller/mcp_server.go create mode 100644 internal/controller/mcp_server_test.go create mode 100644 test/kuttl/common/openstack-lightspeed-instance/assert-lightspeed-stack-config.yaml create mode 100644 test/kuttl/common/openstack-lightspeed-instance/assert-mcp-config.yaml create mode 120000 test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml create mode 120000 test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 3c990416..952a540c 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -28,6 +28,10 @@ const ( // 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" + // OCPRAGCondition Status=True condition which indicates the OCP RAG version resolution status OCPRAGCondition condition.Type = "OCPRAGReady" ) @@ -64,6 +68,15 @@ const ( // OCPRAGOverrideInvalidMessage OCPRAGOverrideInvalidMessage = "Invalid OCP RAG version override" + // 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" + // DeploymentCheckFailedMessage DeploymentCheckFailedMessage = "Failed to check deployment status: %s" diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 62fc5db1..f50c4768 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -46,6 +46,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 ) @@ -220,6 +223,11 @@ type OpenStackLightspeedStatus struct { // 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"` + + // +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"` } // +kubebuilder:object:root=true @@ -244,6 +252,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}} @@ -283,6 +292,7 @@ type OpenStackLightspeedDefaults struct { ConsoleImageURL string ConsoleImagePF5URL string OKPImageURL string + MCPServerImageURL string MaxTokensForResponse int } @@ -306,6 +316,8 @@ func SetupDefaults() { "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, } diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index 73b110a9..839ee8a0 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -251,6 +251,11 @@ spec: for this object. format: int64 type: integer + openStackReady: + description: |- + OpenStackReady indicates whether an OpenStackControlPlane was detected and + is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + type: boolean type: object type: object served: true diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index 93568487..3c92a641 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -110,6 +110,9 @@ spec: - kind: ConfigMap name: llama-stack-config version: v1 + - kind: ConfigMap + name: mcp-config + version: v1 - kind: Secret name: metrics-reader-token version: v1 @@ -162,6 +165,20 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - secrets + verbs: + - get - apiGroups: - "" resourceNames: @@ -170,6 +187,14 @@ spec: - secrets verbs: - get + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -190,6 +215,14 @@ spec: - patch - update - watch + - apiGroups: + - core.openstack.org + resources: + - openstackcontrolplanes + verbs: + - get + - list + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -305,6 +338,8 @@ spec: value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12 - name: RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 + - name: RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT + value: quay.io/openstack-lightspeed/rhos-mcps:latest image: quay.io/openstack-lightspeed/operator:latest livenessProbe: httpGet: @@ -322,10 +357,10 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 256Mi requests: cpu: 10m - memory: 64Mi + memory: 128Mi securityContext: allowPrivilegeEscalation: false capabilities: @@ -492,4 +527,6 @@ spec: name: console-image-url-default - image: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 name: console-pf5-image-url-default + - image: quay.io/openstack-lightspeed/rhos-mcps:latest + name: mcp-server-image-url-default version: 0.0.1 diff --git a/cmd/main.go b/cmd/main.go index 60471435..c17a384b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,13 +22,17 @@ import ( "fmt" "os" "strings" + "sync/atomic" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -62,6 +66,8 @@ func init() { utilruntime.Must(consolev1.AddToScheme(scheme)) utilruntime.Must(openshiftv1.AddToScheme(scheme)) + + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -146,7 +152,15 @@ func main() { setupLog.Info(fmt.Sprintf("openstack-lightspeed operator watches %s namespace", namespace)) } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + cfg := ctrl.GetConfigOrDie() + + kclient, err := kubernetes.NewForConfig(cfg) + if err != nil { + setupLog.Error(err, "unable to create kubernetes client") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, @@ -176,9 +190,14 @@ func main() { // Defaults for OpenStackLightspeed apiv1beta1.SetupDefaults() + dynamicWatchCRDs := getDynamicWatchCRDs() + if err = (&controller.OpenStackLightspeedReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Kclient: kclient, + Scheme: mgr.GetScheme(), + Cache: mgr.GetCache(), + DynamicWatchCRD: dynamicWatchCRDs, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "OpenStackLightspeed") os.Exit(1) @@ -216,3 +235,21 @@ func getWatchNamespaces() ([]string, error) { return strings.Split(ns, ","), nil } + +// getDynamicWatchCRDs returns a map of GroupVersionKind to *atomic.Bool +// representing resources that should be watched dynamically. The watch starts +// once they appear in the cluster for the first time (not required at operator +// start time). +// +// The OpenStackControlPlane GVK is hard-coded here to avoid pulling in the +// openstack-operator/api dependency (which is pinned to an older k8s version). +// The CRD is watched using unstructured types, so the Go type is not needed. +func getDynamicWatchCRDs() map[schema.GroupVersionKind]*atomic.Bool { + return map[schema.GroupVersionKind]*atomic.Bool{ + { + Group: "core.openstack.org", + Version: "v1beta1", + Kind: "OpenStackControlPlane", + }: new(atomic.Bool), + } +} diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index e90ba4d5..c1ae2c20 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -251,6 +251,11 @@ spec: for this object. format: int64 type: integer + openStackReady: + description: |- + OpenStackReady indicates whether an OpenStackControlPlane was detected and + is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + type: boolean type: object type: object served: true diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 114e18cb..45a314db 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -87,6 +87,8 @@ spec: value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12 - name: RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 + - name: RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT + value: quay.io/openstack-lightspeed/rhos-mcps:latest securityContext: allowPrivilegeEscalation: false capabilities: @@ -109,9 +111,9 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 256Mi requests: cpu: 10m - memory: 64Mi + memory: 128Mi serviceAccountName: controller-manager terminationGracePeriodSeconds: 10 diff --git a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml index 41daa0dd..e37d9f8c 100644 --- a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -87,6 +87,9 @@ spec: - kind: ConfigMap name: llama-stack-config version: v1 + - kind: ConfigMap + name: mcp-config + version: v1 - kind: Secret name: metrics-reader-token version: v1 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 613a8000..22f145e0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,20 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get - apiGroups: - "" resourceNames: @@ -12,6 +26,14 @@ rules: - secrets verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -32,6 +54,14 @@ rules: - patch - update - watch +- apiGroups: + - core.openstack.org + resources: + - openstackcontrolplanes + verbs: + - get + - list + - watch - apiGroups: - lightspeed.openstack.org resources: diff --git a/go.mod b/go.mod index 603dd825..7285da9a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 github.com/operator-framework/api v0.37.0 k8s.io/api v0.34.2 + k8s.io/apiextensions-apiserver v0.34.2 k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.2 sigs.k8s.io/controller-runtime v0.22.4 @@ -97,7 +98,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/apiserver v0.34.2 // indirect k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/internal/controller/assets/mcp_server_config.yaml.tmpl b/internal/controller/assets/mcp_server_config.yaml.tmpl new file mode 100644 index 00000000..353d715f --- /dev/null +++ b/internal/controller/assets/mcp_server_config.yaml.tmpl @@ -0,0 +1,24 @@ +--- +ip: 127.0.0.1 +port: 8080 +debug: false +workers: 1 +processes_pool_size: 10 + +openstack: + enabled: {{ .OpenStackEnabled }} + allow_write: false + ca_cert: ./tls-ca-bundle.pem + insecure: false + +openshift: + enabled: {{ .OpenShiftEnabled }} + allow_write: false + insecure: false + +mcp_transport_security: + enable_dns_rebinding_protection: true + allowed_hosts: + - "127.0.0.1:*" + - "localhost:*" + allowed_origins: [] diff --git a/internal/controller/common.go b/internal/controller/common.go index c4b4d949..53a87270 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -25,14 +25,21 @@ import ( "slices" "strings" + common_cm "github.com/openstack-k8s-operators/lib-common/modules/common/configmap" common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + common_secret "github.com/openstack-k8s-operators/lib-common/modules/common/secret" apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) // toPtr returns a pointer to the given value. @@ -83,6 +90,21 @@ func getConfigMapResourceVersion(ctx context.Context, h *common_helper.Helper, n return cm.ResourceVersion, nil } +// getSecretResourceVersion retrieves the resource version of a Secret. +func getSecretResourceVersion(ctx context.Context, h *common_helper.Helper, name string, namespace string) (string, error) { + rawClient, err := getRawClient(h) + if err != nil { + return "", fmt.Errorf("failed to get raw client: %w", err) + } + + secret := &corev1.Secret{} + err = rawClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, secret) + if err != nil { + return "", fmt.Errorf("failed to get secret %s: %w", name, err) + } + return secret.ResourceVersion, nil +} + // providerNameToEnvVarName converts a provider name to a valid environment variable name. // It uppercases the string and replaces hyphens and dots with underscores. func providerNameToEnvVarName(providerName string) string { @@ -170,3 +192,176 @@ func getDeployment(ctx context.Context, h *common_helper.Helper, name string, na return deployment, nil } + +// GetCRDName returns the name of the CustomResourceDefinition (CRD) for a given +// GroupVersionKind (GVK). The CRD name is constructed as "s." string. +func GetCRDName(gvk schema.GroupVersionKind) string { + return fmt.Sprintf("%ss.%s", strings.ToLower(gvk.Kind), gvk.Group) +} + +// IsCRDEstablished checks if a CRD exists and is in "Established" state (ready for use). +// Returns (true, nil) if the CRD exists and is established, (false, nil) if it doesn't exist, +// and (false, error) for other errors. +func IsCRDEstablished(ctx context.Context, helper *common_helper.Helper, gvk schema.GroupVersionKind) (bool, error) { + crdName := GetCRDName(gvk) + crd := &apiextensionsv1.CustomResourceDefinition{} + err := helper.GetClient().Get(ctx, client.ObjectKey{Name: crdName}, crd) + if err != nil { + if k8s_errors.IsNotFound(err) { + return false, nil + } + return false, err + } + + for _, cond := range crd.Status.Conditions { + if cond.Type == apiextensionsv1.Established && cond.Status == apiextensionsv1.ConditionTrue { + return true, nil + } + } + + return false, nil +} + +// OpenStackControlPlaneGVK returns the GroupVersionKind for OpenStackControlPlane. +func OpenStackControlPlaneGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: OpenStackControlPlaneGroup, + Version: OpenStackControlPlaneVersion, + Kind: OpenStackControlPlaneKind, + } +} + +// IsDynamicCRDReadyByGVK checks whether the given GVK is being watched and has +// been observed as ready by the dynamic watch. +func IsDynamicCRDReadyByGVK( + dynamicWatchCRD DynamicWatchCRD, + gvk schema.GroupVersionKind, +) (bool, error) { + seen, exists := dynamicWatchCRD[gvk] + if !exists { + return false, fmt.Errorf("GVK %v not found in DynamicWatchCRD map", gvk) + } + return seen.Load(), nil +} + +// OpenStackLightspeedChecksumAnnotation is the annotation key used to store the checksum of resources. +const OpenStackLightspeedChecksumAnnotation = "openstack.org/checksum" + +// SetChecksumAnnotation sets or updates the checksum annotation on the provided object. +func SetChecksumAnnotation(object client.Object, checksum string) { + annotations := object.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[OpenStackLightspeedChecksumAnnotation] = checksum + object.SetAnnotations(annotations) +} + +// GetChecksumAnnotation retrieves the checksum annotation from the given object. +// If the annotation is not found, it returns an empty string. +func GetChecksumAnnotation(object client.Object) string { + annotations := object.GetAnnotations() + if annotations == nil { + return "" + } + checksum, ok := annotations[OpenStackLightspeedChecksumAnnotation] + if !ok { + return "" + } + return checksum +} + +// GetDeploymentVolumeSection returns a pointer to the Volume in the Deployment's PodSpec +// whose name matches the given volumeSectionName. If no such volume is found, it returns nil. +func GetDeploymentVolumeSection(deployment appsv1.Deployment, volumeSectionName string) *corev1.Volume { + for i, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.Name == volumeSectionName { + return &deployment.Spec.Template.Spec.Volumes[i] + } + } + return nil +} + +// CopyResource copies a resource (Secret or ConfigMap) from one namespace to another, +// setting a controller reference on the copy and computing checksums. +func CopyResource( + ctx context.Context, + helper *common_helper.Helper, + sourceObject client.Object, + targetObject client.Object, + owner client.Object, + scheme *runtime.Scheme, +) (client.Object, error) { + var copyObject client.Object + var err error + + switch source := sourceObject.(type) { + case *corev1.Secret: + fetched, fetchErr := helper.GetKClient().CoreV1().Secrets(source.GetNamespace()).Get(ctx, source.GetName(), metav1.GetOptions{}) + if fetchErr != nil { + return nil, fetchErr + } + + copySecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetObject.GetName(), + Namespace: targetObject.GetNamespace(), + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), copySecret, func() error { + copySecret.Data = fetched.Data + copySecret.StringData = fetched.StringData + copySecret.Type = fetched.Type + if err := controllerutil.SetControllerReference(owner, copySecret, scheme); err != nil { + return err + } + + checksum, err := common_secret.Hash(copySecret) + if err != nil { + return err + } + SetChecksumAnnotation(copySecret, checksum) + return nil + }) + + copyObject = copySecret + case *corev1.ConfigMap: + fetched, fetchErr := helper.GetKClient().CoreV1().ConfigMaps(source.GetNamespace()).Get(ctx, source.GetName(), metav1.GetOptions{}) + if fetchErr != nil { + return nil, fetchErr + } + + copyConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetObject.GetName(), + Namespace: targetObject.GetNamespace(), + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), copyConfigMap, func() error { + copyConfigMap.Data = fetched.Data + copyConfigMap.BinaryData = fetched.BinaryData + if err := controllerutil.SetControllerReference(owner, copyConfigMap, scheme); err != nil { + return err + } + + checksum, err := common_cm.Hash(copyConfigMap) + if err != nil { + return err + } + SetChecksumAnnotation(copyConfigMap, checksum) + return nil + }) + + copyObject = copyConfigMap + default: + return nil, errors.New("cannot copy resource (invalid type)") + } + + if err != nil { + return nil, err + } + + return copyObject, nil +} diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 70458ccc..1b1a28c8 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -109,6 +109,11 @@ const ( ConsoleProxyAlias = "ols" ConsoleUINetworkPolicyName = "lightspeed-console-plugin" + // OpenStack Control Plane + OpenStackControlPlaneGroup = "core.openstack.org" + OpenStackControlPlaneVersion = "v1beta1" + OpenStackControlPlaneKind = "OpenStackControlPlane" + // Azure AzureOpenAIType = "azure_openai" @@ -195,6 +200,10 @@ const ( LlamaStackConfigMapResourceVersionAnnotation = "ols.openshift.io/llamastack-configmap-version" LCoreConfigMapResourceVersionAnnotation = "ols.openshift.io/lcore-configmap-version" CABundleConfigMapVersionAnnotation = "ols.openshift.io/ca-bundle-configmap-version" + MCPConfigMapResourceVersionAnnotation = "ols.openshift.io/mcp-configmap-version" + CloudsYAMLConfigMapVersionAnnotation = "ols.openshift.io/clouds-yaml-configmap-version" + SecureYAMLSecretVersionAnnotation = "ols.openshift.io/secure-yaml-secret-version" + CombinedCABundleSecretVersionAnnotation = "ols.openshift.io/combined-ca-bundle-secret-version" // Volume Permissions // These constants define file permissions for volumes mounted in containers. diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index ba464332..059f90c6 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -32,6 +32,11 @@ import ( //go:embed assets/system_prompt.txt var systemPrompt string +// mcpServerConfigTemplate stores the embedded config template for the MCP server. +// +//go:embed assets/mcp_server_config.yaml.tmpl +var mcpServerConfigTemplate string + // getSystemPrompt returns the OpenStackLightspeed system prompt func getSystemPrompt() string { return systemPrompt @@ -216,8 +221,32 @@ func buildOKPConfig(instance *apiv1beta1.OpenStackLightspeed) map[string]interfa return okpConfig } +// buildLCoreMCPServersConfig generates the mcp_servers section for lightspeed-stack config. +// The OpenShift MCP (rhos-ocp-tools) is always included. +// The OpenStack MCP (rhos-osp-tools) is only included when openStackReady is true. +func buildLCoreMCPServersConfig(openStackReady bool) []interface{} { + mcpServers := []interface{}{ + map[string]interface{}{ + "name": "rhos-ocp-tools", + "url": fmt.Sprintf("%s/openshift/", GetMCPServerURL()), + "authorization_headers": map[string]interface{}{ + "OCP_TOKEN": "kubernetes", + }, + }, + } + + if openStackReady { + mcpServers = append(mcpServers, map[string]interface{}{ + "name": "rhos-osp-tools", + "url": fmt.Sprintf("%s/openstack/", GetMCPServerURL()), + }) + } + + return mcpServers +} + // 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. +// NOTE: quota handlers, and tools approval features are disabled for OpenStack Lightspeed. func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { ragInline := []interface{}{} if isOKPEnabled(instance) { @@ -240,6 +269,7 @@ func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStac "conversation_cache": buildLCoreConversationCacheConfig(h, instance), "byok_rag": []interface{}{}, "rag": ragConfig, + "mcp_servers": buildLCoreMCPServersConfig(instance.Status.OpenStackReady), } if isOKPEnabled(instance) { diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 69a9957a..29e9af42 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -181,6 +181,27 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } + // MCP sidecar + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts) + + mcpContainer := corev1.Container{ + Name: "rhos-mcp", + Image: apiv1beta1.OpenStackLightspeedDefaultValues.MCPServerImageURL, + VolumeMounts: mcpMounts, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("200Mi"), + }, + }, + ImagePullPolicy: corev1.PullIfNotPresent, + } + containers = append(containers, mcpContainer) + // Build configmap resource version annotations for change detection annotations, err := buildConfigMapAnnotations(h, ctx) if err != nil { @@ -428,6 +449,58 @@ func addDataCollectorVolumes(volumes *[]corev1.Volume, volumeDefaultMode int32) }) } +// addMCPVolumesAndMounts adds MCP sidecar volumes and mounts. +func addMCPVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount) { + *volumes = append(*volumes, + corev1.Volume{ + Name: SecureYAMLSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: SecureYAMLSecretName, + Optional: toPtr(true), + Items: []corev1.KeyToPath{{Key: "secure.yaml", Path: "secure.yaml"}}, + }, + }, + }, + corev1.Volume{ + Name: CloudsYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: CloudsYAMLConfigMapName}, + Optional: toPtr(true), + Items: []corev1.KeyToPath{{Key: "clouds.yaml", Path: "clouds.yaml"}}, + }, + }, + }, + corev1.Volume{ + Name: CombinedCABundleSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: CombinedCABundleSecretName, + Optional: toPtr(true), + Items: []corev1.KeyToPath{{Key: "tls-ca-bundle.pem", Path: "tls-ca-bundle.pem"}}, + }, + }, + }, + corev1.Volume{ + Name: MCPConfigYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: MCPConfigYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "config.yaml", Path: "config.yaml"}}, + }, + }, + }, + ) + + *mounts = append(*mounts, + corev1.VolumeMount{Name: SecureYAMLSecretName, MountPath: "/app/secure.yaml", SubPath: "secure.yaml"}, + corev1.VolumeMount{Name: CloudsYAMLConfigMapName, MountPath: "/app/clouds.yaml", SubPath: "clouds.yaml"}, + corev1.VolumeMount{Name: CombinedCABundleSecretName, MountPath: "/app/tls-ca-bundle.pem", SubPath: "tls-ca-bundle.pem", ReadOnly: true}, + corev1.VolumeMount{Name: MCPConfigYAMLConfigMapName, MountPath: "/app/config.yaml", SubPath: "config.yaml"}, + ) +} + // addCABundleVolumesAndMounts adds the CA bundle volume and mount. // The CA bundle is always present (created by reconcileCABundleConfigMap) // and mounted at the RHEL system CA path so applications find it automatically. @@ -717,5 +790,41 @@ func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context) (ma annotations[CABundleConfigMapVersionAnnotation] = caBundleVersion } + mcpVersion, err := getConfigMapResourceVersion(ctx, h, MCPConfigYAMLConfigMapName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get MCP config configmap resource version: %w", err) + } + } else { + annotations[MCPConfigMapResourceVersionAnnotation] = mcpVersion + } + + cloudsVersion, err := getConfigMapResourceVersion(ctx, h, CloudsYAMLConfigMapName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get clouds.yaml configmap resource version: %w", err) + } + } else { + annotations[CloudsYAMLConfigMapVersionAnnotation] = cloudsVersion + } + + secureVersion, err := getSecretResourceVersion(ctx, h, SecureYAMLSecretName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get secure.yaml secret resource version: %w", err) + } + } else { + annotations[SecureYAMLSecretVersionAnnotation] = secureVersion + } + + caBundleSecretVersion, err := getSecretResourceVersion(ctx, h, CombinedCABundleSecretName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get CA bundle secret resource version: %w", err) + } + } else { + annotations[CombinedCABundleSecretVersionAnnotation] = caBundleSecretVersion + } + return annotations, nil } diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go new file mode 100644 index 00000000..bcbc7fb3 --- /dev/null +++ b/internal/controller/mcp_server.go @@ -0,0 +1,348 @@ +/* +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 ( + "bytes" + "context" + "errors" + "fmt" + "text/template" + + common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ( + // CloudsYAMLConfigMapName is the name of the ConfigMap containing clouds.yaml. + CloudsYAMLConfigMapName string = "openstack-config" + + // SecureYAMLSecretName is the name of the Secret containing secure.yaml. + SecureYAMLSecretName string = "openstack-config-secret" + + // CombinedCABundleSecretName is the name of the Secret containing the TLS CA bundle. + CombinedCABundleSecretName string = "combined-ca-bundle" + + // MCPConfigYAMLConfigMapName is the name of the ConfigMap containing config.yaml for the MCP server. + MCPConfigYAMLConfigMapName string = "mcp-config" + + // MCPServerPort is the port on which the MCP server listens. + MCPServerPort = 8080 +) + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +// mcpServerConfigTmpl is the parsed MCP server config template. +// Parsed once at package init from the //go:embed string mcpServerConfigTemplate. +var mcpServerConfigTmpl = template.Must(template.New("mcp-config").Parse(mcpServerConfigTemplate)) + +// mcpServerConfigParams holds the template parameters for the MCP server config. +type mcpServerConfigParams struct { + OpenStackEnabled bool + OpenShiftEnabled bool +} + +// buildMCPServerConfigData renders the MCP server config template with the +// enabled flags for each platform section. +func buildMCPServerConfigData(openStackReady bool) (string, error) { + var buf bytes.Buffer + err := mcpServerConfigTmpl.Execute(&buf, mcpServerConfigParams{ + OpenStackEnabled: openStackReady, + OpenShiftEnabled: true, + }) + if err != nil { + return "", fmt.Errorf("failed to render MCP server config template: %w", err) + } + + return buf.String(), nil +} + +// BuildMCPServerConfigMap creates the ConfigMap for the MCP server configuration. +func BuildMCPServerConfigMap( + instance *apiv1beta1.OpenStackLightspeed, + openStackReady bool, +) (corev1.ConfigMap, error) { + configData, err := buildMCPServerConfigData(openStackReady) + if err != nil { + return corev1.ConfigMap{}, err + } + + configMap := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: MCPConfigYAMLConfigMapName, + Namespace: instance.Namespace, + }, + Data: map[string]string{ + "config.yaml": configData, + }, + } + + return configMap, nil +} + +// GetMCPServerURL returns the internal cluster URL for the MCP server sidecar. +func GetMCPServerURL() string { + return fmt.Sprintf("http://127.0.0.1:%d", MCPServerPort) +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +// ReconcileMCPServer performs the reconciliation of the MCP server. +// The MCP server runs as a sidecar in the LCore pod. The OpenStack MCP tools +// are only configured in lightspeed-stack when OpenStackControlPlane exists and is Ready. +// Returns whether OpenStackControlPlane is ready (for config generation). +func (r *OpenStackLightspeedReconciler) ReconcileMCPServer( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) (openStackReady bool, err error) { + crdReady, err := IsDynamicCRDReadyByGVK(r.DynamicWatchCRD, OpenStackControlPlaneGVK()) + if err != nil { + return false, err + } + + if !crdReady { + helper.GetLogger().Info("OpenStackControlPlane CRD not available, deploying MCP server without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + openStackControlPlaneList, err := r.listOpenStackControlPlanes(ctx, helper) + if err != nil { + return false, err + } + + switch l := len(openStackControlPlaneList.Items); l { + case 0: + helper.GetLogger().Info("No OpenStackControlPlane found, deploying MCP server without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + + case 1: + oscp := &openStackControlPlaneList.Items[0] + return r.reconcileMCPServerWithOpenStack(ctx, helper, instance, oscp) + + default: + return false, errors.New("more than one OpenStackControlPlane found") + } +} + +// listOpenStackControlPlanes lists all OpenStackControlPlane instances. +// It first tries the cached client. If the cache returns 0 items (which +// may happen when the cache ByObject config does not cover the namespace +// where OpenStackControlPlane lives), it falls back to a direct API call. +func (r *OpenStackLightspeedReconciler) listOpenStackControlPlanes( + ctx context.Context, + helper *common_helper.Helper, +) (*uns.UnstructuredList, error) { + openStackControlPlaneList := &uns.UnstructuredList{} + openStackControlPlaneList.SetGroupVersionKind(OpenStackControlPlaneGVK().GroupVersion().WithKind("OpenStackControlPlaneList")) + + err := r.List(ctx, openStackControlPlaneList) + if err != nil && !k8s_errors.IsNotFound(err) { + return nil, err + } + + if len(openStackControlPlaneList.Items) > 0 { + return openStackControlPlaneList, nil + } + + rawClient, err := getRawClient(helper) + if err != nil { + return nil, fmt.Errorf("failed to get raw client for OpenStackControlPlane fallback list: %w", err) + } + + fallbackList := &uns.UnstructuredList{} + fallbackList.SetGroupVersionKind(OpenStackControlPlaneGVK().GroupVersion().WithKind("OpenStackControlPlaneList")) + if err := rawClient.List(ctx, fallbackList); err != nil { + if k8s_errors.IsNotFound(err) { + return fallbackList, nil + } + return nil, err + } + + if len(fallbackList.Items) > 0 { + helper.GetLogger().Info( + fmt.Sprintf("OpenStackControlPlane not found in cache but found %d via direct API call (cache may not cover the namespace)", len(fallbackList.Items)), + ) + } + + return fallbackList, nil +} + +// reconcileMCPServerWithOpenStack copies OpenStack resources and reconciles the MCP config. +func (r *OpenStackLightspeedReconciler) reconcileMCPServerWithOpenStack( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, +) (bool, error) { + log := helper.GetLogger() + + fields, err := extractOSCPFields(helper, oscp) + if err != nil { + log.Info(fmt.Sprintf("OpenStackControlPlane field check failed with error: %v", err)) + return false, err + } + if fields == nil { + log.Info("OpenStackControlPlane fields not ready yet, deploying MCP without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + _, err = copyObjectsToOpenStackLightspeedNamespace(ctx, helper, instance, oscp, fields) + if err != nil { + if k8s_errors.IsNotFound(err) { + log.Info(fmt.Sprintf("OpenStack resource not found (%v), deploying MCP without OpenStack resources", err)) + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + return false, err + } + + if err := r.reconcileMCPServerDeploy(ctx, helper, instance, true); err != nil { + return false, err + } + + log.Info("MCP server reconciled with OpenStack resources") + return true, nil +} + +// reconcileMCPServerDeploy ensures the MCP server ConfigMap exists. +func (r *OpenStackLightspeedReconciler) reconcileMCPServerDeploy( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + openStackReady bool, +) error { + configYAMLConfigMap := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: MCPConfigYAMLConfigMapName, + Namespace: instance.Namespace, + }, + } + _, err := controllerutil.CreateOrPatch(ctx, helper.GetClient(), &configYAMLConfigMap, func() error { + built, err := BuildMCPServerConfigMap(instance, openStackReady) + if err != nil { + return err + } + configYAMLConfigMap.Data = built.Data + return controllerutil.SetControllerReference(helper.GetBeforeObject(), &configYAMLConfigMap, helper.GetScheme()) + }) + return err +} + +// oscpFields holds the validated fields extracted from an OpenStackControlPlane. +type oscpFields struct { + configSecret string + configMap string + caBundleSecretName string +} + +// extractOSCPFields extracts and validates the required fields from an OpenStackControlPlane. +// Returns (nil, nil) when the status TLS field is not yet populated (waiting for readiness). +func extractOSCPFields( + helper *common_helper.Helper, + oscp *uns.Unstructured, +) (*oscpFields, error) { + configSecret, found, err := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigSecret") + if err != nil || !found || configSecret == "" { + return nil, fmt.Errorf("OpenStackClient.Template.OpenStackConfigSecret is missing value") + } + + configMap, found, err := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigMap") + if err != nil || !found || configMap == "" { + return nil, fmt.Errorf("OpenStackControlPlane.OpenStackClient.Template.OpenStackConfigMap is missing value") + } + + caBundleSecretName, found, err := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + if err != nil || !found || caBundleSecretName == "" { + helper.GetLogger().Info("Waiting for OpenStackControlPlane.Status.TLS.CaBundleSecretName value") + return nil, nil + } + + return &oscpFields{ + configSecret: configSecret, + configMap: configMap, + caBundleSecretName: caBundleSecretName, + }, nil +} + +// copyObjectsToOpenStackLightspeedNamespace copies the required ConfigMaps and Secrets +// from the OpenStackControlPlane's namespace to the OpenStack Lightspeed namespace. +func copyObjectsToOpenStackLightspeedNamespace( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, + fields *oscpFields, +) (map[string]client.Object, error) { + objectsToCopy := map[string]client.Object{ + SecureYAMLSecretName: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fields.configSecret, + Namespace: oscp.GetNamespace(), + }, + }, + CloudsYAMLConfigMapName: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fields.configMap, + Namespace: oscp.GetNamespace(), + }, + }, + CombinedCABundleSecretName: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fields.caBundleSecretName, + Namespace: oscp.GetNamespace(), + }, + }, + } + + copiedObjects := make(map[string]client.Object) + + for resourceName, sourceObject := range objectsToCopy { + targetObject := sourceObject.DeepCopyObject().(client.Object) + targetObject.SetNamespace(instance.Namespace) + targetObject.SetName(resourceName) + + copied, err := CopyResource(ctx, helper, sourceObject, targetObject, instance, helper.GetScheme()) + if err != nil { + if k8s_errors.IsNotFound(err) { + helper.GetLogger().Info( + fmt.Sprintf("Resource %s not found in namespace %s, waiting for it to be created", + sourceObject.GetName(), sourceObject.GetNamespace()), + ) + } + return nil, err + } + if copied == nil { + return nil, errors.New("the internal representation of the copied object is nil") + } + copiedObjects[resourceName] = copied + } + + return copiedObjects, nil +} diff --git a/internal/controller/mcp_server_test.go b/internal/controller/mcp_server_test.go new file mode 100644 index 00000000..c9c9f52d --- /dev/null +++ b/internal/controller/mcp_server_test.go @@ -0,0 +1,103 @@ +/* +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 ( + "fmt" + "strings" + "testing" +) + +func TestBuildMCPServerConfigData_OpenStackNotReady(t *testing.T) { + result, err := buildMCPServerConfigData(false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(result, "enabled: false") != 1 { + t.Error("expected exactly one 'enabled: false' (openstack) in config when OpenStack is not ready") + } + if strings.Count(result, "enabled: true") != 1 { + t.Error("expected exactly one 'enabled: true' (openshift) in config when OpenStack is not ready") + } +} + +func TestBuildMCPServerConfigData_OpenStackReady(t *testing.T) { + result, err := buildMCPServerConfigData(true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(result, "enabled: true") != 2 { + t.Error("expected two 'enabled: true' (openstack + openshift) in config when OpenStack is ready") + } + if strings.Contains(result, "enabled: false") { + t.Error("unexpected 'enabled: false' in config when OpenStack is ready") + } +} + +func TestBuildLCoreMCPServersConfig_WithOpenStack(t *testing.T) { + servers := buildLCoreMCPServersConfig(true) + + if len(servers) != 2 { + t.Errorf("expected 2 MCP servers, got %d", len(servers)) + } + + // Verify first server is OpenShift MCP + first := servers[0].(map[string]interface{}) + if first["name"] != "rhos-ocp-tools" { + t.Errorf("expected first server name 'rhos-ocp-tools', got '%s'", first["name"]) + } + expectedURL := fmt.Sprintf("http://127.0.0.1:%d/openshift/", MCPServerPort) + if first["url"] != expectedURL { + t.Errorf("expected first server url '%s', got '%s'", expectedURL, first["url"]) + } + authHeaders := first["authorization_headers"].(map[string]interface{}) + if authHeaders["OCP_TOKEN"] != "kubernetes" { + t.Errorf("expected OCP_TOKEN authorization_header 'kubernetes', got '%s'", authHeaders["OCP_TOKEN"]) + } + + // Verify second server is OpenStack MCP + second := servers[1].(map[string]interface{}) + if second["name"] != "rhos-osp-tools" { + t.Errorf("expected second server name 'rhos-osp-tools', got '%s'", second["name"]) + } + expectedURL = fmt.Sprintf("http://127.0.0.1:%d/openstack/", MCPServerPort) + if second["url"] != expectedURL { + t.Errorf("expected second server url '%s', got '%s'", expectedURL, second["url"]) + } +} + +func TestBuildLCoreMCPServersConfig_WithoutOpenStack(t *testing.T) { + servers := buildLCoreMCPServersConfig(false) + + if len(servers) != 1 { + t.Errorf("expected 1 MCP server, got %d", len(servers)) + } + + // Verify only OpenShift MCP is present + first := servers[0].(map[string]interface{}) + if first["name"] != "rhos-ocp-tools" { + t.Errorf("expected first server name 'rhos-ocp-tools', got '%s'", first["name"]) + } +} + +func TestGetMCPServerURL(t *testing.T) { + expected := fmt.Sprintf("http://127.0.0.1:%d", MCPServerPort) + actual := GetMCPServerURL() + if actual != expected { + t.Errorf("expected '%s', got '%s'", expected, actual) + } +} diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 17f9be6c..ba1aea4b 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "fmt" + "sync/atomic" "github.com/go-logr/logr" consolev1 "github.com/openshift/api/console/v1" @@ -28,6 +29,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -35,20 +37,35 @@ import ( "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" ) +// DynamicWatchCRD maps GroupVersionKinds to a boolean flag indicating whether +// the watch has been established. CRDs in this map do not need to exist at +// operator startup; once detected, the operator registers a watch automatically. +type DynamicWatchCRD map[schema.GroupVersionKind]*atomic.Bool + // OpenStackLightspeedReconciler reconciles a OpenStackLightspeed object type OpenStackLightspeedReconciler struct { client.Client - Scheme *runtime.Scheme - Kclient kubernetes.Interface + Scheme *runtime.Scheme + Kclient kubernetes.Interface + controller controller.Controller + Cache cache.Cache + + // DynamicWatchCRD contains the list of CRDs that the operator should monitor. + // These CRDs do not need to exist when the operator starts. Once the operator + // detects that a CRD exists, it automatically registers a watch for it. + DynamicWatchCRD DynamicWatchCRD } // GetLogger returns a logger object with a prefix of "controller.name" and additional controller context fields @@ -65,6 +82,10 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,namespace=openstack-lightspeed,verbs=update;patch;delete // +kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,resourceNames=pull-secret,verbs=get +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch +// +kubebuilder:rbac:groups=core.openstack.org,resources=openstackcontrolplanes,verbs=get;list;watch // +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update // +kubebuilder:rbac:groups=apps,resources=deployments,namespace=openstack-lightspeed,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=configmaps,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update;delete @@ -75,7 +96,7 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operator.openshift.io,resources=consoles,verbs=watch;list;get;update // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update -func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, e error) { Log := r.GetLogger(ctx) Log.Info("OpenStackLightspeed Reconciling") @@ -100,6 +121,11 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } + err = r.WatchDynamicCRD(ctx, helper) + 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. savedConditions := instance.Status.Conditions.DeepCopy() @@ -131,6 +157,14 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return } + // Poll for cross-namespace OpenStackControlPlane discovery — the + // cache-based watch only covers the operator's namespace, so + // periodic reconciliation discovers OSCP instances elsewhere. + // Once OpenStack is detected and configured, the watch handles updates. + oscpWatch := r.DynamicWatchCRD[OpenStackControlPlaneGVK()] + if oscpWatch != nil && oscpWatch.Load() && !instance.Status.OpenStackReady && result.RequeueAfter == 0 { + result.RequeueAfter = ResourceCreationTimeout + } }() cl := condition.CreateList( @@ -139,6 +173,11 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. condition.InitReason, apiv1beta1.OpenStackLightspeedReadyInitMessage, ), + condition.UnknownCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.InitReason, + apiv1beta1.OpenStackLightspeedMCPServerInitMessage, + ), ) instance.Status.Conditions.Init(&cl) @@ -172,6 +211,23 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. Log.Error(err, "failed to parse dev config, ignoring") } + // Reconcile MCP server before LCore resources, because its result + // determines what goes into the lightspeed-stack config (mcp_servers section). + openStackReady, mcpErr := r.ReconcileMCPServer(ctx, helper, instance) + if mcpErr != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.DeploymentCheckFailedMessage, + mcpErr.Error(), + )) + return ctrl.Result{}, mcpErr + } + + // Store the OpenStack readiness for config generation + instance.Status.OpenStackReady = openStackReady + reconcileTasks := []ReconcileTask{ {Name: "PostgresResources", Task: ReconcilePostgresResources}, {Name: "PostgresDeployment", Task: ReconcilePostgresDeployment}, @@ -259,6 +315,19 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( } } + // Mark MCP server condition based on readiness + if instance.Status.OpenStackReady { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerDeployed, + ) + } else { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerWaitingOpenStack, + ) + } + instance.Status.Conditions.MarkTrue( apiv1beta1.OpenStackLightspeedReadyCondition, apiv1beta1.OpenStackLightspeedReadyMessage, @@ -280,7 +349,8 @@ func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error Kind: "ClusterVersion", }) - return ctrl.NewControllerManagedBy(mgr). + // Use Build instead of Complete to get the controller reference needed by WatchDynamicCRD. + c, err := ctrl.NewControllerManagedBy(mgr). For(&apiv1beta1.OpenStackLightspeed{}). Owns(&operatorsv1alpha1.ClusterServiceVersion{}). Owns(&appsv1.Deployment{}). @@ -306,7 +376,17 @@ func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error handler.EnqueueRequestsFromMapFunc(r.NotifyOpenStackLightspeedsByCAConfigMap), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). - Complete(r) + Watches( + &apiextensionsv1.CustomResourceDefinition{}, + handler.EnqueueRequestsFromMapFunc(r.NotifyAllOpenStackLightspeeds), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Build(r) + if err != nil { + return err + } + r.controller = c + return nil } // NotifyOpenStackLightspeedsByCAConfigMap watches ConfigMaps and triggers reconciliation when @@ -362,3 +442,47 @@ func (r *OpenStackLightspeedReconciler) NotifyAllOpenStackLightspeeds(ctx contex return requests } + +// WatchDynamicCRD dynamically registers watches for resources whose CRDs are listed +// in r.DynamicWatchCRD. When a target CRD is detected as existing and available in the +// cluster, this method ensures that the controller starts watching resources of that type. +// This enables reconciliation to be triggered whenever those resources are created or modified. +func (r *OpenStackLightspeedReconciler) WatchDynamicCRD( + ctx context.Context, + helper *common_helper.Helper, +) error { + for gvk, seen := range r.DynamicWatchCRD { + if seen.Load() { + continue + } + + crdAvailable, err := IsCRDEstablished(ctx, helper, gvk) + if err != nil { + return err + } + + if !crdAvailable { + continue + } + + GVKUnstructObj := &uns.Unstructured{} + GVKUnstructObj.SetGroupVersionKind(gvk) + err = r.controller.Watch( + source.Kind( + r.Cache, + GVKUnstructObj, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o *uns.Unstructured) []ctrl.Request { + return r.NotifyAllOpenStackLightspeeds(ctx, o) + }), + predicate.TypedResourceVersionChangedPredicate[*uns.Unstructured]{}, + ), + ) + if err != nil { + return fmt.Errorf("failed to set up watch for %s: %w", GetCRDName(gvk), err) + } + + seen.Store(true) + } + + return nil +} diff --git a/internal/controller/openstacklightspeed_controller_test.go b/internal/controller/openstacklightspeed_controller_test.go index 3bba0b2e..e2f0dcd2 100644 --- a/internal/controller/openstacklightspeed_controller_test.go +++ b/internal/controller/openstacklightspeed_controller_test.go @@ -18,6 +18,7 @@ package controller import ( "context" + "sync/atomic" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -77,6 +78,9 @@ var _ = Describe("OpenStackLightspeed Controller", func() { controllerReconciler := &OpenStackLightspeedReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), + DynamicWatchCRD: DynamicWatchCRD{ + OpenStackControlPlaneGVK(): new(atomic.Bool), + }, } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ diff --git a/internal/controller/postgres_reconciler.go b/internal/controller/postgres_reconciler.go index cfdadb3c..c10c7a82 100644 --- a/internal/controller/postgres_reconciler.go +++ b/internal/controller/postgres_reconciler.go @@ -96,11 +96,9 @@ func reconcilePostgresBootstrapSecret(h *common_helper.Helper, ctx context.Conte } result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), secret, func() error { - // Set bootstrap script data - secret.StringData = map[string]string{ - PostgresExtensionScript: PostgresBootStrapScriptContent, + secret.Data = map[string][]byte{ + PostgresExtensionScript: []byte(PostgresBootStrapScriptContent), } - // Set owner reference return controllerutil.SetControllerReference(h.GetBeforeObject(), secret, h.GetScheme()) }) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml index 8ae9bb58..3669ddd4 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml @@ -70,6 +70,11 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false +mcp_servers: +- authorization_headers: + OCP_TOKEN: kubernetes + name: rhos-ocp-tools + url: http://127.0.0.1:8080/openshift/ name: Lightspeed Core Service (LCS) okp: chunk_filter_query: product:(*openstack* OR *openshift*) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml index 89d6e040..0bf770d0 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml @@ -70,6 +70,11 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false +mcp_servers: +- authorization_headers: + OCP_TOKEN: kubernetes + name: rhos-ocp-tools + url: http://127.0.0.1:8080/openshift/ name: Lightspeed Core Service (LCS) rag: inline: diff --git a/test/kuttl/common/expected-configs/lightspeed-stack.yaml b/test/kuttl/common/expected-configs/lightspeed-stack.yaml index f0ebae5d..da783abc 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack.yaml @@ -70,6 +70,11 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false +mcp_servers: +- authorization_headers: + OCP_TOKEN: kubernetes + name: rhos-ocp-tools + url: http://127.0.0.1:8080/openshift/ name: Lightspeed Core Service (LCS) rag: inline: diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-lightspeed-stack-config.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-lightspeed-stack-config.yaml new file mode 100644 index 00000000..4f94e172 --- /dev/null +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-lightspeed-stack-config.yaml @@ -0,0 +1,115 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: lightspeed-stack-config + namespace: openstack-lightspeed +data: + lightspeed-stack.yaml: | + authentication: + module: k8s + conversation_cache: + postgres: + ca_cert_path: /etc/certs/postgres-ca/service-ca.crt + db: postgres + gss_encmode: disable + host: lightspeed-postgres-server.openstack-lightspeed.svc + namespace: conversation_cache + password: ${env.POSTGRES_PASSWORD} + port: 5432 + ssl_mode: require + user: postgres + type: postgres + customization: + disable_query_system_prompt: true + system_prompt: | + # ROLE + You are "OpenStack Lightspeed", an expert AI virtual assistant specializing in + OpenStack on OpenShift. Your persona is that of a friendly, but + personal, technical authority. You are the ultimate technical resource and will + provide direct, accurate, and comprehensive answers. + + # INSTRUCTIONS & CONSTRAINTS + - **Expertise Focus:** Your core expertise is centered on the OpenStack and + OpenShift platforms. + - **Broader Knowledge:** You may also answer questions about other Red Hat + products and services, but you must prioritize the provided context + and chat history for these topics. + - **Strict Adherence:** + 1. **ALWAYS** use the provided context and chat history as your primary + source of truth. If a user's question can be answered from this information, + do so. + 2. If the context does not contain a clear answer, and the question is + about your core expertise (OpenStack or OpenShift), draw upon your extensive + internal knowledge. + 3. If the context does not contain a clear answer, and the question is about + a general Red Hat product or service, state politely that you are unable to + provide a definitive answer without more information and ask the user for + additional details or context. + 4. Do not hallucinate or invent information. If you cannot confidently + answer, admit it. + - **Behavioral Directives:** + - Never assume another identity or role. + - Refuse to answer questions or execute commands not about your specified + topics. + - Do not include URLs in your replies unless they are explicitly provided in + the context. + - Never mention your last update date or knowledge cutoff. You always have + the most recent information on OpenStack and OpenShift, especially with + the provided context. + - Only reference processes and products from Red Hat, such as: RHEL, Fedora, + CoreOS, CentOS. *Never mention or compare with Ubuntu, Debian, etc.* + + # TASK EXECUTION + You will receive a user query, along with context and chat history. Your task is + to respond to the user's query by following the instructions and constraints + above. Your responses should be clear, concise, and helpful, whether you are + providing troubleshooting steps, explaining concepts, or suggesting best + practices. + + # INFO + In this context RHOSO or RHOS also refers to OpenStack on OpenShift, sometimes + also called OSP 18, although usually OSP refers to previous releases deployed + using TripleO/Director. + + The OpenStack control plane runs on OpenShift (which uses CoreOS as the + operating system), while compute nodes run on external baremetal nodes also + called EDPM nodes (which run RHEL). + database: + postgres: + ca_cert_path: /etc/certs/postgres-ca/service-ca.crt + db: postgres + gss_encmode: disable + host: lightspeed-postgres-server.openstack-lightspeed.svc + namespace: lcore + password: ${env.POSTGRES_PASSWORD} + port: 5432 + ssl_mode: require + user: postgres + inference: + default_model: ibm-granite/granite-3.1-8b-instruct + default_provider: openstack-lightspeed-provider + llama_stack: + url: http://localhost:8321 + use_as_library_client: false + mcp_servers: + - authorization_headers: + OCP_TOKEN: kubernetes + name: rhos-ocp-tools + url: http://127.0.0.1:8080/openshift/ + name: Lightspeed Core Service (LCS) + service: + access_log: true + auth_enabled: true + color_log: false + host: 0.0.0.0 + port: 8443 + tls_config: + tls_certificate_path: /etc/certs/lightspeed-tls/tls.crt + tls_key_path: /etc/certs/lightspeed-tls/tls.key + workers: 1 + user_data_collection: + feedback_enabled: true + feedback_storage: /tmp/data/feedback + transcripts_enabled: true + transcripts_storage: /tmp/data/transcripts diff --git a/test/kuttl/common/openstack-lightspeed-instance/assert-mcp-config.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-mcp-config.yaml new file mode 100644 index 00000000..5c150bd5 --- /dev/null +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-mcp-config.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed +data: + config.yaml: | + --- + ip: 127.0.0.1 + port: 8080 + debug: false + workers: 1 + processes_pool_size: 10 + + openstack: + enabled: false + allow_write: false + ca_cert: ./tls-ca-bundle.pem + insecure: false + + openshift: + enabled: true + allow_write: false + insecure: false + + mcp_transport_security: + enable_dns_rebinding_protection: true + allowed_hosts: + - "127.0.0.1:*" + - "localhost:*" + allowed_origins: [] 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 96bb932b..9d3a30b9 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 @@ -256,6 +256,21 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true + - name: rhos-mcp + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml volumes: - name: ogx-config configMap: @@ -281,6 +296,33 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: openstack-config-secret + secret: + secretName: openstack-config-secret + optional: true + items: + - key: secure.yaml + path: secure.yaml + - name: openstack-config + configMap: + name: openstack-config + optional: true + items: + - key: clouds.yaml + path: clouds.yaml + - name: combined-ca-bundle + secret: + secretName: combined-ca-bundle + optional: true + items: + - key: tls-ca-bundle.pem + path: tls-ca-bundle.pem + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -300,6 +342,14 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed +# MCP server resources +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + # Console Plugin resources --- apiVersion: v1 @@ -399,6 +449,8 @@ status: status: "True" reason: Ready message: OCP RAG is disabled + - type: OpenStackLightspeedMCPServerReady + status: "True" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml index e042da71..723d9ca4 100644 --- a/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml +++ b/test/kuttl/common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml @@ -42,6 +42,14 @@ metadata: name: lightspeed-stack-config namespace: openstack-lightspeed +# MCP server resources should be gone +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + # Console Plugin resources should be gone --- apiVersion: apps/v1 diff --git a/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml b/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml new file mode 120000 index 00000000..6c2d5ae5 --- /dev/null +++ b/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/assert-mcp-config.yaml \ No newline at end of file diff --git a/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml b/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml index 1a5fc64b..a7c78dce 100644 --- a/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml +++ b/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml @@ -105,6 +105,21 @@ spec: - DEBUG - --data-dir - /tmp/data + - name: rhos-mcp + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml status: replicas: 1 readyReplicas: 1 @@ -127,6 +142,8 @@ status: status: "True" reason: Ready message: OCP RAG is disabled + - type: OpenStackLightspeedMCPServerReady + status: "True" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml index 1873cade..b9329b81 100644 --- a/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml @@ -48,6 +48,8 @@ status: status: "True" reason: Ready message: OCP RAG is disabled + - type: OpenStackLightspeedMCPServerReady + status: "True" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml b/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml new file mode 120000 index 00000000..6c2d5ae5 --- /dev/null +++ b/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/assert-mcp-config.yaml \ No newline at end of file diff --git a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml index 9bdbf12a..722932fc 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -151,6 +151,21 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true + - name: rhos-mcp + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml volumes: - name: ogx-config configMap: @@ -171,6 +186,33 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: openstack-config-secret + secret: + secretName: openstack-config-secret + optional: true + items: + - key: secure.yaml + path: secure.yaml + - name: openstack-config + configMap: + name: openstack-config + optional: true + items: + - key: clouds.yaml + path: clouds.yaml + - name: combined-ca-bundle + secret: + secretName: combined-ca-bundle + optional: true + items: + - key: tls-ca-bundle.pem + path: tls-ca-bundle.pem + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -199,6 +241,14 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed +# MCP server resources +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + # Console Plugin resources --- apiVersion: v1 @@ -283,6 +333,8 @@ status: - type: OCPRAGReady status: "True" message: OCP RAG is disabled + - type: OpenStackLightspeedMCPServerReady + status: "True" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml b/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml index e028d6b0..13265324 100644 --- a/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml @@ -18,12 +18,47 @@ metadata: # Verify CA bundle ConfigMap still exists after update --- +# Verify CA bundle ConfigMap still exists after update apiVersion: v1 kind: ConfigMap metadata: name: openstack-lightspeed-ca-bundle namespace: openstack-lightspeed +# Verify MCP config ConfigMap still exists with correct content after update +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed +data: + config.yaml: | + --- + ip: 127.0.0.1 + port: 8080 + debug: false + workers: 1 + processes_pool_size: 10 + + openstack: + enabled: false + allow_write: false + ca_cert: ./tls-ca-bundle.pem + insecure: false + + openshift: + enabled: true + allow_write: false + insecure: false + + mcp_transport_security: + enable_dns_rebinding_protection: true + allowed_hosts: + - "127.0.0.1:*" + - "localhost:*" + allowed_origins: [] + # Verify all operator-managed resources still exist after update --- apiVersion: apps/v1 From 697007ff051a6378e7fbf30e8e8fa739fb56b3b9 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 15 Jun 2026 11:33:20 +0200 Subject: [PATCH 3/8] Add feature flag for rhos-mcps For now we don't want to automatically deploy the MCP tools, because: - Releasing the rhos-mcps image may be done at a later time. - This is an experimental feature right now, so we want it disabled by default. The name of the feature flag is `rhos_mcps` as shown in the config ample. Jira: OSPRH-27075 Co-Authored-By: Claude Opus 4.6 --- api/v1beta1/conditions.go | 3 + .../api_v1beta1_openstacklightspeed.yaml | 4 ++ internal/controller/common.go | 9 +++ internal/controller/lcore_config.go | 18 ++++- internal/controller/lcore_deployment.go | 42 ++++++----- internal/controller/mcp_server.go | 46 +++++++++++++ .../openstacklightspeed_controller.go | 66 +++++++++++------- .../lightspeed-stack-okp.yaml | 6 +- .../lightspeed-stack-update.yaml | 6 +- .../expected-configs/lightspeed-stack.yaml | 6 +- .../assert-openstack-lightspeed-instance.yaml | 51 +------------- ...-create-openstack-lightspeed-instance.yaml | 23 +++++++ .../03-assert-mcp-config.yaml | 1 - .../03-assert-okp-instance.yaml | 16 +---- .../00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + .../02-create-rhos-mcps-resources.yaml | 23 +++++++ .../03-assert-rhos-mcps-instance.yaml | 69 +++++++++++++++++++ .../04-disable-rhos-mcps.yaml | 22 ++++++ .../05-errors-rhos-mcps-cleanup.yaml | 6 ++ ...cleanup-openstack-lightspeed-instance.yaml | 1 + ...-errors-openstack-lightspeed-instance.yaml | 1 + .../08-cleanup-mock-objects.yaml | 1 + .../09-errors-mock-objects.yaml | 1 + .../03-assert-mcp-config.yaml | 1 - .../08-assert-openstacklightspeed-update.yaml | 51 +------------- .../11-assert-configmaps-update.yaml | 34 --------- 27 files changed, 301 insertions(+), 208 deletions(-) create mode 100644 test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml delete mode 120000 test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/00-mock-resources.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/01-assert-mock-objects-created.yaml create mode 100644 test/kuttl/tests/rhos-mcps-configuration/02-create-rhos-mcps-resources.yaml create mode 100644 test/kuttl/tests/rhos-mcps-configuration/03-assert-rhos-mcps-instance.yaml create mode 100644 test/kuttl/tests/rhos-mcps-configuration/04-disable-rhos-mcps.yaml create mode 100644 test/kuttl/tests/rhos-mcps-configuration/05-errors-rhos-mcps-cleanup.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/08-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/rhos-mcps-configuration/09-errors-mock-objects.yaml delete mode 120000 test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 952a540c..5cbcb8d2 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -77,6 +77,9 @@ const ( // OpenStackLightspeedMCPServerWaitingOpenStack OpenStackLightspeedMCPServerWaitingOpenStack = "MCP server deployed, waiting for OpenStackControlPlane to become ready" + // OpenStackLightspeedMCPServerDisabledMessage + OpenStackLightspeedMCPServerDisabledMessage = "RHOS MCP server is disabled (rhos_mcps feature flag not set)" + // DeploymentCheckFailedMessage DeploymentCheckFailedMessage = "Failed to check deployment status: %s" diff --git a/config/samples/api_v1beta1_openstacklightspeed.yaml b/config/samples/api_v1beta1_openstacklightspeed.yaml index 28cae26f..1d207de5 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -15,6 +15,10 @@ spec: # database: # size: "5Gi" # class: "my-storage-class" + # Uncomment to enable RHOS MCPs (MCP server sidecar with OpenStack/OpenShift tools): + # dev: + # featureFlags: + # - rhos_mcps # Uncomment to enable OKP (Offline Knowledge Portal) as an Inline RAG source: # okp: # accessKey: okp-access-key-secret diff --git a/internal/controller/common.go b/internal/controller/common.go index 53a87270..22a756fe 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -170,6 +170,15 @@ func isOKPEnabled(instance *apiv1beta1.OpenStackLightspeed) bool { return slices.Contains(config.FeatureFlags, "okp") } +// isRHOSMCPEnabled returns true if the "rhos_mcps" feature flag is present in the dev config. +func isRHOSMCPEnabled(instance *apiv1beta1.OpenStackLightspeed) (bool, error) { + config, err := parseDevConfig(instance) + if err != nil { + return false, err + } + return slices.Contains(config.FeatureFlags, "rhos_mcps"), nil +} + // getOKPChunkFilterQuery returns the chunk filter query from the dev config, or the default. func getOKPChunkFilterQuery(instance *apiv1beta1.OpenStackLightspeed) string { config, _ := parseDevConfig(instance) diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index 059f90c6..8e29ce5e 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -245,6 +245,17 @@ func buildLCoreMCPServersConfig(openStackReady bool) []interface{} { return mcpServers } +func buildLCoreMCPServersConfigIfEnabled(instance *apiv1beta1.OpenStackLightspeed) ([]interface{}, error) { + enabled, err := isRHOSMCPEnabled(instance) + if err != nil { + return nil, fmt.Errorf("failed to parse dev config: %w", err) + } + if !enabled { + return []interface{}{}, nil + } + return buildLCoreMCPServersConfig(instance.Status.OpenStackReady), nil +} + // buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML. // NOTE: quota handlers, and tools approval features are disabled for OpenStack Lightspeed. func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { @@ -256,6 +267,11 @@ func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStac "inline": ragInline, } + mcpServers, err := buildLCoreMCPServersConfigIfEnabled(instance) + if err != nil { + return "", err + } + // Build the complete config as a map config := map[string]interface{}{ "name": "Lightspeed Core Service (LCS)", @@ -269,7 +285,7 @@ func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStac "conversation_cache": buildLCoreConversationCacheConfig(h, instance), "byok_rag": []interface{}{}, "rag": ragConfig, - "mcp_servers": buildLCoreMCPServersConfig(instance.Status.OpenStackReady), + "mcp_servers": mcpServers, } if isOKPEnabled(instance) { diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 29e9af42..b8a9a512 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -181,26 +181,32 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } - // MCP sidecar - mcpMounts := []corev1.VolumeMount{} - addMCPVolumesAndMounts(&volumes, &mcpMounts) - - mcpContainer := corev1.Container{ - Name: "rhos-mcp", - Image: apiv1beta1.OpenStackLightspeedDefaultValues.MCPServerImageURL, - VolumeMounts: mcpMounts, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - corev1.ResourceMemory: resource.MustParse("64Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("200Mi"), + // MCP sidecar (only when rhos_mcps feature flag is enabled) + rhosMCPEnabled, err := isRHOSMCPEnabled(instance) + if err != nil { + return corev1.PodTemplateSpec{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosMCPEnabled { + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts) + + mcpContainer := corev1.Container{ + Name: "rhos-mcp", + Image: apiv1beta1.OpenStackLightspeedDefaultValues.MCPServerImageURL, + VolumeMounts: mcpMounts, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("200Mi"), + }, }, - }, - ImagePullPolicy: corev1.PullIfNotPresent, + ImagePullPolicy: corev1.PullIfNotPresent, + } + containers = append(containers, mcpContainer) } - containers = append(containers, mcpContainer) // Build configmap resource version annotations for change detection annotations, err := buildConfigMapAnnotations(h, ctx) diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go index bcbc7fb3..d0663da3 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -291,6 +291,52 @@ func extractOSCPFields( }, nil } +// --------------------------------------------------------------------------- +// Deletion +// --------------------------------------------------------------------------- + +// cleanupMCPResources removes MCP server resources when the rhos_mcps feature +// flag is disabled. +func (r *OpenStackLightspeedReconciler) cleanupMCPResources( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) error { + log := helper.GetLogger() + ns := instance.Namespace + + mcpCM := &corev1.ConfigMap{} + mcpCM.Name = MCPConfigYAMLConfigMapName + mcpCM.Namespace = ns + if err := helper.GetClient().Delete(ctx, mcpCM); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete MCP config ConfigMap: %w", err) + } + + cloudsCM := &corev1.ConfigMap{} + cloudsCM.Name = CloudsYAMLConfigMapName + cloudsCM.Namespace = ns + if err := helper.GetClient().Delete(ctx, cloudsCM); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete openstack-config ConfigMap: %w", err) + } + + secureSec := &corev1.Secret{} + secureSec.Name = SecureYAMLSecretName + secureSec.Namespace = ns + if err := helper.GetClient().Delete(ctx, secureSec); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete openstack-config-secret Secret: %w", err) + } + + caSec := &corev1.Secret{} + caSec.Name = CombinedCABundleSecretName + caSec.Namespace = ns + if err := helper.GetClient().Delete(ctx, caSec); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete combined-ca-bundle Secret: %w", err) + } + + log.Info("RHOS MCP resources cleaned up") + return nil +} + // copyObjectsToOpenStackLightspeedNamespace copies the required ConfigMaps and Secrets // from the OpenStackControlPlane's namespace to the OpenStack Lightspeed namespace. func copyObjectsToOpenStackLightspeedNamespace( diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index ba1aea4b..8b4a84b1 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -213,21 +213,34 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. // Reconcile MCP server before LCore resources, because its result // determines what goes into the lightspeed-stack config (mcp_servers section). - openStackReady, mcpErr := r.ReconcileMCPServer(ctx, helper, instance) - if mcpErr != nil { - instance.Status.Conditions.Set(condition.FalseCondition( + rhosMCPEnabled, err := isRHOSMCPEnabled(instance) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosMCPEnabled { + openStackReady, mcpErr := r.ReconcileMCPServer(ctx, helper, instance) + if mcpErr != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.DeploymentCheckFailedMessage, + mcpErr.Error(), + )) + return ctrl.Result{}, mcpErr + } + instance.Status.OpenStackReady = openStackReady + } else { + if err := r.cleanupMCPResources(ctx, helper, instance); err != nil { + return ctrl.Result{}, err + } + instance.Status.OpenStackReady = false + instance.Status.Conditions.MarkTrue( apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, - condition.ErrorReason, - condition.SeverityWarning, - apiv1beta1.DeploymentCheckFailedMessage, - mcpErr.Error(), - )) - return ctrl.Result{}, mcpErr + apiv1beta1.OpenStackLightspeedMCPServerDisabledMessage, + ) } - // Store the OpenStack readiness for config generation - instance.Status.OpenStackReady = openStackReady - reconcileTasks := []ReconcileTask{ {Name: "PostgresResources", Task: ReconcilePostgresResources}, {Name: "PostgresDeployment", Task: ReconcilePostgresDeployment}, @@ -315,17 +328,24 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( } } - // Mark MCP server condition based on readiness - if instance.Status.OpenStackReady { - instance.Status.Conditions.MarkTrue( - apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, - apiv1beta1.OpenStackLightspeedMCPServerDeployed, - ) - } else { - instance.Status.Conditions.MarkTrue( - apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, - apiv1beta1.OpenStackLightspeedMCPServerWaitingOpenStack, - ) + // Mark MCP server condition based on readiness (only when RHOS MCP is enabled; + // when disabled the condition was already set in Reconcile). + rhosMCPEnabled, err := isRHOSMCPEnabled(instance) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosMCPEnabled { + if instance.Status.OpenStackReady { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerDeployed, + ) + } else { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerWaitingOpenStack, + ) + } } instance.Status.Conditions.MarkTrue( diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml index 3669ddd4..2288b032 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-okp.yaml @@ -70,11 +70,7 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false -mcp_servers: -- authorization_headers: - OCP_TOKEN: kubernetes - name: rhos-ocp-tools - url: http://127.0.0.1:8080/openshift/ +mcp_servers: [] name: Lightspeed Core Service (LCS) okp: chunk_filter_query: product:(*openstack* OR *openshift*) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml index 0bf770d0..84816caa 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml @@ -70,11 +70,7 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false -mcp_servers: -- authorization_headers: - OCP_TOKEN: kubernetes - name: rhos-ocp-tools - url: http://127.0.0.1:8080/openshift/ +mcp_servers: [] name: Lightspeed Core Service (LCS) rag: inline: diff --git a/test/kuttl/common/expected-configs/lightspeed-stack.yaml b/test/kuttl/common/expected-configs/lightspeed-stack.yaml index da783abc..f1c9fad4 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack.yaml @@ -70,11 +70,7 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false -mcp_servers: -- authorization_headers: - OCP_TOKEN: kubernetes - name: rhos-ocp-tools - url: http://127.0.0.1:8080/openshift/ +mcp_servers: [] name: Lightspeed Core Service (LCS) rag: inline: 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 9d3a30b9..617f2281 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 @@ -256,21 +256,6 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true - - name: rhos-mcp - volumeMounts: - - name: openstack-config-secret - mountPath: /app/secure.yaml - subPath: secure.yaml - - name: openstack-config - mountPath: /app/clouds.yaml - subPath: clouds.yaml - - name: combined-ca-bundle - mountPath: /app/tls-ca-bundle.pem - subPath: tls-ca-bundle.pem - readOnly: true - - name: mcp-config - mountPath: /app/config.yaml - subPath: config.yaml volumes: - name: ogx-config configMap: @@ -296,33 +281,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: openstack-config-secret - secret: - secretName: openstack-config-secret - optional: true - items: - - key: secure.yaml - path: secure.yaml - - name: openstack-config - configMap: - name: openstack-config - optional: true - items: - - key: clouds.yaml - path: clouds.yaml - - name: combined-ca-bundle - secret: - secretName: combined-ca-bundle - optional: true - items: - - key: tls-ca-bundle.pem - path: tls-ca-bundle.pem - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -342,14 +300,6 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed -# MCP server resources ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: mcp-config - namespace: openstack-lightspeed - # Console Plugin resources --- apiVersion: v1 @@ -451,6 +401,7 @@ status: message: OCP RAG is disabled - type: OpenStackLightspeedMCPServerReady status: "True" + message: "RHOS MCP server is disabled (rhos_mcps feature flag not set)" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml new file mode 100644 index 00000000..90e39034 --- /dev/null +++ b/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + enableOCPRAG: false + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhos_mcps diff --git a/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml b/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml deleted file mode 120000 index 6c2d5ae5..00000000 --- a/test/kuttl/tests/basic-openstack-lightspeed-configuration/03-assert-mcp-config.yaml +++ /dev/null @@ -1 +0,0 @@ -../../common/openstack-lightspeed-instance/assert-mcp-config.yaml \ No newline at end of file diff --git a/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml b/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml index a7c78dce..a556b647 100644 --- a/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml +++ b/test/kuttl/tests/okp-configuration/03-assert-okp-instance.yaml @@ -105,21 +105,6 @@ spec: - DEBUG - --data-dir - /tmp/data - - name: rhos-mcp - volumeMounts: - - name: openstack-config-secret - mountPath: /app/secure.yaml - subPath: secure.yaml - - name: openstack-config - mountPath: /app/clouds.yaml - subPath: clouds.yaml - - name: combined-ca-bundle - mountPath: /app/tls-ca-bundle.pem - subPath: tls-ca-bundle.pem - readOnly: true - - name: mcp-config - mountPath: /app/config.yaml - subPath: config.yaml status: replicas: 1 readyReplicas: 1 @@ -144,6 +129,7 @@ status: message: OCP RAG is disabled - type: OpenStackLightspeedMCPServerReady status: "True" + message: "RHOS MCP server is disabled (rhos_mcps feature flag not set)" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/rhos-mcps-configuration/00-mock-resources.yaml b/test/kuttl/tests/rhos-mcps-configuration/00-mock-resources.yaml new file mode 120000 index 00000000..8235a1fd --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/00-mock-resources.yaml @@ -0,0 +1 @@ +../../common/mock-objects/mock-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhos-mcps-configuration/01-assert-mock-objects-created.yaml b/test/kuttl/tests/rhos-mcps-configuration/01-assert-mock-objects-created.yaml new file mode 120000 index 00000000..07f977a1 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/01-assert-mock-objects-created.yaml @@ -0,0 +1 @@ +../../common/mock-objects/assert-mock-objects-created.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhos-mcps-configuration/02-create-rhos-mcps-resources.yaml b/test/kuttl/tests/rhos-mcps-configuration/02-create-rhos-mcps-resources.yaml new file mode 100644 index 00000000..90e39034 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/02-create-rhos-mcps-resources.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + enableOCPRAG: false + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhos_mcps diff --git a/test/kuttl/tests/rhos-mcps-configuration/03-assert-rhos-mcps-instance.yaml b/test/kuttl/tests/rhos-mcps-configuration/03-assert-rhos-mcps-instance.yaml new file mode 100644 index 00000000..e0e61989 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/03-assert-rhos-mcps-instance.yaml @@ -0,0 +1,69 @@ +############################################################################## +# Assert that the operator created all expected RHOS MCP resources # +############################################################################## + +# Main deployment with RHOS MCP sidecar +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-stack-deployment + namespace: openstack-lightspeed +spec: + template: + spec: + containers: + - name: llama-stack + - name: lightspeed-service-api + - name: lightspeed-to-dataverse-exporter + - name: rhos-mcp + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml +status: + replicas: 1 + readyReplicas: 1 + availableReplicas: 1 + +# MCP server ConfigMap +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + +# OpenStackLightspeed CR status +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + conditions: + - type: Ready + status: "True" + reason: Ready + message: Setup complete + - type: OCPRAGReady + status: "True" + reason: Ready + message: OCP RAG is disabled + - type: OpenStackLightspeedMCPServerReady + status: "True" + - type: OpenStackLightspeedReady + status: "True" + reason: Ready + message: OpenStack Lightspeed created diff --git a/test/kuttl/tests/rhos-mcps-configuration/04-disable-rhos-mcps.yaml b/test/kuttl/tests/rhos-mcps-configuration/04-disable-rhos-mcps.yaml new file mode 100644 index 00000000..846e5d1a --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/04-disable-rhos-mcps.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + enableOCPRAG: false + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: [] diff --git a/test/kuttl/tests/rhos-mcps-configuration/05-errors-rhos-mcps-cleanup.yaml b/test/kuttl/tests/rhos-mcps-configuration/05-errors-rhos-mcps-cleanup.yaml new file mode 100644 index 00000000..22fe5379 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/05-errors-rhos-mcps-cleanup.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/rhos-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhos-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..6b2075b0 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/cleanup-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhos-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhos-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..81472440 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhos-mcps-configuration/08-cleanup-mock-objects.yaml b/test/kuttl/tests/rhos-mcps-configuration/08-cleanup-mock-objects.yaml new file mode 120000 index 00000000..410c9278 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/08-cleanup-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/cleanup-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhos-mcps-configuration/09-errors-mock-objects.yaml b/test/kuttl/tests/rhos-mcps-configuration/09-errors-mock-objects.yaml new file mode 120000 index 00000000..696a5e26 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/09-errors-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/errors-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml b/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml deleted file mode 120000 index 6c2d5ae5..00000000 --- a/test/kuttl/tests/update-openstacklightspeed/03-assert-mcp-config.yaml +++ /dev/null @@ -1 +0,0 @@ -../../common/openstack-lightspeed-instance/assert-mcp-config.yaml \ No newline at end of file diff --git a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml index 722932fc..7627e90c 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -151,21 +151,6 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true - - name: rhos-mcp - volumeMounts: - - name: openstack-config-secret - mountPath: /app/secure.yaml - subPath: secure.yaml - - name: openstack-config - mountPath: /app/clouds.yaml - subPath: clouds.yaml - - name: combined-ca-bundle - mountPath: /app/tls-ca-bundle.pem - subPath: tls-ca-bundle.pem - readOnly: true - - name: mcp-config - mountPath: /app/config.yaml - subPath: config.yaml volumes: - name: ogx-config configMap: @@ -186,33 +171,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: openstack-config-secret - secret: - secretName: openstack-config-secret - optional: true - items: - - key: secure.yaml - path: secure.yaml - - name: openstack-config - configMap: - name: openstack-config - optional: true - items: - - key: clouds.yaml - path: clouds.yaml - - name: combined-ca-bundle - secret: - secretName: combined-ca-bundle - optional: true - items: - - key: tls-ca-bundle.pem - path: tls-ca-bundle.pem - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -241,14 +199,6 @@ metadata: name: vector-db-scripts namespace: openstack-lightspeed -# MCP server resources ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: mcp-config - namespace: openstack-lightspeed - # Console Plugin resources --- apiVersion: v1 @@ -335,6 +285,7 @@ status: message: OCP RAG is disabled - type: OpenStackLightspeedMCPServerReady status: "True" + message: "RHOS MCP server is disabled (rhos_mcps feature flag not set)" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml b/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml index 13265324..3314b400 100644 --- a/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml @@ -25,40 +25,6 @@ metadata: name: openstack-lightspeed-ca-bundle namespace: openstack-lightspeed -# Verify MCP config ConfigMap still exists with correct content after update ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: mcp-config - namespace: openstack-lightspeed -data: - config.yaml: | - --- - ip: 127.0.0.1 - port: 8080 - debug: false - workers: 1 - processes_pool_size: 10 - - openstack: - enabled: false - allow_write: false - ca_cert: ./tls-ca-bundle.pem - insecure: false - - openshift: - enabled: true - allow_write: false - insecure: false - - mcp_transport_security: - enable_dns_rebinding_protection: true - allowed_hosts: - - "127.0.0.1:*" - - "localhost:*" - allowed_origins: [] - # Verify all operator-managed resources still exist after update --- apiVersion: apps/v1 From 2fde292e36a742568c07b1a2ba425a0a44efb190 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Fri, 12 Jun 2026 13:26:17 +0200 Subject: [PATCH 4/8] MCP Use Keystone Application Credentials Initial implementation of the MCP deployment uses the credentials from the `openstackclient` pod, which means that we are not the owners of that secret, just the copy we make in the `openstack-lightspeed` namespace, so those credentials could be removed/deleted and that would break our `openstack-cli` tool. In this patch we change the credentials and we leverage the `KeystoneApplicationCredential` CR to get our own credentials. Credential Rotation is handled by the code as well. Jira: OSPRH-27075 Co-Authored-By: Claude Opus 4.6 --- api/v1beta1/conditions.go | 6 + api/v1beta1/openstacklightspeed_types.go | 5 + ...ed.openstack.org_openstacklightspeeds.yaml | 5 + ...tspeed-operator.clusterserviceversion.yaml | 24 + cmd/main.go | 5 + ...ed.openstack.org_openstacklightspeeds.yaml | 5 + config/rbac/role.yaml | 24 + go.mod | 5 +- go.sum | 12 + internal/controller/common.go | 9 + internal/controller/constants.go | 13 + internal/controller/mcp_server.go | 642 ++++++++++++++++-- .../openstacklightspeed_controller.go | 15 +- .../mock-openstack/assert-mock-openstack.yaml | 37 + .../cleanup-mock-openstack.yaml | 45 ++ .../mock-openstack/errors-mock-openstack.yaml | 47 ++ test/kuttl/common/mock-openstack/kac-crd.yaml | 29 + .../common/mock-openstack/mock-keystone.yaml | 144 ++++ .../mock-openstack/mock-oscp-resources.yaml | 86 +++ .../kuttl/common/mock-openstack/oscp-crd.yaml | 27 + .../simulate-keystone-operator.sh | 47 ++ .../00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + .../02-create-openstack-crds.yaml | 16 + .../03-create-mock-openstack.yaml | 14 + .../04-assert-mock-openstack.yaml | 15 + .../06-assert-ac-resources.yaml | 67 ++ .../07-simulate-keystone-operator.yaml | 9 + .../08-assert-mcp-credentials.yaml | 84 +++ ...cleanup-openstack-lightspeed-instance.yaml | 1 + .../10-assert-ac-cleanup.yaml | 50 ++ ...-errors-openstack-lightspeed-instance.yaml | 1 + .../12-cleanup-mock-openstack.yaml | 15 + .../13-assert-mock-openstack.yaml | 17 + .../14-cleanup-mock-objects.yaml | 1 + .../15-errors-mock-objects.yaml | 1 + 36 files changed, 1452 insertions(+), 73 deletions(-) create mode 100644 test/kuttl/common/mock-openstack/assert-mock-openstack.yaml create mode 100644 test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml create mode 100644 test/kuttl/common/mock-openstack/errors-mock-openstack.yaml create mode 100644 test/kuttl/common/mock-openstack/kac-crd.yaml create mode 100644 test/kuttl/common/mock-openstack/mock-keystone.yaml create mode 100644 test/kuttl/common/mock-openstack/mock-oscp-resources.yaml create mode 100644 test/kuttl/common/mock-openstack/oscp-crd.yaml create mode 100755 test/kuttl/common/mock-openstack/simulate-keystone-operator.sh create mode 120000 test/kuttl/tests/application-credentials/00-mock-resources.yaml create mode 120000 test/kuttl/tests/application-credentials/01-assert-mock-objects-created.yaml create mode 100644 test/kuttl/tests/application-credentials/02-create-openstack-crds.yaml create mode 100644 test/kuttl/tests/application-credentials/03-create-mock-openstack.yaml create mode 100644 test/kuttl/tests/application-credentials/04-assert-mock-openstack.yaml create mode 100644 test/kuttl/tests/application-credentials/06-assert-ac-resources.yaml create mode 100644 test/kuttl/tests/application-credentials/07-simulate-keystone-operator.yaml create mode 100644 test/kuttl/tests/application-credentials/08-assert-mcp-credentials.yaml create mode 120000 test/kuttl/tests/application-credentials/09-cleanup-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/application-credentials/10-assert-ac-cleanup.yaml create mode 120000 test/kuttl/tests/application-credentials/11-errors-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/application-credentials/12-cleanup-mock-openstack.yaml create mode 100644 test/kuttl/tests/application-credentials/13-assert-mock-openstack.yaml create mode 120000 test/kuttl/tests/application-credentials/14-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/application-credentials/15-errors-mock-objects.yaml diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 5cbcb8d2..2c1b6472 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -77,6 +77,12 @@ const ( // 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)" diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index f50c4768..114a14b2 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -228,6 +228,11 @@ type OpenStackLightspeedStatus struct { // 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 diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index 839ee8a0..d226d9ff 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -204,6 +204,11 @@ spec: 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 + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret is the name of the current AC secret in the + OpenStack namespace. Tracked for rotation detection. + 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 3c92a641..175025c5 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -178,7 +178,13 @@ spec: resources: - secrets verbs: + - create + - delete - get + - list + - patch + - update + - watch - apiGroups: - "" resourceNames: @@ -223,6 +229,24 @@ spec: - get - list - watch + - apiGroups: + - keystone.openstack.org + resources: + - keystoneapplicationcredentials + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - keystone.openstack.org + resources: + - keystoneapplicationcredentials/status + verbs: + - get - apiGroups: - lightspeed.openstack.org resources: diff --git a/cmd/main.go b/cmd/main.go index c17a384b..0b5a9c9c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -251,5 +251,10 @@ func getDynamicWatchCRDs() map[schema.GroupVersionKind]*atomic.Bool { Version: "v1beta1", Kind: "OpenStackControlPlane", }: new(atomic.Bool), + { + Group: "keystone.openstack.org", + Version: "v1beta1", + Kind: "KeystoneApplicationCredential", + }: new(atomic.Bool), } } diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index c1ae2c20..02be1c36 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -204,6 +204,11 @@ spec: 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 + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret is the name of the current AC secret in the + OpenStack namespace. Tracked for rotation detection. + type: string conditions: description: Conditions items: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 22f145e0..3572d32f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -17,7 +17,13 @@ rules: resources: - secrets verbs: + - create + - delete - get + - list + - patch + - update + - watch - apiGroups: - "" resourceNames: @@ -62,6 +68,24 @@ rules: - get - list - watch +- apiGroups: + - keystone.openstack.org + resources: + - keystoneapplicationcredentials + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - keystone.openstack.org + resources: + - keystoneapplicationcredentials/status + verbs: + - get - apiGroups: - lightspeed.openstack.org resources: diff --git a/go.mod b/go.mod index 7285da9a..5e0f7d5b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/onsi/gomega v1.39.0 github.com/openshift/api v3.9.0+incompatible // from lib-common github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 + github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0 github.com/operator-framework/api v0.37.0 k8s.io/api v0.34.2 k8s.io/apiextensions-apiserver v0.34.2 @@ -21,6 +22,8 @@ require ( // must be consistent within modules and service operators replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e +require k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + require ( cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -48,6 +51,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud v1.14.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -102,7 +106,6 @@ require ( k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2b4c5f4e..58ddf037 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= +github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 h1:+epNPbD5EqgpEMm5wrl4Hqts3jZt8+kYaqUisuuIGTk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -113,6 +115,8 @@ github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e h1:E1OdwSpqWuDPCedyU github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 h1:2TD4hi+MLt67jKxJUs2tuBKFMxibrLJQqKqhsTMsHeQ= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0/go.mod h1:rgpcv2tLD+/vudXx/gpIQSTuRpk4GOxHx84xwfvQalM= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0 h1:8BniQwsPk8qjqoniLFDLnBEJgA0FLOwIrPDv93URiMo= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0/go.mod h1:tfMa+ochq7Dyilq9hQr2CEPfPtsj6IUgMmMqi4CWDmo= github.com/operator-framework/api v0.37.0 h1:2XCMWitBnumtJTqzip6LQKUwpM2pXVlt3gkpdlkbaCE= github.com/operator-framework/api v0.37.0/go.mod h1:NZs4vB+Jiamyv3pdPDjZtuC4U7KX0eq4z2r5hKY5fUA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -196,6 +200,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -206,6 +211,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -218,13 +224,18 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -256,6 +267,7 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/controller/common.go b/internal/controller/common.go index 22a756fe..deb2535f 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -240,6 +240,15 @@ func OpenStackControlPlaneGVK() schema.GroupVersionKind { } } +// KeystoneApplicationCredentialGVK returns the GroupVersionKind for KeystoneApplicationCredential. +func KeystoneApplicationCredentialGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: KeystoneApplicationCredentialGroup, + Version: KeystoneApplicationCredentialVersion, + Kind: KeystoneApplicationCredentialKind, + } +} + // IsDynamicCRDReadyByGVK checks whether the given GVK is being watched and has // been observed as ready by the dynamic watch. func IsDynamicCRDReadyByGVK( diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 1b1a28c8..c932122b 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -114,6 +114,19 @@ const ( OpenStackControlPlaneVersion = "v1beta1" OpenStackControlPlaneKind = "OpenStackControlPlane" + // Keystone Application Credential + KeystoneApplicationCredentialGroup = "keystone.openstack.org" + KeystoneApplicationCredentialVersion = "v1beta1" + KeystoneApplicationCredentialKind = "KeystoneApplicationCredential" + + // Lightspeed Service User + LightspeedServiceUserName = "lightspeed" + LightspeedServiceUserDomain = "default" + LightspeedPasswordSecretName = "lightspeed-password" + LightspeedPasswordSecretKey = "password" + LightspeedACCRName = "lightspeed" + LightspeedACFinalizerName = "openstack.org/lightspeed-ac-consumer" + // Azure AzureOpenAIType = "azure_openai" diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go index d0663da3..ab4f7d86 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -18,18 +18,26 @@ package controller import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" + "sort" + "strings" "text/template" + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + common_secret "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + openstack_lib "github.com/openstack-k8s-operators/lib-common/modules/openstack" apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1" corev1 "k8s.io/api/core/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/yaml" ) // --------------------------------------------------------------------------- @@ -195,7 +203,456 @@ func (r *OpenStackLightspeedReconciler) listOpenStackControlPlanes( return fallbackList, nil } -// reconcileMCPServerWithOpenStack copies OpenStack resources and reconciles the MCP config. +// --------------------------------------------------------------------------- +// Cloud config parsing +// --------------------------------------------------------------------------- + +type cloudsYAML struct { + Clouds map[string]cloudYAMLEntry `json:"clouds"` +} + +type cloudYAMLEntry struct { + Auth cloudYAMLAuth `json:"auth"` + RegionName string `json:"region_name"` +} + +type cloudYAMLAuth struct { + AuthURL string `json:"auth_url"` + Username string `json:"username"` + Password string `json:"password"` +} + +// parseCloudConfig reads the openstackclient's clouds.yaml and secure.yaml +// from the OSCP namespace and returns the merged config for the first cloud. +func parseCloudConfig( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, +) (*cloudYAMLEntry, error) { + configMapName, _, _ := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigMap") + configSecretName, _, _ := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigSecret") + oscpNS := oscp.GetNamespace() + + kclient := helper.GetKClient() + + cm, err := kclient.CoreV1().ConfigMaps(oscpNS).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to read clouds.yaml configmap %s/%s: %w", oscpNS, configMapName, err) + } + + var clouds cloudsYAML + if err := yaml.Unmarshal([]byte(cm.Data["clouds.yaml"]), &clouds); err != nil { + return nil, fmt.Errorf("failed to parse clouds.yaml: %w", err) + } + + sec, err := kclient.CoreV1().Secrets(oscpNS).Get(ctx, configSecretName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to read secure.yaml secret %s/%s: %w", oscpNS, configSecretName, err) + } + + var secClouds cloudsYAML + if err := yaml.Unmarshal(sec.Data["secure.yaml"], &secClouds); err != nil { + return nil, fmt.Errorf("failed to parse secure.yaml: %w", err) + } + + var name string + switch len(clouds.Clouds) { + case 0: + return nil, errors.New("no cloud entry found in clouds.yaml") + case 1: + for name = range clouds.Clouds { + } + default: + if _, ok := clouds.Clouds["default"]; ok { + name = "default" + } else { + names := make([]string, 0, len(clouds.Clouds)) + for n := range clouds.Clouds { + names = append(names, n) + } + sort.Strings(names) + return nil, fmt.Errorf("clouds.yaml has multiple entries (%s) and none is named \"default\"", strings.Join(names, ", ")) + } + } + + entry := clouds.Clouds[name] + if secEntry, ok := secClouds.Clouds[name]; ok { + if secEntry.Auth.Password != "" { + entry.Auth.Password = secEntry.Auth.Password + } + } + return &entry, nil +} + +// --------------------------------------------------------------------------- +// OpenStack client +// --------------------------------------------------------------------------- + +func getOpenStackClient( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, + caPEM []byte, +) (*openstack_lib.OpenStack, *cloudYAMLEntry, error) { + log := helper.GetLogger() + + cloudCfg, err := parseCloudConfig(ctx, helper, oscp) + if err != nil { + return nil, nil, err + } + + // If caPEM was not provided, read it from the OSCP CA bundle secret. + if caPEM == nil { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + if caBundleSecretName != "" { + caSecret, err := helper.GetKClient().CoreV1().Secrets(oscp.GetNamespace()).Get(ctx, caBundleSecretName, metav1.GetOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to read CA bundle secret: %w", err) + } + caPEM = caSecret.Data["tls-ca-bundle.pem"] + } + } + + var tlsCfg *openstack_lib.TLSConfig + if len(caPEM) > 0 { + tlsCfg = &openstack_lib.TLSConfig{CACerts: []string{string(caPEM)}} + } + + osClient, err := openstack_lib.NewOpenStack(log, openstack_lib.AuthOpts{ + AuthURL: cloudCfg.Auth.AuthURL, + Username: cloudCfg.Auth.Username, + Password: cloudCfg.Auth.Password, + TenantName: "admin", + DomainName: "Default", + Region: cloudCfg.RegionName, + TLS: tlsCfg, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to authenticate with keystone: %w", err) + } + + return osClient, cloudCfg, nil +} + +// --------------------------------------------------------------------------- +// Service user management +// --------------------------------------------------------------------------- + +func ensurePasswordSecret( + ctx context.Context, + helper *common_helper.Helper, + namespace string, +) (string, error) { + kclient := helper.GetKClient() + + sec, err := kclient.CoreV1().Secrets(namespace).Get(ctx, LightspeedPasswordSecretName, metav1.GetOptions{}) + if err == nil { + return string(sec.Data[LightspeedPasswordSecretKey]), nil + } + if !k8s_errors.IsNotFound(err) { + return "", err + } + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + password := hex.EncodeToString(b) + + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: LightspeedPasswordSecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + LightspeedPasswordSecretKey: []byte(password), + }, + } + _, err = kclient.CoreV1().Secrets(namespace).Create(ctx, newSecret, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create password secret: %w", err) + } + + return password, nil +} + +func ensureServiceUser( + ctx context.Context, + helper *common_helper.Helper, + osClient *openstack_lib.OpenStack, + oscpNamespace string, +) error { + log := helper.GetLogger() + + password, err := ensurePasswordSecret(ctx, helper, oscpNamespace) + if err != nil { + return err + } + + userID, err := osClient.CreateUser(log, openstack_lib.User{ + Name: LightspeedServiceUserName, + Password: password, + DomainID: LightspeedServiceUserDomain, + }) + if err != nil { + return fmt.Errorf("failed to create keystone user: %w", err) + } + + serviceProject, err := osClient.GetProject(log, "service", LightspeedServiceUserDomain) + if err != nil { + return fmt.Errorf("failed to get service project: %w", err) + } + + if err := osClient.AssignUserRole(log, "admin", userID, serviceProject.ID); err != nil { + return fmt.Errorf("failed to assign admin role: %w", err) + } + + return nil +} + +// --------------------------------------------------------------------------- +// Application credential management +// --------------------------------------------------------------------------- + +func ensureApplicationCredential( + ctx context.Context, + helper *common_helper.Helper, + namespace string, +) error { + rawClient, err := getRawClient(helper) + if err != nil { + return fmt.Errorf("failed to get raw client: %w", err) + } + + acCR := &uns.Unstructured{} + acCR.SetGroupVersionKind(KeystoneApplicationCredentialGVK()) + acCR.SetName(LightspeedACCRName) + acCR.SetNamespace(namespace) + + err = rawClient.Get(ctx, types.NamespacedName{Name: LightspeedACCRName, Namespace: namespace}, acCR) + if err == nil { + return nil + } + if !k8s_errors.IsNotFound(err) { + return err + } + + acCR.SetAnnotations(map[string]string{ + "keystone.openstack.org/edpm-service": "false", + }) + if err := uns.SetNestedField(acCR.Object, LightspeedServiceUserName, "spec", "userName"); err != nil { + return err + } + if err := uns.SetNestedField(acCR.Object, LightspeedPasswordSecretName, "spec", "secret"); err != nil { + return err + } + if err := uns.SetNestedField(acCR.Object, LightspeedPasswordSecretKey, "spec", "passwordSelector"); err != nil { + return err + } + if err := uns.SetNestedStringSlice(acCR.Object, []string{"admin"}, "spec", "roles"); err != nil { + return err + } + + helper.GetLogger().Info("Creating KeystoneApplicationCredential CR", "namespace", namespace) + return rawClient.Create(ctx, acCR) +} + +// reconcileACSecret reads the AC secret name from the CR status, manages +// finalizers for rotation safety, and returns the credential values. +func reconcileACSecret( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscpNamespace string, +) (acID, acSecret string, ready bool, err error) { + rawClient, err := getRawClient(helper) + if err != nil { + return "", "", false, err + } + + acCR := &uns.Unstructured{} + acCR.SetGroupVersionKind(KeystoneApplicationCredentialGVK()) + if err := rawClient.Get(ctx, types.NamespacedName{Name: LightspeedACCRName, Namespace: oscpNamespace}, acCR); err != nil { + return "", "", false, fmt.Errorf("failed to read AC CR: %w", err) + } + + secretName, _, _ := uns.NestedString(acCR.Object, "status", "secretName") + if secretName == "" { + return "", "", false, nil + } + + kclient := helper.GetKClient() + + // Add finalizer to current AC secret + acSecretObj, err := kclient.CoreV1().Secrets(oscpNamespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + if k8s_errors.IsNotFound(err) { + return "", "", false, nil + } + return "", "", false, err + } + + if !controllerutil.ContainsFinalizer(acSecretObj, LightspeedACFinalizerName) { + controllerutil.AddFinalizer(acSecretObj, LightspeedACFinalizerName) + if _, err := kclient.CoreV1().Secrets(oscpNamespace).Update(ctx, acSecretObj, metav1.UpdateOptions{}); err != nil { + return "", "", false, fmt.Errorf("failed to add finalizer to AC secret: %w", err) + } + } + + // Handle rotation: remove finalizer from previous secret + prevSecret := instance.Status.ApplicationCredentialSecret + if prevSecret != "" && prevSecret != secretName { + helper.GetLogger().Info("AC secret rotated", "old", prevSecret, "new", secretName) + oldSecret, err := kclient.CoreV1().Secrets(oscpNamespace).Get(ctx, prevSecret, metav1.GetOptions{}) + if err == nil && controllerutil.RemoveFinalizer(oldSecret, LightspeedACFinalizerName) { + if _, err := kclient.CoreV1().Secrets(oscpNamespace).Update(ctx, oldSecret, metav1.UpdateOptions{}); err != nil { + helper.GetLogger().Info("Failed to remove finalizer from old AC secret", "secret", prevSecret, "error", err) + } + } + } + + instance.Status.ApplicationCredentialSecret = secretName + + return string(acSecretObj.Data["AC_ID"]), string(acSecretObj.Data["AC_SECRET"]), true, nil +} + +// --------------------------------------------------------------------------- +// MCP credential generation +// --------------------------------------------------------------------------- + +func generateMCPCredentials( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + cloudCfg *cloudYAMLEntry, + acID, acSecret string, +) error { + type acAuth struct { + AuthURL string `json:"auth_url,omitempty"` + ApplicationCredentialID string `json:"application_credential_id,omitempty"` + ApplicationCredentialSecret string `json:"application_credential_secret,omitempty"` + } + type acEntry struct { + AuthType string `json:"auth_type,omitempty"` + Auth acAuth `json:"auth"` + RegionName string `json:"region_name,omitempty"` + IdentityAPIVersion int `json:"identity_api_version,omitempty"` + } + type acClouds struct { + Clouds map[string]acEntry `json:"clouds"` + } + + cloudsBytes, err := yaml.Marshal(acClouds{ + Clouds: map[string]acEntry{ + "default": { + AuthType: "v3applicationcredential", + Auth: acAuth{ + AuthURL: cloudCfg.Auth.AuthURL, + ApplicationCredentialID: acID, + }, + RegionName: cloudCfg.RegionName, + IdentityAPIVersion: 3, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to marshal clouds.yaml: %w", err) + } + + cloudsConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: CloudsYAMLConfigMapName, + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), cloudsConfigMap, func() error { + cloudsConfigMap.Data = map[string]string{"clouds.yaml": string(cloudsBytes)} + return controllerutil.SetControllerReference(instance, cloudsConfigMap, helper.GetScheme()) + }) + if err != nil { + return fmt.Errorf("failed to create clouds.yaml configmap: %w", err) + } + + secureBytes, err := yaml.Marshal(acClouds{ + Clouds: map[string]acEntry{ + "default": { + Auth: acAuth{ + ApplicationCredentialSecret: acSecret, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to marshal secure.yaml: %w", err) + } + + secureSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureYAMLSecretName, + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), secureSecret, func() error { + secureSecret.Data = map[string][]byte{"secure.yaml": secureBytes} + if err := controllerutil.SetControllerReference(instance, secureSecret, helper.GetScheme()); err != nil { + return err + } + checksum, err := common_secret.Hash(secureSecret) + if err != nil { + return err + } + SetChecksumAnnotation(secureSecret, checksum) + return nil + }) + if err != nil { + return fmt.Errorf("failed to create secure.yaml secret: %w", err) + } + + return nil +} + +// --------------------------------------------------------------------------- +// CA bundle copy +// --------------------------------------------------------------------------- + +func copyCABundle( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, +) ([]byte, error) { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + + source := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: caBundleSecretName, + Namespace: oscp.GetNamespace(), + }, + } + target := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: CombinedCABundleSecretName, + Namespace: instance.Namespace, + }, + } + + copied, err := CopyResource(ctx, helper, source, target, instance, helper.GetScheme()) + if err != nil { + return nil, err + } + + if sec, ok := copied.(*corev1.Secret); ok { + return sec.Data["tls-ca-bundle.pem"], nil + } + return nil, nil +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +// reconcileMCPServerWithOpenStack creates a service user, application credential, +// generates credential files, and reconciles the MCP config. func (r *OpenStackLightspeedReconciler) reconcileMCPServerWithOpenStack( ctx context.Context, helper *common_helper.Helper, @@ -203,31 +660,71 @@ func (r *OpenStackLightspeedReconciler) reconcileMCPServerWithOpenStack( oscp *uns.Unstructured, ) (bool, error) { log := helper.GetLogger() + oscpNS := oscp.GetNamespace() - fields, err := extractOSCPFields(helper, oscp) + fieldsReady, err := extractOSCPFields(helper, oscp) if err != nil { - log.Info(fmt.Sprintf("OpenStackControlPlane field check failed with error: %v", err)) + log.Info(fmt.Sprintf("OpenStackControlPlane field check failed: %v", err)) return false, err } - if fields == nil { - log.Info("OpenStackControlPlane fields not ready yet, deploying MCP without OpenStack resources") + if !fieldsReady { + log.Info("OpenStackControlPlane fields not ready, deploying MCP without OpenStack") return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) } - _, err = copyObjectsToOpenStackLightspeedNamespace(ctx, helper, instance, oscp, fields) + caPEM, err := copyCABundle(ctx, helper, instance, oscp) if err != nil { if k8s_errors.IsNotFound(err) { - log.Info(fmt.Sprintf("OpenStack resource not found (%v), deploying MCP without OpenStack resources", err)) + log.Info("CA bundle not found, deploying MCP without OpenStack") return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) } return false, err } + osClient, cloudCfg, err := getOpenStackClient(ctx, helper, oscp, caPEM) + if err != nil { + return false, fmt.Errorf("failed to get OpenStack client: %w", err) + } + + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackLightspeedMCPServerCreatingUser, + )) + + if err := ensureServiceUser(ctx, helper, osClient, oscpNS); err != nil { + return false, fmt.Errorf("failed to ensure service user: %w", err) + } + + if err := ensureApplicationCredential(ctx, helper, oscpNS); err != nil { + return false, fmt.Errorf("failed to ensure application credential: %w", err) + } + + acID, acSec, ready, err := reconcileACSecret(ctx, helper, instance, oscpNS) + if err != nil { + return false, err + } + if !ready { + log.Info("Application credential secret not ready, deploying MCP without OpenStack") + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackLightspeedMCPServerWaitingAC, + )) + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + if err := generateMCPCredentials(ctx, helper, instance, cloudCfg, acID, acSec); err != nil { + return false, err + } + if err := r.reconcileMCPServerDeploy(ctx, helper, instance, true); err != nil { return false, err } - log.Info("MCP server reconciled with OpenStack resources") + log.Info("MCP server reconciled with application credentials") return true, nil } @@ -255,40 +752,29 @@ func (r *OpenStackLightspeedReconciler) reconcileMCPServerDeploy( return err } -// oscpFields holds the validated fields extracted from an OpenStackControlPlane. -type oscpFields struct { - configSecret string - configMap string - caBundleSecretName string -} - -// extractOSCPFields extracts and validates the required fields from an OpenStackControlPlane. -// Returns (nil, nil) when the status TLS field is not yet populated (waiting for readiness). +// extractOSCPFields validates that the required fields in an OpenStackControlPlane are populated. +// Returns (false, nil) when the status TLS field is not yet populated (waiting for readiness). func extractOSCPFields( helper *common_helper.Helper, oscp *uns.Unstructured, -) (*oscpFields, error) { +) (bool, error) { configSecret, found, err := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigSecret") if err != nil || !found || configSecret == "" { - return nil, fmt.Errorf("OpenStackClient.Template.OpenStackConfigSecret is missing value") + return false, fmt.Errorf("OpenStackClient.Template.OpenStackConfigSecret is missing value") } configMap, found, err := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigMap") if err != nil || !found || configMap == "" { - return nil, fmt.Errorf("OpenStackControlPlane.OpenStackClient.Template.OpenStackConfigMap is missing value") + return false, fmt.Errorf("OpenStackControlPlane.OpenStackClient.Template.OpenStackConfigMap is missing value") } caBundleSecretName, found, err := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") if err != nil || !found || caBundleSecretName == "" { helper.GetLogger().Info("Waiting for OpenStackControlPlane.Status.TLS.CaBundleSecretName value") - return nil, nil + return false, nil } - return &oscpFields{ - configSecret: configSecret, - configMap: configMap, - caBundleSecretName: caBundleSecretName, - }, nil + return true, nil } // --------------------------------------------------------------------------- @@ -333,62 +819,76 @@ func (r *OpenStackLightspeedReconciler) cleanupMCPResources( return fmt.Errorf("failed to delete combined-ca-bundle Secret: %w", err) } + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return fmt.Errorf("failed to clean up OpenStack resources during RHOS MCP disable: %w", err) + } + log.Info("RHOS MCP resources cleaned up") return nil } -// copyObjectsToOpenStackLightspeedNamespace copies the required ConfigMaps and Secrets -// from the OpenStackControlPlane's namespace to the OpenStack Lightspeed namespace. -func copyObjectsToOpenStackLightspeedNamespace( +// reconcileDeleteOpenStackResources cleans up OpenStack resources created for the +// MCP server: AC secret finalizer, AC CR, keystone user, and password secret. +func (r *OpenStackLightspeedReconciler) reconcileDeleteOpenStackResources( ctx context.Context, helper *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed, - oscp *uns.Unstructured, - fields *oscpFields, -) (map[string]client.Object, error) { - objectsToCopy := map[string]client.Object{ - SecureYAMLSecretName: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fields.configSecret, - Namespace: oscp.GetNamespace(), - }, - }, - CloudsYAMLConfigMapName: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: fields.configMap, - Namespace: oscp.GetNamespace(), - }, - }, - CombinedCABundleSecretName: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fields.caBundleSecretName, - Namespace: oscp.GetNamespace(), - }, - }, - } - - copiedObjects := make(map[string]client.Object) +) error { + log := helper.GetLogger() - for resourceName, sourceObject := range objectsToCopy { - targetObject := sourceObject.DeepCopyObject().(client.Object) - targetObject.SetNamespace(instance.Namespace) - targetObject.SetName(resourceName) + crdReady, err := IsDynamicCRDReadyByGVK(r.DynamicWatchCRD, OpenStackControlPlaneGVK()) + if err != nil || !crdReady { + return err + } - copied, err := CopyResource(ctx, helper, sourceObject, targetObject, instance, helper.GetScheme()) - if err != nil { - if k8s_errors.IsNotFound(err) { - helper.GetLogger().Info( - fmt.Sprintf("Resource %s not found in namespace %s, waiting for it to be created", - sourceObject.GetName(), sourceObject.GetNamespace()), - ) + openStackControlPlaneList, err := r.listOpenStackControlPlanes(ctx, helper) + if err != nil { + return fmt.Errorf("failed to list OpenStackControlPlanes during deletion: %w", err) + } + if len(openStackControlPlaneList.Items) != 1 { + return nil + } + oscp := &openStackControlPlaneList.Items[0] + oscpNS := oscp.GetNamespace() + + // Remove finalizer from AC secret + if instance.Status.ApplicationCredentialSecret != "" { + acSecret, err := helper.GetKClient().CoreV1().Secrets(oscpNS).Get( + ctx, instance.Status.ApplicationCredentialSecret, metav1.GetOptions{}) + if err == nil && controllerutil.RemoveFinalizer(acSecret, LightspeedACFinalizerName) { + if _, err := helper.GetKClient().CoreV1().Secrets(oscpNS).Update(ctx, acSecret, metav1.UpdateOptions{}); err != nil { + log.Info("Failed to remove finalizer from AC secret", "error", err) } - return nil, err } - if copied == nil { - return nil, errors.New("the internal representation of the copied object is nil") + } + + // Delete AC CR + rawClient, err := getRawClient(helper) + if err == nil { + acCR := &uns.Unstructured{} + acCR.SetGroupVersionKind(KeystoneApplicationCredentialGVK()) + acCR.SetName(LightspeedACCRName) + acCR.SetNamespace(oscpNS) + if err := rawClient.Delete(ctx, acCR); err != nil && !k8s_errors.IsNotFound(err) { + log.Info("Failed to delete AC CR", "error", err) + } + } + + // Delete keystone user (best-effort) + osClient, _, err := getOpenStackClient(ctx, helper, oscp, nil) + if err == nil { + if err := osClient.DeleteUser(log, LightspeedServiceUserName, LightspeedServiceUserDomain); err != nil { + log.Info("Failed to delete keystone user (best-effort)", "error", err) } - copiedObjects[resourceName] = copied + } else { + log.Info("Could not connect to keystone for user deletion (best-effort)", "error", err) + } + + // Delete password secret + if err := helper.GetKClient().CoreV1().Secrets(oscpNS).Delete( + ctx, LightspeedPasswordSecretName, metav1.DeleteOptions{}); err != nil && !k8s_errors.IsNotFound(err) { + log.Info("Failed to delete password secret", "error", err) } - return copiedObjects, nil + return nil } diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 8b4a84b1..cc1b5fc7 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -82,10 +82,12 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,namespace=openstack-lightspeed,verbs=update;patch;delete // +kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,resourceNames=pull-secret,verbs=get -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;patch;update;delete // +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups=core.openstack.org,resources=openstackcontrolplanes,verbs=get;list;watch +// +kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials/status,verbs=get // +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update // +kubebuilder:rbac:groups=apps,resources=deployments,namespace=openstack-lightspeed,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=configmaps,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update;delete @@ -188,6 +190,9 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. _ = r.resolveOCPVersion(ctx, helper, instance) if !instance.DeletionTimestamp.IsZero() { + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return ctrl.Result{}, err + } if err := r.reconcileDelete(ctx, helper, instance); err != nil { return ctrl.Result{}, err } @@ -473,6 +478,14 @@ func (r *OpenStackLightspeedReconciler) WatchDynamicCRD( ) error { for gvk, seen := range r.DynamicWatchCRD { if seen.Load() { + // Re-verify CRD still exists — it may have been uninstalled. + crdAvailable, err := IsCRDEstablished(ctx, helper, gvk) + if err != nil { + return err + } + if !crdAvailable { + seen.Store(false) + } continue } diff --git a/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml b/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml new file mode 100644 index 00000000..7cd24658 --- /dev/null +++ b/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml @@ -0,0 +1,37 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: Established + status: "True" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +status: + conditions: + - type: Established + status: "True" +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack +status: + phase: Running +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: openstack-galera-network-isolation + namespace: openstack diff --git a/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml b/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml new file mode 100644 index 00000000..1f79624b --- /dev/null +++ b/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: core.openstack.org/v1beta1 + kind: OpenStackControlPlane + name: openstack-galera-network-isolation + namespace: openstack + - apiVersion: v1 + kind: Pod + name: mock-keystone-server + namespace: openstack + - apiVersion: v1 + kind: Service + name: mock-keystone-server + namespace: openstack + - apiVersion: v1 + kind: ConfigMap + name: mock-keystone-code + namespace: openstack + - apiVersion: v1 + kind: ConfigMap + name: openstack-config-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: openstack-config-secret-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: combined-ca-bundle-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: ac-lightspeed-test-secret + namespace: openstack + - apiVersion: v1 + kind: Namespace + name: openstack + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: openstackcontrolplanes.core.openstack.org + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: keystoneapplicationcredentials.keystone.openstack.org diff --git a/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml b/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml new file mode 100644 index 00000000..f4586d38 --- /dev/null +++ b/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: openstack-galera-network-isolation + namespace: openstack +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-keystone-server + namespace: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-keystone-code + namespace: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config-mock + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret-mock + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle-mock + namespace: openstack +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack diff --git a/test/kuttl/common/mock-openstack/kac-crd.yaml b/test/kuttl/common/mock-openstack/kac-crd.yaml new file mode 100644 index 00000000..4df9372c --- /dev/null +++ b/test/kuttl/common/mock-openstack/kac-crd.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +spec: + group: keystone.openstack.org + names: + kind: KeystoneApplicationCredential + listKind: KeystoneApplicationCredentialList + plural: keystoneapplicationcredentials + singular: keystoneapplicationcredential + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/common/mock-openstack/mock-keystone.yaml b/test/kuttl/common/mock-openstack/mock-keystone.yaml new file mode 100644 index 00000000..60a851dd --- /dev/null +++ b/test/kuttl/common/mock-openstack/mock-keystone.yaml @@ -0,0 +1,144 @@ +############################################################################## +# Mock Keystone v3 API server for application credential kuttl tests # +############################################################################## +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-keystone-code + namespace: openstack +data: + app.py: | + from http.server import HTTPServer, BaseHTTPRequestHandler + import json + + URL = "http://mock-keystone-server.openstack.svc:5000/v3" + TOKEN = "mock-token-12345" + USER_ID = "mock-user-id-lightspeed" + PROJECT_ID = "mock-project-id-service" + ROLE_ID = "mock-role-id-admin" + + AUTH_RESPONSE = json.dumps({"token": { + "methods": ["password"], + "expires_at": "2099-12-31T23:59:59.000000Z", + "user": {"id": "admin-user-id", "name": "admin", + "domain": {"id": "default", "name": "Default"}}, + "project": {"id": "admin-project-id", "name": "admin", + "domain": {"id": "default", "name": "Default"}}, + "catalog": [{"name": "keystone", "type": "identity", "endpoints": [ + {"region_id": "regionOne", "region": "regionOne", + "url": URL, "interface": i, "id": "ep-" + i} + for i in ("admin", "internal", "public") + ]}] + }}) + + class Handler(BaseHTTPRequestHandler): + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length else b"" + + def _json(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _no_content(self): + self.send_response(204) + self.end_headers() + + def do_POST(self): + self._read_body() + if self.path == "/v3/auth/tokens": + data = AUTH_RESPONSE.encode() + self.send_response(201) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("X-Subject-Token", TOKEN) + self.end_headers() + self.wfile.write(data) + elif self.path == "/v3/users": + self._json(201, {"user": {"id": USER_ID, "name": "lightspeed", + "domain_id": "default", "enabled": True, + "password_expires_at": None, + "links": {"self": URL + "/users/" + USER_ID}}}) + else: + self._json(404, {"error": "not found"}) + + def do_GET(self): + if self.path.startswith("/v3/projects"): + self._json(200, {"projects": [ + {"id": PROJECT_ID, "name": "service", "domain_id": "default", + "enabled": True, "links": {"self": URL + "/projects/" + PROJECT_ID}} + ], "links": {"self": URL + "/projects", "next": None, "previous": None}}) + elif self.path.startswith("/v3/roles"): + self._json(200, {"roles": [ + {"id": ROLE_ID, "name": "admin", + "links": {"self": URL + "/roles/" + ROLE_ID}} + ], "links": {"self": URL + "/roles", "next": None, "previous": None}}) + elif self.path.startswith("/v3/users"): + self._json(200, {"users": [ + {"id": USER_ID, "name": "lightspeed", "domain_id": "default", + "enabled": True, "links": {"self": URL + "/users/" + USER_ID}} + ], "links": {"self": URL + "/users", "next": None, "previous": None}}) + else: + self._json(200, {}) + + def do_PUT(self): + self._read_body() + self._no_content() + + def do_DELETE(self): + self._no_content() + + def do_PATCH(self): + self._read_body() + self._json(200, {"user": {"id": USER_ID}}) + + print("Mock keystone starting on :5000", flush=True) + HTTPServer(("", 5000), Handler).serve_forever() +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack + labels: + app: mock-keystone-server +spec: + containers: + - name: mock-keystone + image: registry.redhat.io/ubi8/python-311:latest + ports: + - containerPort: 5000 + volumeMounts: + - name: app-code + mountPath: /app + workingDir: /app + command: + - python3 + - /app/app.py + volumes: + - name: app-code + configMap: + name: mock-keystone-code +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-keystone-server + namespace: openstack +spec: + selector: + app: mock-keystone-server + ports: + - protocol: TCP + port: 5000 + targetPort: 5000 diff --git a/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml b/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml new file mode 100644 index 00000000..799c2c16 --- /dev/null +++ b/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml @@ -0,0 +1,86 @@ +############################################################################## +# Mock OpenStackControlPlane and openstackclient resources for AC tests # +############################################################################## + +# openstackclient clouds.yaml ConfigMap +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config-mock + namespace: openstack +data: + clouds.yaml: | + clouds: + default: + auth: + auth_url: http://mock-keystone-server.openstack.svc:5000/v3 + username: admin + region_name: regionOne + +# openstackclient secure.yaml Secret +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: openstack-config-secret-mock + namespace: openstack +stringData: + secure.yaml: | + clouds: + default: + auth: + password: admin-password + +# CA bundle Secret (dummy cert, keystone uses HTTP) +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: combined-ca-bundle-mock + namespace: openstack +stringData: + tls-ca-bundle.pem: | + -----BEGIN CERTIFICATE----- + MIIEMDCCAxigAwIBAgIJANqb7HHzA7AZMA0GCSqGSIb3DQEBCwUAMIGkMQswCQYD + VQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEk + MCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U + cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRydXN0Q29y + IFJvb3RDZXJ0IENBLTEwHhcNMTYwMjA0MTIzMjE2WhcNMjkxMjMxMTcyMzE2WjCB + pDELMAkGA1UEBhMCUEExDzANBgNVBAgMBlBhbmFtYTEUMBIGA1UEBwwLUGFuYW1h + IENpdHkxJDAiBgNVBAoMG1RydXN0Q29yIFN5c3RlbXMgUy4gZGUgUi5MLjEnMCUG + A1UECwweVHJ1c3RDb3IgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MR8wHQYDVQQDDBZU + cnVzdENvciBSb290Q2VydCBDQS0xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEAv463leLCJhJrMxnHQFgKq1mqjQCj/IDHUHuO1CAmujIS2CNUSSUQIpid + RtLByZ5OGy4sDjjzGiVoHKZaBeYei0i/mJZ0PmnK6bV4pQa81QBeCQryJ3pS/C3V + seq0iWEk8xoT26nPUu0MJLq5nux+AHT6k61sKZKuUbS701e/s/OojZz0JEsq1pme + 9J7+wH5COucLlVPat2gOkEz7cD+PSiyU8ybdY2mplNgQTsVHCJCZGxdNuWxu72CV + EY4hgLW9oHPY0LJ3xEXqWib7ZnZ2+AYfYW0PVcWDtxBWcgYHpfOxGgMFZA6dWorW + hnAbJN7+KIor0Gqw/Hqi3LJ5DotlDwIDAQABo2MwYTAdBgNVHQ4EFgQU7mtJPHo/ + DeOxCbeKyKsZn3MzUOcwHwYDVR0jBBgwFoAU7mtJPHo/DeOxCbeKyKsZn3MzUOcw + DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD + ggEBACUY1JGPE+6PHh0RU9otRCkZoB5rMZ5NDp6tPVxBb5UrJKF5mDo4Nvu7Zp5I + /5CQ7z3UuJu0h3U/IJvOcs+hVcFNZKIZBqEHMwwLKeXx6quj7LUKdJDHfXLy11yf + ke+Ri7fc7Waiz45mO7yfOgLgJ90WmMCV1Aqk5IGadZQ1nJBfiDcGrVmVCrDRZ9MZ + yonnMlo2HD6CqFqTvsbQZJG2z9m2GM/bftJlo6bEjhcxwft+dtvTheNYsnd6djts + L1Ac59v2Z3kf9YKVmgenFK+P3CghZwnS1k1aHBkcjndcw5QkPTJrS37UeJSDvjdN + zl/HHk484IkzlQsPpTLWPFp5LBk= + -----END CERTIFICATE----- + +# OpenStackControlPlane instance +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: openstack-galera-network-isolation + namespace: openstack +spec: + openstackclient: + template: + openStackConfigMap: openstack-config-mock + openStackConfigSecret: openstack-config-secret-mock +status: + tls: + caBundleSecretName: combined-ca-bundle-mock diff --git a/test/kuttl/common/mock-openstack/oscp-crd.yaml b/test/kuttl/common/mock-openstack/oscp-crd.yaml new file mode 100644 index 00000000..c03d5bbd --- /dev/null +++ b/test/kuttl/common/mock-openstack/oscp-crd.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +spec: + group: core.openstack.org + names: + kind: OpenStackControlPlane + listKind: OpenStackControlPlaneList + plural: openstackcontrolplanes + singular: openstackcontrolplane + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/common/mock-openstack/simulate-keystone-operator.sh b/test/kuttl/common/mock-openstack/simulate-keystone-operator.sh new file mode 100755 index 00000000..68aaf55c --- /dev/null +++ b/test/kuttl/common/mock-openstack/simulate-keystone-operator.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Simulates what the keystone-operator would do when processing a +# KeystoneApplicationCredential CR: create an AC secret and update the +# CR status with the secret name. +set -euo pipefail + +NAMESPACE="openstack" +AC_CR_NAME="lightspeed" +AC_SECRET_NAME="ac-lightspeed-test-secret" +AC_ID="mock-ac-id-12345" +AC_SECRET="mock-ac-secret-abcde" + +echo "Waiting for KeystoneApplicationCredential CR to exist..." +for i in $(seq 1 60); do + if oc get keystoneapplicationcredentials.keystone.openstack.org "$AC_CR_NAME" \ + -n "$NAMESPACE" 2>/dev/null; then + echo "AC CR found" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: AC CR not found after 120s" + exit 1 + fi + sleep 2 +done + +echo "Creating AC secret..." +oc apply -f - </dev/null; then + echo "Password secret found" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Password secret not created after 120s" + exit 1 + fi + sleep 2 + done + + PW=$(oc get secret lightspeed-password -n openstack \ + -o jsonpath='{.data.password}' | base64 -d) + if [ -z "$PW" ]; then + echo "ERROR: Password secret has no password key" + exit 1 + fi + echo "Password secret has password key (length: ${#PW})" + + echo "Waiting for AC CR to be created..." + for i in $(seq 1 60); do + if oc get keystoneapplicationcredentials.keystone.openstack.org lightspeed \ + -n openstack 2>/dev/null; then + echo "AC CR found" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: AC CR not created after 120s" + exit 1 + fi + sleep 2 + done + + # Verify AC CR spec + USERNAME=$(oc get keystoneapplicationcredentials.keystone.openstack.org lightspeed \ + -n openstack -o jsonpath='{.spec.userName}') + if [ "$USERNAME" != "lightspeed" ]; then + echo "ERROR: AC CR userName is '$USERNAME', expected 'lightspeed'" + exit 1 + fi + + SECRET_REF=$(oc get keystoneapplicationcredentials.keystone.openstack.org lightspeed \ + -n openstack -o jsonpath='{.spec.secret}') + if [ "$SECRET_REF" != "lightspeed-password" ]; then + echo "ERROR: AC CR secret is '$SECRET_REF', expected 'lightspeed-password'" + exit 1 + fi + + EDPM_SVC=$(oc get keystoneapplicationcredentials.keystone.openstack.org lightspeed \ + -n openstack -o jsonpath='{.metadata.annotations.keystone\.openstack\.org/edpm-service}') + if [ "$EDPM_SVC" != "false" ]; then + echo "ERROR: AC CR edpm-service annotation is '$EDPM_SVC', expected 'false'" + exit 1 + fi + + echo "All AC resources verified" + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/07-simulate-keystone-operator.yaml b/test/kuttl/tests/application-credentials/07-simulate-keystone-operator.yaml new file mode 100644 index 00000000..f0903b1d --- /dev/null +++ b/test/kuttl/tests/application-credentials/07-simulate-keystone-operator.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/08-assert-mcp-credentials.yaml b/test/kuttl/tests/application-credentials/08-assert-mcp-credentials.yaml new file mode 100644 index 00000000..38230e70 --- /dev/null +++ b/test/kuttl/tests/application-credentials/08-assert-mcp-credentials.yaml @@ -0,0 +1,84 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + echo "Waiting for openstack-config ConfigMap with AC credentials..." + for i in $(seq 1 60); do + CLOUDS=$(oc get configmap openstack-config -n openstack-lightspeed \ + -o jsonpath='{.data.clouds\.yaml}' 2>/dev/null || true) + if echo "$CLOUDS" | grep -q "v3applicationcredential"; then + echo "Found v3applicationcredential auth type" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: openstack-config ConfigMap not updated with AC credentials after 120s" + echo "Current clouds.yaml content:" + echo "$CLOUDS" + exit 1 + fi + sleep 2 + done + + # Verify AC ID in clouds.yaml + if ! echo "$CLOUDS" | grep -q "application_credential_id: mock-ac-id-12345"; then + echo "ERROR: clouds.yaml missing application_credential_id" + echo "$CLOUDS" + exit 1 + fi + echo "clouds.yaml has correct application_credential_id" + + # Verify auth_url in clouds.yaml + if ! echo "$CLOUDS" | grep -q "auth_url: http://mock-keystone-server.openstack.svc:5000/v3"; then + echo "ERROR: clouds.yaml missing correct auth_url" + echo "$CLOUDS" + exit 1 + fi + echo "clouds.yaml has correct auth_url" + + # Verify openstack-config-secret Secret + SECURE=$(oc get secret openstack-config-secret -n openstack-lightspeed \ + -o jsonpath='{.data.secure\.yaml}' | base64 -d) + if ! echo "$SECURE" | grep -q "application_credential_secret: mock-ac-secret-abcde"; then + echo "ERROR: secure.yaml missing application_credential_secret" + echo "$SECURE" + exit 1 + fi + echo "secure.yaml has correct application_credential_secret" + + # Verify MCP config has OpenStack enabled + MCP_CFG=$(oc get configmap mcp-config -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + if ! echo "$MCP_CFG" | grep -qP "openstack:\s*\n\s+enabled:\s+true"; then + # Try with a simpler grep + ENABLED=$(echo "$MCP_CFG" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$ENABLED" != "enabled:true" ]; then + echo "ERROR: MCP config does not have openstack enabled" + echo "$MCP_CFG" + exit 1 + fi + fi + echo "MCP config has OpenStack enabled" + + # Verify combined-ca-bundle secret was copied + if ! oc get secret combined-ca-bundle -n openstack-lightspeed 2>/dev/null; then + echo "ERROR: combined-ca-bundle secret not found in openstack-lightspeed namespace" + exit 1 + fi + echo "CA bundle secret copied to openstack-lightspeed namespace" + + # Verify AC secret has the finalizer + FINALIZERS=$(oc get secret ac-lightspeed-test-secret -n openstack \ + -o jsonpath='{.metadata.finalizers}') + if ! echo "$FINALIZERS" | grep -q "openstack.org/lightspeed-ac-consumer"; then + echo "ERROR: AC secret missing finalizer" + echo "Finalizers: $FINALIZERS" + exit 1 + fi + echo "AC secret has lightspeed-ac-consumer finalizer" + + echo "All MCP credential assertions passed" + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/09-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/09-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..6b2075b0 --- /dev/null +++ b/test/kuttl/tests/application-credentials/09-cleanup-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/cleanup-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/10-assert-ac-cleanup.yaml b/test/kuttl/tests/application-credentials/10-assert-ac-cleanup.yaml new file mode 100644 index 00000000..cb04e23c --- /dev/null +++ b/test/kuttl/tests/application-credentials/10-assert-ac-cleanup.yaml @@ -0,0 +1,50 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + echo "Waiting for AC CR to be deleted..." + for i in $(seq 1 60); do + if ! oc get keystoneapplicationcredentials.keystone.openstack.org lightspeed \ + -n openstack 2>/dev/null; then + echo "AC CR deleted" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: AC CR still exists after 120s" + exit 1 + fi + sleep 2 + done + + echo "Waiting for password secret to be deleted..." + for i in $(seq 1 30); do + if ! oc get secret lightspeed-password -n openstack 2>/dev/null; then + echo "Password secret deleted" + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Password secret still exists after 60s" + exit 1 + fi + sleep 2 + done + + # Verify finalizer removed from AC secret + if oc get secret ac-lightspeed-test-secret -n openstack 2>/dev/null; then + FINALIZERS=$(oc get secret ac-lightspeed-test-secret -n openstack \ + -o jsonpath='{.metadata.finalizers}' 2>/dev/null || true) + if echo "$FINALIZERS" | grep -q "openstack.org/lightspeed-ac-consumer"; then + echo "ERROR: AC secret still has lightspeed-ac-consumer finalizer" + exit 1 + fi + echo "AC secret finalizer removed" + else + echo "AC secret already deleted (OK)" + fi + + echo "AC cleanup verified" + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/11-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/11-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..81472440 --- /dev/null +++ b/test/kuttl/tests/application-credentials/11-errors-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/12-cleanup-mock-openstack.yaml b/test/kuttl/tests/application-credentials/12-cleanup-mock-openstack.yaml new file mode 100644 index 00000000..84a00236 --- /dev/null +++ b/test/kuttl/tests/application-credentials/12-cleanup-mock-openstack.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + #!/bin/bash + set -euo pipefail + oc delete openstackcontrolplane openstack-galera-network-isolation -n openstack --ignore-not-found=true + oc delete pod mock-keystone-server -n openstack --ignore-not-found=true + oc delete svc mock-keystone-server -n openstack --ignore-not-found=true + oc delete configmap mock-keystone-code openstack-config-mock -n openstack --ignore-not-found=true + oc delete secret openstack-config-secret-mock combined-ca-bundle-mock ac-lightspeed-test-secret -n openstack --ignore-not-found=true + oc delete namespace openstack --ignore-not-found=true --timeout=60s + oc delete crd openstackcontrolplanes.core.openstack.org keystoneapplicationcredentials.keystone.openstack.org --ignore-not-found=true + timeout: 120 diff --git a/test/kuttl/tests/application-credentials/13-assert-mock-openstack.yaml b/test/kuttl/tests/application-credentials/13-assert-mock-openstack.yaml new file mode 100644 index 00000000..64c59777 --- /dev/null +++ b/test/kuttl/tests/application-credentials/13-assert-mock-openstack.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +commands: + - script: | + #!/bin/bash + set -euo pipefail + ! oc get pod mock-keystone-server -n openstack 2>/dev/null + ! oc get svc mock-keystone-server -n openstack 2>/dev/null + ! oc get configmap mock-keystone-code -n openstack 2>/dev/null + ! oc get configmap openstack-config-mock -n openstack 2>/dev/null + ! oc get secret openstack-config-secret-mock -n openstack 2>/dev/null + ! oc get secret combined-ca-bundle-mock -n openstack 2>/dev/null + ! oc get namespace openstack 2>/dev/null + ! oc get crd openstackcontrolplanes.core.openstack.org 2>/dev/null + ! oc get crd keystoneapplicationcredentials.keystone.openstack.org 2>/dev/null diff --git a/test/kuttl/tests/application-credentials/14-cleanup-mock-objects.yaml b/test/kuttl/tests/application-credentials/14-cleanup-mock-objects.yaml new file mode 120000 index 00000000..410c9278 --- /dev/null +++ b/test/kuttl/tests/application-credentials/14-cleanup-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/cleanup-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/15-errors-mock-objects.yaml b/test/kuttl/tests/application-credentials/15-errors-mock-objects.yaml new file mode 120000 index 00000000..696a5e26 --- /dev/null +++ b/test/kuttl/tests/application-credentials/15-errors-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/errors-mock-objects.yaml \ No newline at end of file From de97c9c0d25f274854c3bae176d55aa33cafe233 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Thu, 18 Jun 2026 11:05:43 +0200 Subject: [PATCH 5/8] Add kuttl test artifacts to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 52b6e27f..f939691f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ go.work *.swp *.swo *~ + +# Kuttl test files +kubeconfig +kuttl-report-openstack-lightspeed.xml From 19bfc7790c7c9d4ff0dd6fb250edd895368b40ad Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 1 Jun 2026 12:29:54 +0200 Subject: [PATCH 6/8] Add ocp-deploy and ocp-deploy-cleanup targets --- Makefile | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 07fef3b4..0e3c55bc 100644 --- a/Makefile +++ b/Makefile @@ -219,10 +219,12 @@ openstack-lightspeed-deploy: ## Deploy using a catalog image. oc apply -f $(OUTPUT_DIR)/rhosls bash scripts/confirm-rhosls-running.sh -# Deploy using the catalog image. +# Undeploy using the catalog image. +# Remove OpenStackLightspeds so the namespace deletion doesn't get stuck .PHONY: openstack-lightspeed-undeploy openstack-lightspeed-undeploy: export OUTPUT_DIR = out openstack-lightspeed-undeploy: ## Undeploy using a catalog image. + oc delete openstacklightspeed --all -n openstack-lightspeed --ignore-not-found=true --timeout=120s find out/{catalog,rhosls} -name "*.yaml" -printf " -f %p" | xargs oc delete --ignore-not-found=true CATALOG_NAME ?= openstack-lightspeed-catalog @@ -299,6 +301,20 @@ kuttl-test-ocp: BUNDLE_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/oper kuttl-test-ocp: CATALOG_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-catalog:$(TAG) kuttl-test-ocp: docker-build bundle bundle-build ocp-catalog-build ocp-registry-push kuttl-test-run +.PHONY: ocp-deploy +ocp-deploy: IMG = $(OCP_INTERNAL_REGISTRY)/$(OCP_REGISTRY_NAMESPACE)/operator:latest +ocp-deploy: BUNDLE_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-bundle:$(TAG) +ocp-deploy: CATALOG_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-catalog:$(TAG) +ocp-deploy: docker-build bundle bundle-build ocp-catalog-build ocp-registry-push openstack-lightspeed-deploy + +.PHONY: ocp-deploy-cleanup +ocp-deploy-cleanup: IMG = $(OCP_INTERNAL_REGISTRY)/$(OCP_REGISTRY_NAMESPACE)/operator:latest +ocp-deploy-cleanup: BUNDLE_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-bundle:$(TAG) +ocp-deploy-cleanup: CATALOG_IMG = $(OCP_INTERNAL_REGISTRY)/openshift-marketplace/operator-catalog:$(TAG) +ocp-deploy-cleanup: openstack-lightspeed-undeploy ## Clean up everything created by ocp-deploy. + oc delete imagestreamtag operator-catalog:$(TAG) -n openshift-marketplace --ignore-not-found=true + oc delete namespace $(OCP_REGISTRY_NAMESPACE) --ignore-not-found=true --wait + # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary # $2 - package url which can be installed From 8f895e540c55e78e67d1d741df39fa5c5ff29498 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 17 Jun 2026 15:53:07 +0200 Subject: [PATCH 7/8] Add GOMAXPROCS support when building Right now when we build the container (make docker-build) or the controller (make build) the go compiler will try to use all the CPUs on the machine. This can become problematic in some systems, so we add support for the `GOMAXPROCS` env var to be passed to those 2 make targets. --- Dockerfile | 3 ++- Makefile | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7366762e..0fd573f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM golang:1.24 AS builder ARG TARGETOS ARG TARGETARCH +ARG GOMAXPROCS WORKDIR /workspace # Copy the Go Modules manifests @@ -21,7 +22,7 @@ COPY internal/controller/ internal/controller/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN GOMAXPROCS=${GOMAXPROCS} CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Makefile b/Makefile index 0e3c55bc..041a083d 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go + GOMAXPROCS=$(GOMAXPROCS) go build -o bin/manager cmd/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. @@ -157,7 +157,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build --build-arg GOMAXPROCS=$(GOMAXPROCS) -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. From 72ceff8b7508f98ce7408f81b1a0a1c2491880a1 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Fri, 12 Jun 2026 23:46:27 +0200 Subject: [PATCH 8/8] Support changing all the images Currently we can only change the image for the RAG using the `ragImage` field in our CR, but we have many other images subject to change. In this patch we add functionality to change the images used by the operator for the following: - lighspeed-core - exporter - postgres - console image - OKP image - RHOS MCP server To provide a consistent interface we can now set all the images under the `images` field and we remove the top level `ragImage` field (now available under `images`) for consistency. Removing the field should not be a problem since we don't have a stable CRD yet. Co-Authored-By: Claude Opus 4.6 --- api/v1beta1/openstacklightspeed_types.go | 97 +++++++++---- api/v1beta1/openstacklightspeed_types_test.go | 132 ++++++++++++++++++ api/v1beta1/zz_generated.deepcopy.go | 17 +++ ...ed.openstack.org_openstacklightspeeds.yaml | 26 +++- ...ed.openstack.org_openstacklightspeeds.yaml | 26 +++- .../api_v1beta1_openstacklightspeed.yaml | 10 ++ internal/controller/common.go | 35 +++-- internal/controller/lcore_config.go | 32 ++--- internal/controller/lcore_deployment.go | 23 ++- internal/controller/lcore_reconciler.go | 2 +- internal/controller/llama_stack_config.go | 28 ++-- internal/controller/ocp_version.go | 4 +- internal/controller/ocp_version_test.go | 6 +- internal/controller/okp_reconciler.go | 2 +- .../openstacklightspeed_controller.go | 32 ++--- .../tests/dev-defaults/00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + ...-create-openstack-lightspeed-instance.yaml | 21 +++ .../03-assert-dev-defaults-config.yaml | 29 ++++ .../dev-defaults/03-assert-dev-defaults.yaml | 13 ++ .../04-update-remove-dev-defaults.yaml | 8 ++ .../05-assert-defaults-reverted.yaml | 28 ++++ ...cleanup-openstack-lightspeed-instance.yaml | 1 + ...-errors-openstack-lightspeed-instance.yaml | 1 + .../dev-defaults/08-cleanup-mock-objects.yaml | 1 + .../dev-defaults/09-errors-mock-objects.yaml | 1 + 26 files changed, 457 insertions(+), 120 deletions(-) create mode 100644 api/v1beta1/openstacklightspeed_types_test.go create mode 120000 test/kuttl/tests/dev-defaults/00-mock-resources.yaml create mode 120000 test/kuttl/tests/dev-defaults/01-assert-mock-objects-created.yaml create mode 100644 test/kuttl/tests/dev-defaults/02-create-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/dev-defaults/03-assert-dev-defaults-config.yaml create mode 100644 test/kuttl/tests/dev-defaults/03-assert-dev-defaults.yaml create mode 100644 test/kuttl/tests/dev-defaults/04-update-remove-dev-defaults.yaml create mode 100644 test/kuttl/tests/dev-defaults/05-assert-defaults-reverted.yaml create mode 120000 test/kuttl/tests/dev-defaults/06-cleanup-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/dev-defaults/07-errors-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/dev-defaults/08-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/dev-defaults/09-errors-mock-objects.yaml diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 114a14b2..5fb3300f 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -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" @@ -99,8 +101,9 @@ type OpenStackLightspeedSpec struct { OpenStackLightspeedCore `json:",inline"` // +kubebuilder:validation:Optional - // ContainerImage for the OpenStack Lightspeed RAG container (will be set to environmental default if empty) - RAGImage string `json:"ragImage"` + // 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 // +kubebuilder:default=false @@ -289,42 +292,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 - MCPServerImageURL 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), - MCPServerImageURL: util.GetEnvVar( - "RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT", MCPServerContainerImage), + 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 } diff --git a/api/v1beta1/openstacklightspeed_types_test.go b/api/v1beta1/openstacklightspeed_types_test.go new file mode 100644 index 00000000..b8a0a8f7 --- /dev/null +++ b/api/v1beta1/openstacklightspeed_types_test.go @@ -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) + } +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index c5d61159..a55477cc 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -142,6 +142,7 @@ func (in *OpenStackLightspeedCore) DeepCopy() *OpenStackLightspeedCore { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackLightspeedDefaults) DeepCopyInto(out *OpenStackLightspeedDefaults) { *out = *in + out.OpenStackLightspeedImages = in.OpenStackLightspeedImages } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackLightspeedDefaults. @@ -154,6 +155,21 @@ func (in *OpenStackLightspeedDefaults) DeepCopy() *OpenStackLightspeedDefaults { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenStackLightspeedImages) DeepCopyInto(out *OpenStackLightspeedImages) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackLightspeedImages. +func (in *OpenStackLightspeedImages) DeepCopy() *OpenStackLightspeedImages { + if in == nil { + return nil + } + out := new(OpenStackLightspeedImages) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackLightspeedList) DeepCopyInto(out *OpenStackLightspeedList) { *out = *in @@ -190,6 +206,7 @@ func (in *OpenStackLightspeedList) DeepCopyObject() runtime.Object { func (in *OpenStackLightspeedSpec) DeepCopyInto(out *OpenStackLightspeedSpec) { *out = *in out.OpenStackLightspeedCore = in.OpenStackLightspeedCore + out.Images = in.Images if in.Database != nil { in, out := &in.Database, &out.Database *out = new(DatabaseSpec) diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index d226d9ff..961d2c00 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -83,6 +83,28 @@ spec: feedbackDisabled: description: Disable feedback collection type: boolean + images: + description: |- + Images configures container images used by the operator. + When omitted, each image defaults to its environment variable or hardcoded fallback. + properties: + consoleImage: + type: string + consoleImagePF5: + type: string + exporterImage: + type: string + lcoreImage: + type: string + mcpServerImage: + type: string + okpImage: + type: string + postgresImage: + type: string + ragImage: + type: string + type: object llmAPIVersion: description: LLM API Version for LLM providers that require it (e.g., Microsoft Azure OpenAI) @@ -180,10 +202,6 @@ spec: When false, uses reference_url (online). type: boolean type: object - ragImage: - description: ContainerImage for the OpenStack Lightspeed RAG container - (will be set to environmental default if empty) - type: string tlsCACertBundle: description: Configmap name containing a CA Certificates bundle type: string diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index 02be1c36..73d560d4 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -83,6 +83,28 @@ spec: feedbackDisabled: description: Disable feedback collection type: boolean + images: + description: |- + Images configures container images used by the operator. + When omitted, each image defaults to its environment variable or hardcoded fallback. + properties: + consoleImage: + type: string + consoleImagePF5: + type: string + exporterImage: + type: string + lcoreImage: + type: string + mcpServerImage: + type: string + okpImage: + type: string + postgresImage: + type: string + ragImage: + type: string + type: object llmAPIVersion: description: LLM API Version for LLM providers that require it (e.g., Microsoft Azure OpenAI) @@ -180,10 +202,6 @@ spec: When false, uses reference_url (online). type: boolean type: object - ragImage: - description: ContainerImage for the OpenStack Lightspeed RAG container - (will be set to environmental default if empty) - type: string tlsCACertBundle: description: Configmap name containing a CA Certificates bundle type: string diff --git a/config/samples/api_v1beta1_openstacklightspeed.yaml b/config/samples/api_v1beta1_openstacklightspeed.yaml index 1d207de5..cdb7c3ec 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -27,3 +27,13 @@ spec: # - okp # okpChunkFilterQuery: "product:(*openstack* OR *openshift*)" # okpRagOnly: true + # Uncomment to customize container images: + # images: + # ragImage: "quay.io/openstack-lightspeed/rag-content:custom" + # lcoreImage: "quay.io/lightspeed-core/lightspeed-stack:custom" + # exporterImage: "quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:custom" + # postgresImage: "registry.redhat.io/rhel9/postgresql-16:custom" + # consoleImage: "registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:custom" + # consoleImagePF5: "registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:custom" + # okpImage: "registry.redhat.io/offline-knowledge-portal/rhokp-rhel9:custom" + # mcpServerImage: "quay.io/openstack-lightspeed/rhos-mcps:custom" diff --git a/internal/controller/common.go b/internal/controller/common.go index deb2535f..9cb8f01e 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -165,29 +165,38 @@ func parseDevConfig(instance *apiv1beta1.OpenStackLightspeed) (apiv1beta1.DevSpe } // isOKPEnabled returns true if the "okp" feature flag is present in the dev config. -func isOKPEnabled(instance *apiv1beta1.OpenStackLightspeed) bool { - config, _ := parseDevConfig(instance) - return slices.Contains(config.FeatureFlags, "okp") +func isOKPEnabled(devConfig apiv1beta1.DevSpec) bool { + return slices.Contains(devConfig.FeatureFlags, "okp") } // isRHOSMCPEnabled returns true if the "rhos_mcps" feature flag is present in the dev config. -func isRHOSMCPEnabled(instance *apiv1beta1.OpenStackLightspeed) (bool, error) { - config, err := parseDevConfig(instance) - if err != nil { - return false, err - } - return slices.Contains(config.FeatureFlags, "rhos_mcps"), nil +func isRHOSMCPEnabled(devConfig apiv1beta1.DevSpec) bool { + return slices.Contains(devConfig.FeatureFlags, "rhos_mcps") } // getOKPChunkFilterQuery returns the chunk filter query from the dev config, or the default. -func getOKPChunkFilterQuery(instance *apiv1beta1.OpenStackLightspeed) string { - config, _ := parseDevConfig(instance) - if config.OKPChunkFilterQuery != "" { - return config.OKPChunkFilterQuery +func getOKPChunkFilterQuery(devConfig apiv1beta1.DevSpec) string { + if devConfig.OKPChunkFilterQuery != "" { + return devConfig.OKPChunkFilterQuery } return OKPDefaultChunkFilterQuery } +// devConfigKey is the context key for storing the parsed DevSpec. +type devConfigKey struct{} + +// contextWithDevConfig returns a new context carrying the given DevSpec. +func contextWithDevConfig(ctx context.Context, devConfig apiv1beta1.DevSpec) context.Context { + return context.WithValue(ctx, devConfigKey{}, devConfig) +} + +// devConfigFromContext extracts the DevSpec stored in ctx. +// Returns a zero-value DevSpec if none was stored. +func devConfigFromContext(ctx context.Context) apiv1beta1.DevSpec { + dc, _ := ctx.Value(devConfigKey{}).(apiv1beta1.DevSpec) + return dc +} + // getDeployment retrieves deployment from the cluster func getDeployment(ctx context.Context, h *common_helper.Helper, name string, namespace string) (*appsv1.Deployment, error) { deployment := &appsv1.Deployment{} diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index 8e29ce5e..8fa1988a 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" @@ -207,7 +208,7 @@ ingress_connection_timeout: 30 } } -func buildOKPConfig(instance *apiv1beta1.OpenStackLightspeed) map[string]interface{} { +func buildOKPConfig(instance *apiv1beta1.OpenStackLightspeed, devConfig apiv1beta1.DevSpec) map[string]interface{} { offline := true if instance.Spec.OKP != nil && instance.Spec.OKP.Offline != nil { offline = *instance.Spec.OKP.Offline @@ -217,7 +218,7 @@ func buildOKPConfig(instance *apiv1beta1.OpenStackLightspeed) map[string]interfa "rhokp_url": "${env.RH_SERVER_OKP}", "offline": offline, } - okpConfig["chunk_filter_query"] = getOKPChunkFilterQuery(instance) + okpConfig["chunk_filter_query"] = getOKPChunkFilterQuery(devConfig) return okpConfig } @@ -245,32 +246,27 @@ func buildLCoreMCPServersConfig(openStackReady bool) []interface{} { return mcpServers } -func buildLCoreMCPServersConfigIfEnabled(instance *apiv1beta1.OpenStackLightspeed) ([]interface{}, error) { - enabled, err := isRHOSMCPEnabled(instance) - if err != nil { - return nil, fmt.Errorf("failed to parse dev config: %w", err) - } - if !enabled { - return []interface{}{}, nil +func buildLCoreMCPServersConfigIfEnabled(instance *apiv1beta1.OpenStackLightspeed, devConfig apiv1beta1.DevSpec) []interface{} { + if !isRHOSMCPEnabled(devConfig) { + return []interface{}{} } - return buildLCoreMCPServersConfig(instance.Status.OpenStackReady), nil + return buildLCoreMCPServersConfig(instance.Status.OpenStackReady) } // buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML. // NOTE: quota handlers, and tools approval features are disabled for OpenStack Lightspeed. -func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { +func buildLCoreConfigYAML(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) (string, error) { + devConfig := devConfigFromContext(ctx) + ragInline := []interface{}{} - if isOKPEnabled(instance) { + if isOKPEnabled(devConfig) { ragInline = append(ragInline, "okp") } ragConfig := map[string]interface{}{ "inline": ragInline, } - mcpServers, err := buildLCoreMCPServersConfigIfEnabled(instance) - if err != nil { - return "", err - } + mcpServers := buildLCoreMCPServersConfigIfEnabled(instance, devConfig) // Build the complete config as a map config := map[string]interface{}{ @@ -288,8 +284,8 @@ func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStac "mcp_servers": mcpServers, } - if isOKPEnabled(instance) { - config["okp"] = buildOKPConfig(instance) + if isOKPEnabled(devConfig) { + config["okp"] = buildOKPConfig(instance, devConfig) } // Convert to YAML diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index b8a9a512..80c4e240 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -37,6 +37,8 @@ import ( // buildLCorePodTemplateSpec builds the pod template spec for the LCore deployment. // This function is used by CreateOrPatch to generate the desired pod spec. func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) (corev1.PodTemplateSpec, error) { + devConfig := devConfigFromContext(ctx) + // Build shared volumes volumes := []corev1.Volume{ buildOGXConfigVolume(VolumeDefaultMode), @@ -58,7 +60,7 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins if err != nil { return corev1.PodTemplateSpec{}, fmt.Errorf("failed to build llama-stack env vars: %w", err) } - lsEnvVars := buildLightspeedStackEnvVars(instance) + lsEnvVars := buildLightspeedStackEnvVars(instance, devConfig) // Llama Stack container mounts: its config + shared + cache + vector_store_db data llamaStackMounts := []corev1.VolumeMount{} @@ -182,11 +184,7 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins } // MCP sidecar (only when rhos_mcps feature flag is enabled) - rhosMCPEnabled, err := isRHOSMCPEnabled(instance) - if err != nil { - return corev1.PodTemplateSpec{}, fmt.Errorf("failed to parse dev config: %w", err) - } - if rhosMCPEnabled { + if isRHOSMCPEnabled(devConfig) { mcpMounts := []corev1.VolumeMount{} addMCPVolumesAndMounts(&volumes, &mcpMounts) @@ -271,7 +269,7 @@ func buildInitContainers( var containers []corev1.Container containers = append(containers, corev1.Container{ Name: "vector-database-collect", - Image: instance.Spec.RAGImage, + Image: instance.Spec.Images.RAGImageURL, Command: func() []string { cmd := []string{ "sh", VectorDBScriptsMountPath + "/" + VectorDBCollectScriptKey, @@ -279,7 +277,7 @@ func buildInitContainers( "--enable-ocp-rag", strconv.FormatBool(instance.Spec.EnableOCPRAG), "--ocp-version", ocp_version, } - if isOKPEnabled(instance) { + if isOKPEnabled(devConfigFromContext(ctx)) { cmd = append(cmd, "--enable-okp") } return cmd @@ -305,8 +303,7 @@ func buildInitContainers( "--ogx-config-path", OGXConfigInitContainerMountPath, "--lightspeed-stack-path", LightspeedStackInitContainerMountPath, } - devConfig, _ := parseDevConfig(instance) - if devConfig.OKPRagOnly { + if devConfigFromContext(ctx).OKPRagOnly { configBuildCmd = append(configBuildCmd, "--disable-rag-entries") } @@ -663,7 +660,7 @@ func buildLlamaStackEnvVars(h *common_helper.Helper, ctx context.Context, instan Value: VectorDBVolumeMountPath, }) - if isOKPEnabled(instance) { + if isOKPEnabled(devConfigFromContext(ctx)) { envVars = append(envVars, corev1.EnvVar{ Name: "RH_SERVER_OKP", Value: fmt.Sprintf("http://%s.%s.svc:%d", OKPServiceName, instance.GetNamespace(), OKPServicePort), @@ -689,7 +686,7 @@ func buildPostgresPasswordEnvVar() corev1.EnvVar { } // buildLightspeedStackEnvVars builds environment variables for the lightspeed-stack container. -func buildLightspeedStackEnvVars(instance *apiv1beta1.OpenStackLightspeed) []corev1.EnvVar { +func buildLightspeedStackEnvVars(instance *apiv1beta1.OpenStackLightspeed, devConfig apiv1beta1.DevSpec) []corev1.EnvVar { envVars := []corev1.EnvVar{ { Name: "LIGHTSPEED_STACK_LOG_LEVEL", @@ -697,7 +694,7 @@ func buildLightspeedStackEnvVars(instance *apiv1beta1.OpenStackLightspeed) []cor }, buildPostgresPasswordEnvVar(), } - if isOKPEnabled(instance) { + if isOKPEnabled(devConfig) { envVars = append(envVars, corev1.EnvVar{ Name: "RH_SERVER_OKP", Value: fmt.Sprintf("http://%s.%s.svc:%d", OKPServiceName, instance.GetNamespace(), OKPServicePort), diff --git a/internal/controller/lcore_reconciler.go b/internal/controller/lcore_reconciler.go index 71cc8197..e87f9beb 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(h, ctx, 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 cef8b8de..4fc5852e 100644 --- a/internal/controller/llama_stack_config.go +++ b/internal/controller/llama_stack_config.go @@ -237,16 +237,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, devConfig apiv1beta1.DevSpec) []interface{} { providers := buildLlamaStackVectorDB(h, instance) - if isOKPEnabled(instance) { - providers = append(providers, buildOKPVectorIOProvider(instance)) + if isOKPEnabled(devConfig) { + providers = append(providers, buildOKPVectorIOProvider(devConfig)) } return providers } -func buildOKPVectorIOProvider(instance *apiv1beta1.OpenStackLightspeed) map[string]interface{} { - chunkFilterQuery := "is_chunk:true AND " + getOKPChunkFilterQuery(instance) +func buildOKPVectorIOProvider(devConfig apiv1beta1.DevSpec) map[string]interface{} { + chunkFilterQuery := "is_chunk:true AND " + getOKPChunkFilterQuery(devConfig) return map[string]interface{}{ "provider_id": "okp_solr", @@ -335,7 +335,7 @@ func buildLlamaStackStorage(_ *common_helper.Helper, instance *apiv1beta1.OpenSt } } -func buildLlamaStackModels(_ *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) []interface{} { +func buildLlamaStackModels(_ *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed, devConfig apiv1beta1.DevSpec) []interface{} { models := []interface{}{} // Add LLM models from the instance spec { @@ -361,7 +361,7 @@ func buildLlamaStackModels(_ *common_helper.Helper, instance *apiv1beta1.OpenSta } } - if isOKPEnabled(instance) { + if isOKPEnabled(devConfig) { models = append(models, map[string]interface{}{ "model_id": "solr_embedding", "model_type": "embedding", @@ -376,9 +376,9 @@ func buildLlamaStackModels(_ *common_helper.Helper, instance *apiv1beta1.OpenSta return models } -func buildLlamaStackVectorStores(_ *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) []interface{} { +func buildLlamaStackVectorStores(_ *common_helper.Helper, devConfig apiv1beta1.DevSpec) []interface{} { stores := []interface{}{} - if isOKPEnabled(instance) { + if isOKPEnabled(devConfig) { stores = append(stores, map[string]interface{}{ "vector_store_id": "portal-rag", "provider_id": "okp_solr", @@ -400,6 +400,8 @@ func buildLlamaStackToolGroups(_ *common_helper.Helper, _ *apiv1beta1.OpenStackL // buildLlamaStackYAML assembles the complete Llama Stack configuration and converts to YAML func buildLlamaStackYAML(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) (string, error) { + devConfig := devConfigFromContext(ctx) + // Build the complete config as a map config := buildLlamaStackCoreConfig(h, instance) @@ -409,7 +411,7 @@ func buildLlamaStackYAML(h *common_helper.Helper, ctx context.Context, instance return "", fmt.Errorf("failed to build inference providers: %w", err) } - if isOKPEnabled(instance) { + if isOKPEnabled(devConfig) { config["external_providers_dir"] = ExternalProvidersDir } @@ -420,7 +422,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, devConfig), } // Add top-level fields @@ -431,8 +433,8 @@ func buildLlamaStackYAML(h *common_helper.Helper, ctx context.Context, instance "enabled": false, } config["registered_resources"] = map[string][]interface{}{ - "models": buildLlamaStackModels(h, instance), - "vector_stores": buildLlamaStackVectorStores(h, instance), + "models": buildLlamaStackModels(h, instance, devConfig), + "vector_stores": buildLlamaStackVectorStores(h, devConfig), "tool_groups": buildLlamaStackToolGroups(h, instance), } diff --git a/internal/controller/ocp_version.go b/internal/controller/ocp_version.go index 14f65bc0..b32f2c6d 100644 --- a/internal/controller/ocp_version.go +++ b/internal/controller/ocp_version.go @@ -250,7 +250,7 @@ func BuildRAGConfigs(instance *apiv1beta1.OpenStackLightspeed, ocpVersion string rags := []interface{}{ // OpenStack RAG map[string]interface{}{ - "image": instance.Spec.RAGImage, + "image": instance.Spec.Images.RAGImageURL, "indexPath": OpenStackLightspeedVectorDBPath, }, } @@ -258,7 +258,7 @@ func BuildRAGConfigs(instance *apiv1beta1.OpenStackLightspeed, ocpVersion string // Add OCP RAG if enabled if ocpVersion != "" { rags = append(rags, map[string]interface{}{ - "image": instance.Spec.RAGImage, + "image": instance.Spec.Images.RAGImageURL, "indexPath": GetOCPVectorDBPath(ocpVersion), "indexID": GetOCPIndexName(ocpVersion), }) diff --git a/internal/controller/ocp_version_test.go b/internal/controller/ocp_version_test.go index 2ad5378a..5bc0960d 100644 --- a/internal/controller/ocp_version_test.go +++ b/internal/controller/ocp_version_test.go @@ -237,7 +237,7 @@ func TestBuildRAGConfigs(t *testing.T) { t.Run("OCP RAG disabled (empty version)", func(t *testing.T) { instance := &apiv1beta1.OpenStackLightspeed{ Spec: apiv1beta1.OpenStackLightspeedSpec{ - RAGImage: testRAGImage, + Images: apiv1beta1.OpenStackLightspeedImages{RAGImageURL: testRAGImage}, }, } @@ -270,7 +270,7 @@ func TestBuildRAGConfigs(t *testing.T) { t.Run("OCP RAG enabled", func(t *testing.T) { instance := &apiv1beta1.OpenStackLightspeed{ Spec: apiv1beta1.OpenStackLightspeedSpec{ - RAGImage: testRAGImage, + Images: apiv1beta1.OpenStackLightspeedImages{RAGImageURL: testRAGImage}, }, } @@ -334,7 +334,7 @@ func TestBuildRAGConfigs(t *testing.T) { t.Run("OCP RAG with latest version", func(t *testing.T) { instance := &apiv1beta1.OpenStackLightspeed{ Spec: apiv1beta1.OpenStackLightspeedSpec{ - RAGImage: testRAGImage, + Images: apiv1beta1.OpenStackLightspeedImages{RAGImageURL: testRAGImage}, }, } diff --git a/internal/controller/okp_reconciler.go b/internal/controller/okp_reconciler.go index 9e9e9533..09fb9abf 100644 --- a/internal/controller/okp_reconciler.go +++ b/internal/controller/okp_reconciler.go @@ -34,7 +34,7 @@ import ( // ReconcileOKPDeployment reconciles the OKP Deployment and Service. // When OKP is disabled, it cleans up existing resources. func ReconcileOKPDeployment(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { - if !isOKPEnabled(instance) { + if !isOKPEnabled(devConfigFromContext(ctx)) { return cleanupOKPResources(h, ctx) } diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index cc1b5fc7..9e6fc084 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -203,26 +203,28 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } - if instance.Spec.RAGImage == "" { - instance.Spec.RAGImage = apiv1beta1.OpenStackLightspeedDefaultValues.RAGImageURL + devConfig, devErr := parseDevConfig(instance) + if devErr != nil { + Log.Error(devErr, "failed to parse dev config, ignoring dev overrides") } - if instance.Spec.MaxTokensForResponse == 0 { - instance.Spec.MaxTokensForResponse = apiv1beta1.OpenStackLightspeedDefaultValues.MaxTokensForResponse + defaults := apiv1beta1.MergeDefaults(&instance.Spec.Images) + // Set the global so that deployment builders (which read it directly) + // see the merged values for this reconcile cycle. + apiv1beta1.OpenStackLightspeedDefaultValues = defaults + ctx = contextWithDevConfig(ctx, devConfig) + + if instance.Spec.Images.RAGImageURL == "" { + instance.Spec.Images.RAGImageURL = defaults.RAGImageURL } - // Log dev config parse errors so misconfigurations don't silently disable features. - if _, err := parseDevConfig(instance); err != nil { - Log.Error(err, "failed to parse dev config, ignoring") + if instance.Spec.MaxTokensForResponse == 0 { + instance.Spec.MaxTokensForResponse = defaults.MaxTokensForResponse } // Reconcile MCP server before LCore resources, because its result // determines what goes into the lightspeed-stack config (mcp_servers section). - rhosMCPEnabled, err := isRHOSMCPEnabled(instance) - if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) - } - if rhosMCPEnabled { + if isRHOSMCPEnabled(devConfig) { openStackReady, mcpErr := r.ReconcileMCPServer(ctx, helper, instance) if mcpErr != nil { instance.Status.Conditions.Set(condition.FalseCondition( @@ -335,11 +337,7 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( // Mark MCP server condition based on readiness (only when RHOS MCP is enabled; // when disabled the condition was already set in Reconcile). - rhosMCPEnabled, err := isRHOSMCPEnabled(instance) - if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) - } - if rhosMCPEnabled { + if isRHOSMCPEnabled(devConfigFromContext(ctx)) { if instance.Status.OpenStackReady { instance.Status.Conditions.MarkTrue( apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, diff --git a/test/kuttl/tests/dev-defaults/00-mock-resources.yaml b/test/kuttl/tests/dev-defaults/00-mock-resources.yaml new file mode 120000 index 00000000..8235a1fd --- /dev/null +++ b/test/kuttl/tests/dev-defaults/00-mock-resources.yaml @@ -0,0 +1 @@ +../../common/mock-objects/mock-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dev-defaults/01-assert-mock-objects-created.yaml b/test/kuttl/tests/dev-defaults/01-assert-mock-objects-created.yaml new file mode 120000 index 00000000..07f977a1 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/01-assert-mock-objects-created.yaml @@ -0,0 +1 @@ +../../common/mock-objects/assert-mock-objects-created.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dev-defaults/02-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dev-defaults/02-create-openstack-lightspeed-instance.yaml new file mode 100644 index 00000000..4d55ef7b --- /dev/null +++ b/test/kuttl/tests/dev-defaults/02-create-openstack-lightspeed-instance.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + enableOCPRAG: false + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + maxTokensForResponse: 4096 diff --git a/test/kuttl/tests/dev-defaults/03-assert-dev-defaults-config.yaml b/test/kuttl/tests/dev-defaults/03-assert-dev-defaults-config.yaml new file mode 100644 index 00000000..487e0dea --- /dev/null +++ b/test/kuttl/tests/dev-defaults/03-assert-dev-defaults-config.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + oc wait --for=condition=Ready pod \ + -l app.kubernetes.io/name=openstack-lightspeed-app-server \ + -n openstack-lightspeed \ + --timeout=120s + + POD_NAME=$(oc get pods \ + -l app.kubernetes.io/name=openstack-lightspeed-app-server \ + -n openstack-lightspeed \ + -o jsonpath='{.items[0].metadata.name}') + + MAX_TOKENS=$(oc exec -i -n openstack-lightspeed "$POD_NAME" \ + -c llama-stack -- cat /vector-db-discovered-values/ogx_config.yaml | \ + grep 'max_tokens:' | head -1 | awk '{print $2}') + + if [ "$MAX_TOKENS" != "4096" ]; then + echo "ERROR: Expected max_tokens: 4096, got: $MAX_TOKENS" + exit 1 + fi + + echo "max_tokens correctly overridden to 4096 via dev defaults" + timeout: 180 diff --git a/test/kuttl/tests/dev-defaults/03-assert-dev-defaults.yaml b/test/kuttl/tests/dev-defaults/03-assert-dev-defaults.yaml new file mode 100644 index 00000000..06788f20 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/03-assert-dev-defaults.yaml @@ -0,0 +1,13 @@ +############################################################################## +# Assert deployment is ready and dev defaults override took effect # +############################################################################## +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-stack-deployment + namespace: openstack-lightspeed +status: + replicas: 1 + readyReplicas: 1 + availableReplicas: 1 diff --git a/test/kuttl/tests/dev-defaults/04-update-remove-dev-defaults.yaml b/test/kuttl/tests/dev-defaults/04-update-remove-dev-defaults.yaml new file mode 100644 index 00000000..ed362ca8 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/04-update-remove-dev-defaults.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + oc patch openstacklightspeed openstack-lightspeed -n openstack-lightspeed \ + --type=json -p='[{"op":"replace","path":"/spec/maxTokensForResponse","value":0}]' + timeout: 30 diff --git a/test/kuttl/tests/dev-defaults/05-assert-defaults-reverted.yaml b/test/kuttl/tests/dev-defaults/05-assert-defaults-reverted.yaml new file mode 100644 index 00000000..74cb8625 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/05-assert-defaults-reverted.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + # Poll until the ConfigMap has max_tokens: 2048. + # Check the ConfigMap directly rather than exec-ing into a pod to avoid + # rollout timing issues (old pod still Running during deployment rollout). + # The full chain (ConfigMap -> init container -> pod) is validated in step 03. + for i in $(seq 1 60); do + MAX_TOKENS=$(oc get configmap llama-stack-config -n openstack-lightspeed \ + -o jsonpath='{.data.ogx_config\.yaml}' 2>/dev/null | \ + grep 'max_tokens:' | head -1 | awk '{print $2}' || true) + + if [ "$MAX_TOKENS" = "2048" ]; then + echo "max_tokens correctly reverted to 2048" + exit 0 + fi + + sleep 2 + done + + echo "ERROR: Expected max_tokens: 2048 in ConfigMap, got: $MAX_TOKENS" + exit 1 + timeout: 180 diff --git a/test/kuttl/tests/dev-defaults/06-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dev-defaults/06-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..6b2075b0 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/06-cleanup-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/cleanup-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dev-defaults/07-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dev-defaults/07-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 00000000..81472440 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/07-errors-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dev-defaults/08-cleanup-mock-objects.yaml b/test/kuttl/tests/dev-defaults/08-cleanup-mock-objects.yaml new file mode 120000 index 00000000..410c9278 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/08-cleanup-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/cleanup-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dev-defaults/09-errors-mock-objects.yaml b/test/kuttl/tests/dev-defaults/09-errors-mock-objects.yaml new file mode 120000 index 00000000..696a5e26 --- /dev/null +++ b/test/kuttl/tests/dev-defaults/09-errors-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/errors-mock-objects.yaml \ No newline at end of file