From ed3173ca5a8e200836da8ea7e41d696889748c35 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Tue, 9 Jun 2026 17:49:13 +0200 Subject: [PATCH 1/4] 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 | 30 ++ cmd/main.go | 43 ++- ...ed.openstack.org_openstacklightspeeds.yaml | 5 + config/manager/manager.yaml | 2 + ...tspeed-operator.clusterserviceversion.yaml | 3 + config/rbac/role.yaml | 23 ++ go.mod | 2 +- hack/env.sh | 1 + .../assets/mcp_server_config.yaml.tmpl | 24 ++ internal/controller/common.go | 175 ++++++++- internal/controller/constants.go | 12 + internal/controller/lcore_config.go | 32 +- internal/controller/lcore_deployment.go | 117 ++++++ internal/controller/mcp_server.go | 358 ++++++++++++++++++ internal/controller/mcp_server_test.go | 103 +++++ .../openstacklightspeed_controller.go | 155 +++++++- .../openstacklightspeed_controller_test.go | 4 + internal/controller/postgres_reconciler.go | 1 - .../lightspeed-stack-update.yaml | 5 + .../expected-configs/lightspeed-stack.yaml | 5 + .../assert-lightspeed-stack-config.yaml | 115 ++++++ .../assert-mcp-config.yaml | 6 + .../assert-openstack-lightspeed-instance.yaml | 17 + .../errors-openstack-lightspeed-instance.yaml | 8 + .../03-assert-mcp-config.yaml | 1 + .../00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + .../02-assert-oscp-crd.yaml | 11 + .../02-create-oscp-crd.yaml | 21 + .../03-assert-mcp-config.yaml | 1 + ...-create-openstack-lightspeed-instance.yaml | 1 + ...-assert-openstack-lightspeed-instance.yaml | 43 +++ .../04-create-oscp-instance.yaml | 54 +++ ...-assert-openstack-lightspeed-instance.yaml | 67 ++++ .../05-delete-oscp-crd.yaml | 7 + .../06-assert-oscp-crd.yaml | 11 + .../06-recreate-oscp-crd.yaml | 21 + ...-assert-openstack-lightspeed-instance.yaml | 44 +++ .../07-recreate-oscp-instance.yaml | 15 + .../08-cleanup-oscp-crd.yaml | 19 + ...cleanup-openstack-lightspeed-instance.yaml | 1 + ...-errors-openstack-lightspeed-instance.yaml | 1 + .../11-cleanup-mock-objects.yaml | 1 + .../12-errors-mock-objects.yaml | 1 + ...-assert-openstack-lightspeed-instance.yaml | 2 + .../03-assert-mcp-config.yaml | 1 + .../08-assert-openstacklightspeed-update.yaml | 17 + .../11-assert-configmaps-update.yaml | 9 + 51 files changed, 1615 insertions(+), 12 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/dynamic-crd-watch-recovery/00-mock-resources.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/03-assert-mcp-config.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-mock-objects.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 7de488a..b812101 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -27,6 +27,10 @@ const ( // OpenShift Lightspeed Operator Status=True condition which indicates if OpenShift Lightspeed is installed and // operational and it can be used by OpenStack Lightspeed operator. OpenShiftLightspeedOperatorReadyCondition condition.Type = "OpenShiftLightspeedOperatorReady" + + // OpenStackLightspeedMCPServerReadyCondition is set to True when the MCP server + // deployment succeeds. False indicates a failure during MCP server deployment. + OpenStackLightspeedMCPServerReadyCondition condition.Type = "OpenStackLightspeedMCPServerReady" ) // Common Messages used by API objects. @@ -46,6 +50,15 @@ const ( // OpenShiftLightspeedOperatorReady OpenShiftLightspeedOperatorReady = "OpenShift Lightspeed operator is ready." + // OpenStackLightspeedMCPServerInitMessage + OpenStackLightspeedMCPServerInitMessage = "MCP server deployment has not resolved" + + // OpenStackLightspeedMCPServerDeployed + OpenStackLightspeedMCPServerDeployed = "MCP server is ready" + + // OpenStackLightspeedMCPServerWaitingOpenStack + OpenStackLightspeedMCPServerWaitingOpenStack = "MCP server deployed, waiting for OpenStackControlPlane to become ready" + // DeploymentCheckFailedMessage DeploymentCheckFailedMessage = "Failed to check deployment status: %s" diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 040ab30..951f067 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 ) @@ -202,6 +205,11 @@ type OpenStackLightspeedStatus struct { // ObservedGeneration - the most recent generation observed for this object. ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +optional + // OpenStackReady indicates whether an OpenStackControlPlane was detected and + // is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + OpenStackReady bool `json:"openStackReady,omitempty"` } // +kubebuilder:object:root=true @@ -226,6 +234,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}} @@ -265,6 +274,7 @@ type OpenStackLightspeedDefaults struct { ConsoleImageURL string ConsoleImagePF5URL string OKPImageURL string + MCPServerImageURL string MaxTokensForResponse int } @@ -288,6 +298,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 0a05a43..f020c6a 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -231,6 +231,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 cf7f1fb..8e41b9c 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,13 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - get - apiGroups: - "" resourceNames: @@ -170,6 +180,14 @@ spec: - secrets verbs: - get + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -190,6 +208,14 @@ spec: - patch - update - watch + - apiGroups: + - core.openstack.org + resources: + - openstackcontrolplanes + verbs: + - get + - list + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -305,6 +331,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: @@ -500,4 +528,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 6576d60..33b5d8a 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 } @@ -152,7 +158,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, @@ -182,9 +196,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) @@ -222,3 +241,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 1371ad0..ec60101 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -231,6 +231,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 ac2b535..3586390 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: diff --git a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml index 5d55817..f3eac11 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 613a800..2459fde 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,13 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - get - apiGroups: - "" resourceNames: @@ -12,6 +19,14 @@ rules: - secrets verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -32,6 +47,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 fc3b0b2..3ffb13d 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,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 @@ -99,7 +100,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/hack/env.sh b/hack/env.sh index 6d980da..4fcf4a1 100644 --- a/hack/env.sh +++ b/hack/env.sh @@ -9,4 +9,5 @@ export RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT="quay.io/openstack-l export RELATED_IMAGE_OKP_IMAGE_URL_DEFAULT="registry.redhat.io/offline-knowledge-portal/rhokp-rhel9:latest" export RELATED_IMAGE_CONSOLE_IMAGE_URL_DEFAULT="registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12" export RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT="registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12" +export RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT="quay.io/openstack-lightspeed/rhos-mcps:latest" export WATCH_NAMESPACE="openstack-lightspeed" 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 0000000..353d715 --- /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 cabde38..b682b0b 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -26,14 +26,21 @@ import ( "fmt" "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-k8s-operators/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. @@ -86,8 +93,13 @@ func getConfigMapResourceVersion(ctx context.Context, h *common_helper.Helper, n // 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 := h.GetClient().Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, 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) } @@ -200,3 +212,164 @@ func generateRandomString(secretLength int) (string, error) { randString := hex.EncodeToString(b) return randString[:secretLength], 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, + } +} + +// IsDynamicCRDWatched reports whether a controller-runtime watch is currently +// registered for the given GVK. The flag is reset to false when a CRD is +// removed and its broken informer is cleaned up. This does NOT indicate +// whether the CRD currently exists — use IsCRDEstablished for that. +func IsDynamicCRDWatched( + 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 +} + +// 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 +} + +// 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 2231f0f..0fc4524 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -161,6 +161,11 @@ const ( OpenAIProviderName = "openai" WatsonXProviderName = "watsonx" + // OpenStack Control Plane + OpenStackControlPlaneGroup = "core.openstack.org" + OpenStackControlPlaneVersion = "v1beta1" + OpenStackControlPlaneKind = "OpenStackControlPlane" + // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials EnvVarSuffixAPIKey = "_API_KEY" @@ -245,6 +250,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" // #nosec G101 -- annotation key, not a credential + CombinedCABundleSecretVersionAnnotation = "ols.openshift.io/combined-ca-bundle-secret-version" // #nosec G101 -- annotation key, not a credential // Volume Permissions // These constants define file permissions for volumes mounted in containers. @@ -317,6 +326,9 @@ const ( // that signs TLS certificates auto-provisioned for Services via the // service.beta.openshift.io/serving-cert-secret-name annotation. OpenShiftServiceCAConfigMap = "openshift-service-ca.crt" + + // OpenStackLightspeedChecksumAnnotation is the annotation key used to store the checksum of resources. + OpenStackLightspeedChecksumAnnotation = "openstack.org/checksum" ) // PostgreSQL Bootstrap Script - creates database, extensions, and schemas diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index d498444..1ff4bd8 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -33,6 +33,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 @@ -217,8 +222,32 @@ func buildOKPConfig(ctx context.Context, h *common_helper.Helper, instance *apiv } } +// 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(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { ragInline := []interface{}{"okp"} @@ -239,6 +268,7 @@ func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance "conversation_cache": buildLCoreConversationCacheConfig(h, instance), "byok_rag": []interface{}{}, "rag": ragConfig, + "mcp_servers": buildLCoreMCPServersConfig(instance.Status.OpenStackReady), } config["okp"] = buildOKPConfig(ctx, h, instance) diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index f51dd20..5cd48fd 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -205,6 +205,27 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } + // MCP sidecar + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts, instance.Status.OpenStackReady) + + 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 { @@ -433,6 +454,66 @@ func addDataCollectorVolumes(volumes *[]corev1.Volume, volumeDefaultMode int32) }) } +// addMCPVolumesAndMounts adds MCP sidecar volumes and mounts. +// OpenStack-specific volumes (clouds.yaml, secure.yaml, tls-ca-bundle.pem) are +// only added when openStackReady is true, since they only exist after being +// copied from the OpenStackControlPlane namespace. +func addMCPVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount, openStackReady bool) { + if openStackReady { + *volumes = append(*volumes, + corev1.Volume{ + Name: SecureYAMLSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: SecureYAMLSecretName, + Items: []corev1.KeyToPath{{Key: "secure.yaml", Path: "secure.yaml"}}, + }, + }, + }, + corev1.Volume{ + Name: CloudsYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: CloudsYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "clouds.yaml", Path: "clouds.yaml"}}, + }, + }, + }, + corev1.Volume{ + Name: CombinedCABundleSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: CombinedCABundleSecretName, + Items: []corev1.KeyToPath{{Key: "tls-ca-bundle.pem", Path: "tls-ca-bundle.pem"}}, + }, + }, + }, + ) + + *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}, + ) + } + + *volumes = append(*volumes, + 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: 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. @@ -760,5 +841,41 @@ func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context) (ma annotations[PostgresSecretResourceVersionAnnotation] = postgresSecretVersion } + 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 0000000..13c567a --- /dev/null +++ b/internal/controller/mcp_server.go @@ -0,0 +1,358 @@ +/* +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-k8s-operators/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" // #nosec G101 -- Kubernetes Secret resource name, not a credential + + // CombinedCABundleSecretName is the name of the Secret containing the TLS CA bundle. + CombinedCABundleSecretName string = "combined-ca-bundle" // #nosec G101 -- Kubernetes Secret resource name, not a credential + + // 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 := IsDynamicCRDWatched(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) + } + + // The watch is registered, but the CRD may have been removed since. + crdEstablished, err := IsCRDEstablished(ctx, helper, OpenStackControlPlaneGVK()) + if err != nil { + return false, err + } + if !crdEstablished { + helper.GetLogger().Info("OpenStackControlPlane CRD was removed, 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 0000000..c9c9f52 --- /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 5556164..8caed34 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,25 +29,45 @@ 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" + "k8s.io/apimachinery/pkg/runtime/schema" "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-k8s-operators/lightspeed-operator/api/v1beta1" ) +// DynamicWatchCRD maps GroupVersionKinds to a flag indicating whether a +// controller-runtime watch is currently registered. The flag is set to true +// when a watch is registered and reset to false when the CRD is removed and +// the broken informer is cleaned up via cache.RemoveInformer. CRDs in this +// map do not need to exist at operator startup. +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 @@ -63,6 +84,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 +// +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 @@ -73,7 +98,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") @@ -98,6 +123,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() @@ -129,6 +159,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( @@ -137,6 +175,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) @@ -162,6 +205,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}, @@ -249,6 +309,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, @@ -261,7 +334,8 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( // SetupWithManager sets up the controller with the Manager. func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error { - 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{}). @@ -282,7 +356,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 @@ -338,3 +422,64 @@ func (r *OpenStackLightspeedReconciler) NotifyAllOpenStackLightspeeds(ctx contex return requests } + +// WatchDynamicCRD dynamically registers watches for resources whose CRDs are +// listed in r.DynamicWatchCRD. When a CRD is detected as established, it +// registers a watch and sets the flag to true. When a previously-watched CRD +// is removed, it cleans up the broken informer via cache.RemoveInformer and +// resets the flag to false. The next reconcile after CRD recreation will +// register a fresh watch. +func (r *OpenStackLightspeedReconciler) WatchDynamicCRD( + ctx context.Context, + helper *common_helper.Helper, +) error { + Log := r.GetLogger(ctx) + + for gvk, seen := range r.DynamicWatchCRD { + crdAvailable, err := IsCRDEstablished(ctx, helper, gvk) + if err != nil { + return err + } + + if seen.Load() { + if crdAvailable { + continue + } + + // CRD removed — clean up the broken informer so a fresh one + // can be created when the CRD reappears. + Log.Info("CRD removed, cleaning up stale informer", "crd", GetCRDName(gvk)) + obj := &uns.Unstructured{} + obj.SetGroupVersionKind(gvk) + if err := r.Cache.RemoveInformer(ctx, obj); err != nil { + return fmt.Errorf("failed to remove informer for %s: %w", GetCRDName(gvk), err) + } + seen.Store(false) + continue + } + + 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 feec7c6..36366bc 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 5adfb93..75e53a8 100644 --- a/internal/controller/postgres_reconciler.go +++ b/internal/controller/postgres_reconciler.go @@ -99,7 +99,6 @@ func reconcilePostgresBootstrapSecret(h *common_helper.Helper, ctx context.Conte PostgresBootstrapScript: PostgresBootStrapScriptContent, PostgresBootstrapSQLScript: PostgresBootStrapSQLContent, } - // Set owner reference return controllerutil.SetControllerReference(h.GetBeforeObject(), secret, h.GetScheme()) }) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml index 78dc28c..e75da12 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml @@ -71,6 +71,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.yaml b/test/kuttl/common/expected-configs/lightspeed-stack.yaml index d9c3db8..3230c91 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack.yaml @@ -71,6 +71,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/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 0000000..d672e39 --- /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/pki/ca-trust/extracted/pem/tls-ca-bundle.pem + db: lightspeed-stack + gss_encmode: disable + host: lightspeed-postgres-server.openstack-lightspeed.svc + namespace: conversation_cache + password: ${env.POSTGRESQL_PASSWORD} + port: 5432 + ssl_mode: verify-full + user: ${env.POSTGRESQL_USER} + 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/pki/ca-trust/extracted/pem/tls-ca-bundle.pem + db: lightspeed-stack + gss_encmode: disable + host: lightspeed-postgres-server.openstack-lightspeed.svc + namespace: lcore + password: ${env.POSTGRESQL_PASSWORD} + port: 5432 + ssl_mode: verify-full + user: ${env.POSTGRESQL_USER} + 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: false + 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 0000000..22fe537 --- /dev/null +++ b/test/kuttl/common/openstack-lightspeed-instance/assert-mcp-config.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed 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 f65abf2..86d3bc4 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 @@ -311,6 +311,7 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true + - name: rhos-mcp volumes: - name: ogx-config configMap: @@ -336,6 +337,12 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -355,6 +362,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 @@ -450,6 +465,8 @@ status: status: "True" reason: Ready message: Setup complete + - 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 e042da7..723d9ca 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 0000000..6c2d5ae --- /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/dynamic-crd-watch-recovery/00-mock-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/00-mock-resources.yaml new file mode 120000 index 0000000..8235a1f --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/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/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml new file mode 120000 index 0000000..07f977a --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/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/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml new file mode 100644 index 0000000..8809fce --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml new file mode 100644 index 0000000..0d164d0 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml @@ -0,0 +1,21 @@ +--- +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 + x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-assert-mcp-config.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-assert-mcp-config.yaml new file mode 120000 index 0000000..6c2d5ae --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/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/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..d08d582 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..e8af4f3 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,43 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + # Wait for OpenStackLightspeed to report OpenStack as ready + echo "Waiting for status.openStackReady=true..." + for i in $(seq 1 60); do + READY=$(oc get openstacklightspeed openstack-lightspeed \ + -n openstack-lightspeed \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$READY" = "true" ]; then + echo "OpenStackReady is true" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Timed out waiting for status.openStackReady=true" + oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml + exit 1 + fi + sleep 5 + done + + # Verify MCP config has OpenStack enabled + echo "Checking MCP config for openstack enabled..." + MCP_DATA=$(oc get configmap mcp-config \ + -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + + # The openstack section should have enabled: true + OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") + if ! echo "$OPENSTACK_LINE" | grep -q "true"; then + echo "ERROR: Expected openstack enabled: true in MCP config" + echo "MCP config:" + echo "$MCP_DATA" + exit 1 + fi + + echo "OK: OpenStack detected and MCP configured with OpenStack resources" + timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml new file mode 100644 index 0000000..a10aec9 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml @@ -0,0 +1,54 @@ +# Mock OpenStack resources that the operator copies from the OSCP namespace +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-config-secret + namespace: openstack-lightspeed +type: Opaque +stringData: + secure.yaml: | + clouds: + default: + auth: + password: test-password +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config-map + namespace: openstack-lightspeed +data: + clouds.yaml: | + clouds: + default: + auth: + auth_url: http://keystone.example.com:5000 +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-ca-bundle + namespace: openstack-lightspeed +type: Opaque +stringData: + tls-ca-bundle.pem: | + -----BEGIN CERTIFICATE----- + dGVzdC1jYS1jZXJ0aWZpY2F0ZQ== + -----END CERTIFICATE----- + +# OpenStackControlPlane instance with required fields +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: test-oscp + namespace: openstack-lightspeed +spec: + openstackclient: + template: + openStackConfigSecret: test-config-secret + openStackConfigMap: test-config-map +status: + tls: + caBundleSecretName: test-ca-bundle diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..e43c0c1 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,67 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + # Wait for the CRD to be fully removed + echo "Waiting for OpenStackControlPlane CRD to be deleted..." + for i in $(seq 1 30); do + if ! oc get crd openstackcontrolplanes.core.openstack.org 2>/dev/null; then + echo "CRD deleted" + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: CRD still exists after timeout" + exit 1 + fi + sleep 2 + done + + # Wait for the operator to reconcile and reset OpenStackReady + echo "Waiting for status.openStackReady to become false..." + for i in $(seq 1 60); do + READY=$(oc get openstacklightspeed openstack-lightspeed \ + -n openstack-lightspeed \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + # Empty or "false" both mean not ready (bool defaults to false when omitted) + if [ "$READY" != "true" ]; then + echo "OpenStackReady is false (value: '${READY}')" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Timed out waiting for status.openStackReady=false" + oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml + exit 1 + fi + sleep 5 + done + + # Verify MCP config has OpenStack disabled + echo "Checking MCP config for openstack disabled..." + MCP_DATA=$(oc get configmap mcp-config \ + -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + + OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") + if ! echo "$OPENSTACK_LINE" | grep -q "false"; then + echo "ERROR: Expected openstack enabled: false in MCP config" + echo "MCP config:" + echo "$MCP_DATA" + exit 1 + fi + + # Verify the operator is still healthy (Ready condition) + CONDITION=$(oc get openstacklightspeed openstack-lightspeed \ + -n openstack-lightspeed \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}') + if [ "$CONDITION" != "True" ]; then + echo "ERROR: Operator is not Ready after CRD removal" + oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml + exit 1 + fi + + echo "OK: Operator recovered from CRD removal, MCP running without OpenStack" + timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml new file mode 100644 index 0000000..8eb884a --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: openstackcontrolplanes.core.openstack.org diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml new file mode 100644 index 0000000..8809fce --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml new file mode 100644 index 0000000..0d164d0 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml @@ -0,0 +1,21 @@ +--- +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 + x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..9ffa1b0 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,44 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + + # This is the critical assertion: after CRD removal and recreation, + # the operator must re-register the watch and detect the new OSCP instance. + echo "Waiting for status.openStackReady=true after CRD recreation (proves watch recovery)..." + for i in $(seq 1 60); do + READY=$(oc get openstacklightspeed openstack-lightspeed \ + -n openstack-lightspeed \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$READY" = "true" ]; then + echo "OpenStackReady is true - watch recovery confirmed!" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: Timed out waiting for status.openStackReady=true" + echo "The operator failed to recover the watch after CRD recreation" + oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml + exit 1 + fi + sleep 5 + done + + # Verify MCP config has OpenStack enabled again + echo "Checking MCP config for openstack enabled..." + MCP_DATA=$(oc get configmap mcp-config \ + -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + + OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") + if ! echo "$OPENSTACK_LINE" | grep -q "true"; then + echo "ERROR: Expected openstack enabled: true in MCP config after recovery" + echo "MCP config:" + echo "$MCP_DATA" + exit 1 + fi + + echo "OK: Watch recovery verified - operator detected OpenStack after CRD recreation" + timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml new file mode 100644 index 0000000..c66a9ae --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml @@ -0,0 +1,15 @@ +# Recreate OpenStackControlPlane instance (mock resources still exist from step 04) +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: test-oscp + namespace: openstack-lightspeed +spec: + openstackclient: + template: + openStackConfigSecret: test-config-secret + openStackConfigMap: test-config-map +status: + tls: + caBundleSecretName: test-ca-bundle diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml new file mode 100644 index 0000000..8e1d33e --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: openstackcontrolplanes.core.openstack.org + - apiVersion: v1 + kind: Secret + name: test-config-secret + namespace: openstack-lightspeed + - apiVersion: v1 + kind: ConfigMap + name: test-config-map + namespace: openstack-lightspeed + - apiVersion: v1 + kind: Secret + name: test-ca-bundle + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/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/dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-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/dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/11-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/dynamic-crd-watch-recovery/12-errors-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/12-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/persistent-database/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml index d213ad1..ced9abd 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 @@ -44,6 +44,8 @@ status: status: "True" reason: Ready message: Setup complete + - 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 0000000..6c2d5ae --- /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 b2e3e3f..6063449 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -171,6 +171,7 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true + - name: rhos-mcp volumes: - name: ogx-config configMap: @@ -191,6 +192,12 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -219,6 +226,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 @@ -298,6 +313,8 @@ status: status: "True" reason: Ready message: Setup complete + - 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 e028d6b..6a57bae 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,21 @@ 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 + # Verify all operator-managed resources still exist after update --- apiVersion: apps/v1 From d3765fff975a88bdb9e03c2cbafaaeb6594bbc24 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 15 Jun 2026 11:33:20 +0200 Subject: [PATCH 2/4] 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 | 8 +- internal/controller/common.go | 10 +++ internal/controller/lcore_config.go | 18 ++++- internal/controller/lcore_deployment.go | 42 +++++----- internal/controller/mcp_server.go | 79 +++++++++++++++++++ .../openstacklightspeed_controller.go | 47 +++++------ .../lightspeed-stack-update.yaml | 6 +- .../expected-configs/lightspeed-stack.yaml | 6 +- .../assert-openstack-lightspeed-instance.yaml | 16 +--- ...-create-openstack-lightspeed-instance.yaml | 22 ++++++ .../03-assert-mcp-config.yaml | 1 - ...-create-openstack-lightspeed-instance.yaml | 23 +++++- .../00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + .../02-create-rhos-mcps-resources.yaml | 22 ++++++ .../03-assert-rhos-mcps-instance.yaml | 55 +++++++++++++ .../04-disable-rhos-mcps.yaml | 21 +++++ .../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 | 16 +--- .../11-assert-configmaps-update.yaml | 9 --- 26 files changed, 315 insertions(+), 102 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 mode change 120000 => 100644 test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.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 b812101..9079026 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -59,6 +59,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 ed262d0..a75a927 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -20,11 +20,13 @@ spec: # ogxLogLevel: "all=info" # lightspeedStackLogLevel: "INFO" # dataverseExporterLogLevel: "INFO" - # Uncomment to enable OKP (Offline Knowledge Portal) as an Inline RAG source: - # okp: - # accessKey: okp-access-key-secret + # Uncomment to enable RHOS MCPs (MCP server sidecar with OpenStack/OpenShift tools) + # and/or OKP (Offline Knowledge Portal) as an Inline RAG source: # dev: # featureFlags: + # - rhos_mcps # - okp # okpChunkFilterQuery: "product:(*openstack* OR *openshift*)" # okpRagOnly: true + # okp: + # accessKey: okp-access-key-secret diff --git a/internal/controller/common.go b/internal/controller/common.go index b682b0b..6cce7e3 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" common_cm "github.com/openstack-k8s-operators/lib-common/modules/common/configmap" @@ -165,6 +166,15 @@ func parseDevConfig(instance *apiv1beta1.OpenStackLightspeed) (apiv1beta1.DevSpe return config, nil } +// 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 a version-aware default. func getOKPChunkFilterQuery(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) string { config, _ := parseDevConfig(instance) diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index 1ff4bd8..bd8453d 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -246,6 +246,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(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { @@ -255,6 +266,11 @@ func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance "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)", @@ -268,7 +284,7 @@ func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance "conversation_cache": buildLCoreConversationCacheConfig(h, instance), "byok_rag": []interface{}{}, "rag": ragConfig, - "mcp_servers": buildLCoreMCPServersConfig(instance.Status.OpenStackReady), + "mcp_servers": mcpServers, } config["okp"] = buildOKPConfig(ctx, h, instance) diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 5cd48fd..90396c2 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -205,26 +205,32 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } - // MCP sidecar - mcpMounts := []corev1.VolumeMount{} - addMCPVolumesAndMounts(&volumes, &mcpMounts, instance.Status.OpenStackReady) - - 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, instance.Status.OpenStackReady) + + 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 13c567a..920c060 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -22,6 +22,7 @@ import ( "fmt" "text/template" + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" corev1 "k8s.io/api/core/v1" @@ -114,6 +115,38 @@ func GetMCPServerURL() string { // Reconciliation // --------------------------------------------------------------------------- +// ReconcileMCPServerTask reconciles the MCP server as a ReconcileFunc. +func (r *OpenStackLightspeedReconciler) ReconcileMCPServerTask(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { + rhosMCPEnabled, err := isRHOSMCPEnabled(instance) + if err != nil { + return fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosMCPEnabled { + openStackReady, mcpErr := r.ReconcileMCPServer(ctx, h, instance) + if mcpErr != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.DeploymentCheckFailedMessage, + mcpErr.Error(), + )) + return mcpErr + } + instance.Status.OpenStackReady = openStackReady + } else { + if err := r.cleanupMCPResources(ctx, h, instance); err != nil { + return err + } + instance.Status.OpenStackReady = false + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerDisabledMessage, + ) + } + return nil +} + // 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. @@ -301,6 +334,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 8caed34..b8ab757 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -205,24 +205,8 @@ 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: "MCPServer", Task: r.ReconcileMCPServerTask}, {Name: "PostgresResources", Task: ReconcilePostgresResources}, {Name: "PostgresDeployment", Task: ReconcilePostgresDeployment}, {Name: "OKPDeployment", Task: ReconcileOKPDeployment}, @@ -309,17 +293,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-update.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml index e75da12..1224e4a 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml @@ -71,11 +71,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.yaml b/test/kuttl/common/expected-configs/lightspeed-stack.yaml index 3230c91..2e2a6e3 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack.yaml @@ -71,11 +71,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/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml b/test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml index 86d3bc4..54553d5 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 @@ -311,7 +311,6 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true - - name: rhos-mcp volumes: - name: ogx-config configMap: @@ -337,12 +336,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -362,14 +355,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 @@ -467,6 +452,7 @@ status: message: Setup complete - 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 0000000..6042a8d --- /dev/null +++ b/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.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 + 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 6c2d5ae..0000000 --- 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/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml deleted file mode 120000 index d08d582..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml +++ /dev/null @@ -1 +0,0 @@ -../../common/openstack-lightspeed-instance/create-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..6042a8d --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.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 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhos_mcps 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 0000000..8235a1f --- /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 0000000..07f977a --- /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 0000000..6042a8d --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/02-create-rhos-mcps-resources.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 + 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 0000000..8a5638e --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/03-assert-rhos-mcps-instance.yaml @@ -0,0 +1,55 @@ +############################################################################## +# 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: 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: 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 0000000..bca96d8 --- /dev/null +++ b/test/kuttl/tests/rhos-mcps-configuration/04-disable-rhos-mcps.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 + 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 0000000..22fe537 --- /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 0000000..6b2075b --- /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 0000000..8147244 --- /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 0000000..410c927 --- /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 0000000..696a5e2 --- /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 6c2d5ae..0000000 --- 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 6063449..f9dcb9d 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -171,7 +171,6 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true - - name: rhos-mcp volumes: - name: ogx-config configMap: @@ -192,12 +191,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -226,14 +219,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 @@ -315,6 +300,7 @@ status: message: Setup complete - 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 6a57bae..e028d6b 100644 --- a/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/11-assert-configmaps-update.yaml @@ -18,21 +18,12 @@ 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 - # Verify all operator-managed resources still exist after update --- apiVersion: apps/v1 From bd2487c36b319ded530c44387aecf68787a68ef7 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Fri, 12 Jun 2026 13:26:17 +0200 Subject: [PATCH 3/4] 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 | 37 +- cmd/main.go | 5 + ...ed.openstack.org_openstacklightspeeds.yaml | 5 + config/rbac/role.yaml | 35 + go.mod | 5 +- go.sum | 12 + internal/controller/common.go | 9 + internal/controller/constants.go | 13 + internal/controller/lcore_deployment.go | 50 +- internal/controller/mcp_server.go | 654 ++++++++++++++++-- .../openstacklightspeed_controller.go | 54 +- .../openstacklightspeed_controller_test.go | 3 +- .../mock-openstack/assert-mock-openstack.yaml | 41 ++ .../cleanup-mock-openstack.yaml | 45 ++ .../mock-openstack/errors-mock-openstack.yaml | 51 ++ test/kuttl/common/mock-openstack/kac-crd.yaml | 29 + .../common/mock-openstack/mock-keystone.yaml | 144 ++++ .../mock-openstack/mock-oscp-resources.yaml | 86 +++ .../mock-openstack/oscp-crd.yaml} | 8 +- .../simulate-keystone-operator.sh | 47 ++ .../00-mock-resources.yaml | 1 + .../01-assert-mock-objects-created.yaml | 1 + .../02-assert-openstack-crds.yaml | 22 + .../02-create-kac-crd.yaml | 1 + .../02-create-oscp-crd.yaml | 1 + .../03-create-mock-keystone.yaml | 1 + .../03-create-mock-oscp-resources.yaml | 1 + .../04-assert-mock-openstack.yaml | 1 + .../06-assert-ac-resources.yaml | 32 + .../07-assert-intermediate-state.yaml | 29 + .../07-errors-intermediate-state.yaml | 22 + .../08-simulate-keystone-operator.yaml | 10 + .../09-assert-mcp-credentials.yaml | 53 ++ ...leanup-openstack-lightspeed-instance.yaml} | 0 .../11-assert-ac-cleanup.yaml | 17 + .../11-errors-ac-cleanup.yaml | 12 + ...errors-openstack-lightspeed-instance.yaml} | 0 .../13-cleanup-mock-openstack.yaml | 1 + .../14-errors-mock-openstack.yaml | 1 + .../15-cleanup-mock-objects.yaml} | 0 .../16-errors-mock-objects.yaml} | 0 .../02-assert-mock-openstack.yaml | 1 + .../02-assert-oscp-crd.yaml | 11 - .../02-create-mock-openstack.yaml | 17 + .../02-create-oscp-crd.yaml | 21 - ...-assert-openstack-lightspeed-instance.yaml | 46 +- .../04-create-oscp-instance.yaml | 54 -- .../04-simulate-keystone-operator.yaml | 10 + ...-assert-openstack-lightspeed-instance.yaml | 60 +- ...crd.yaml => 05-delete-openstack-crds.yaml} | 3 + .../05-errors-openstack-crds.yaml | 10 + .../06-assert-mock-openstack.yaml | 22 + .../06-assert-oscp-crd.yaml | 11 - .../06-recreate-mock-openstack.yaml | 19 + ...-assert-openstack-lightspeed-instance.yaml | 47 +- .../07-recreate-oscp-instance.yaml | 15 - .../07-simulate-keystone-operator.yaml | 10 + ...cleanup-openstack-lightspeed-instance.yaml | 1 + .../08-cleanup-oscp-crd.yaml | 19 - ...-errors-openstack-lightspeed-instance.yaml | 1 + .../10-cleanup-mock-openstack.yaml | 1 + .../11-errors-mock-openstack.yaml | 1 + .../12-cleanup-mock-objects.yaml | 1 + .../13-errors-mock-objects.yaml | 1 + 67 files changed, 1588 insertions(+), 349 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 rename test/kuttl/{tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml => common/mock-openstack/oscp-crd.yaml} (67%) 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-assert-openstack-crds.yaml create mode 120000 test/kuttl/tests/application-credentials/02-create-kac-crd.yaml create mode 120000 test/kuttl/tests/application-credentials/02-create-oscp-crd.yaml create mode 120000 test/kuttl/tests/application-credentials/03-create-mock-keystone.yaml create mode 120000 test/kuttl/tests/application-credentials/03-create-mock-oscp-resources.yaml create mode 120000 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-assert-intermediate-state.yaml create mode 100644 test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml create mode 100644 test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml create mode 100644 test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml rename test/kuttl/tests/{dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml => application-credentials/10-cleanup-openstack-lightspeed-instance.yaml} (100%) create mode 100644 test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml create mode 100644 test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml rename test/kuttl/tests/{dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml => application-credentials/12-errors-openstack-lightspeed-instance.yaml} (100%) create mode 120000 test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml create mode 120000 test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml rename test/kuttl/tests/{dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml => application-credentials/15-cleanup-mock-objects.yaml} (100%) rename test/kuttl/tests/{dynamic-crd-watch-recovery/12-errors-mock-objects.yaml => application-credentials/16-errors-mock-objects.yaml} (100%) create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/04-simulate-keystone-operator.yaml rename test/kuttl/tests/dynamic-crd-watch-recovery/{05-delete-oscp-crd.yaml => 05-delete-openstack-crds.yaml} (56%) create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-openstack-lightspeed-instance.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/09-errors-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/12-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 9079026..db1468e 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -59,6 +59,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 951f067..585ccf4 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -210,6 +210,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 f020c6a..d29e6b4 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -184,6 +184,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + 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 8e41b9c..dc95960 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: ] capabilities: Basic Install categories: AI/Machine Learning - createdAt: "2026-07-13T09:32:13Z" + createdAt: "2026-07-13T15:45:52Z" description: AI-powered virtual assistant for Red Hat OpenStack Services on OpenShift features.operators.openshift.io/cnf: "false" features.operators.openshift.io/cni: "false" @@ -169,9 +169,26 @@ spec: - "" resources: - configmaps + verbs: + - get + - apiGroups: + - "" + resources: - secrets verbs: + - create + - get + - update + - apiGroups: + - "" + resourceNames: + - lightspeed-password + resources: + - secrets + verbs: + - delete - get + - update - apiGroups: - "" resourceNames: @@ -216,6 +233,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 33b5d8a..45b7509 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -257,5 +257,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 ec60101..bf5a291 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -184,6 +184,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + 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 2459fde..d47c08d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -8,9 +8,26 @@ rules: - "" resources: - configmaps + verbs: + - get +- apiGroups: + - "" + resources: - secrets verbs: + - create + - get + - update +- apiGroups: + - "" + resourceNames: + - lightspeed-password + resources: + - secrets + verbs: + - delete - get + - update - apiGroups: - "" resourceNames: @@ -55,6 +72,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 3ffb13d..cd1a257 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,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 @@ -24,6 +25,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 // indirect + require ( cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -50,6 +53,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.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -104,7 +108,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 bf4439a..48c5945 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.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= 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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -218,13 +224,18 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -258,6 +269,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 6cce7e3..a1900c5 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -261,6 +261,15 @@ func OpenStackControlPlaneGVK() schema.GroupVersionKind { } } +// KeystoneApplicationCredentialGVK returns the GroupVersionKind for KeystoneApplicationCredential. +func KeystoneApplicationCredentialGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: KeystoneApplicationCredentialGroup, + Version: KeystoneApplicationCredentialVersion, + Kind: KeystoneApplicationCredentialKind, + } +} + // IsDynamicCRDWatched reports whether a controller-runtime watch is currently // registered for the given GVK. The flag is reset to false when a CRD is // removed and its broken informer is cleaned up. This does NOT indicate diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 0fc4524..0699fee 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -166,6 +166,19 @@ const ( OpenStackControlPlaneVersion = "v1beta1" OpenStackControlPlaneKind = "OpenStackControlPlane" + // Keystone Application Credential + KeystoneApplicationCredentialGroup = "keystone.openstack.org" + KeystoneApplicationCredentialVersion = "v1beta1" + KeystoneApplicationCredentialKind = "KeystoneApplicationCredential" + + // Lightspeed Service User in OpenStack created by the Keystone Application Credential + LightspeedServiceUserName = "lightspeed" + LightspeedServiceUserDomain = "default" + LightspeedPasswordSecretName = "lightspeed-password" + LightspeedPasswordSecretKey = "password" + LightspeedACCRName = "lightspeed" + LightspeedACFinalizerName = "openstack.org/lightspeed-ac-consumer" + // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials EnvVarSuffixAPIKey = "_API_KEY" diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 90396c2..75a103a 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -233,7 +233,7 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins } // Build configmap resource version annotations for change detection - annotations, err := buildConfigMapAnnotations(h, ctx) + annotations, err := buildConfigMapAnnotations(h, ctx, instance) if err != nil { return corev1.PodTemplateSpec{}, err } @@ -798,7 +798,7 @@ func getOGXLogLevel(instance *apiv1beta1.OpenStackLightspeed) string { // buildConfigMapAnnotations builds annotations with configmap resource versions // so that changes to the configmaps trigger a deployment rollout. -func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context) (map[string]string, error) { +func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) (map[string]string, error) { annotations := make(map[string]string) lcoreVersion, err := getConfigMapResourceVersion(ctx, h, LCoreConfigCmName, h.GetBeforeObject().GetNamespace()) @@ -856,31 +856,37 @@ func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context) (ma 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) + // OpenStack-specific resources are only mounted when OpenStackReady is + // true (via addMCPVolumesAndMounts). Tracking their ResourceVersion + // when they're not mounted would cause unnecessary pod rollouts if + // they get created before OpenStackReady transitions to true. + if instance.Status.OpenStackReady { + 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 } - } 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) + 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 } - } 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) + 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 } - } else { - annotations[CombinedCABundleSecretVersionAnnotation] = caBundleSecretVersion } return annotations, nil diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go index 920c060..19f835e 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -18,19 +18,26 @@ package controller import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" + "sort" + "strings" "text/template" - "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + 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-k8s-operators/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" ) // --------------------------------------------------------------------------- @@ -238,7 +245,463 @@ 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 readCABundle( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, +) ([]byte, error) { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + + secret, err := helper.GetKClient().CoreV1().Secrets(oscp.GetNamespace()).Get(ctx, caBundleSecretName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return secret.Data["tls-ca-bundle.pem"], nil +} + +func copyCABundle( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, +) 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, + }, + } + + _, err := CopyResource(ctx, helper, source, target, instance, helper.GetScheme()) + return err +} + +// --------------------------------------------------------------------------- +// 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, @@ -246,31 +709,75 @@ 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 := readCABundle(ctx, helper, 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 := copyCABundle(ctx, helper, instance, oscp); err != nil { + return false, fmt.Errorf("failed to copy CA bundle: %w", err) + } + + 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 } @@ -298,40 +805,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 } // --------------------------------------------------------------------------- @@ -376,62 +872,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 := IsDynamicCRDWatched(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 b8ab757..2288da1 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -83,11 +83,20 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,verbs=get;list;watch // +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 +// SAR role escalation: the operator creates a ClusterRole granting pull-secret GET, +// so it must hold that permission itself (K8s RBAC escalation prevention). // +kubebuilder:rbac:groups="",resources=secrets,resourceNames=pull-secret,verbs=get -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get +// Cross-namespace secrets: GET reads OSCP secrets (CA bundle, clouds.yaml, secure.yaml needed to create user); +// CREATE creates lightspeed-password in the OSCP namespace (resourceNames can't restrict create); +// UPDATE adds/removes finalizers on the AC secret (dynamic name set by keystone-operator). +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;create;update +// lightspeed-password lifecycle: update credentials on rotation, delete on CR teardown. +// +kubebuilder:rbac:groups="",resources=secrets,resourceNames=lightspeed-password,verbs=get;update;delete // +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get // +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 @@ -159,13 +168,26 @@ 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 + // Poll when any dynamically watched CRD is missing, or when + // rhos_mcps is enabled — the cache-based watch only covers + // the operator namespace, so polling detects cross-namespace + // OpenStackControlPlane changes (readiness, CA rotations, etc.). + if result.RequeueAfter == 0 { + needsPoll := false + for _, seen := range r.DynamicWatchCRD { + if !seen.Load() { + needsPoll = true + break + } + } + if !needsPoll { + if enabled, _ := isRHOSMCPEnabled(instance); enabled { + needsPoll = true + } + } + if needsPoll { + result.RequeueAfter = ResourceCreationTimeout + } } }() @@ -252,6 +274,10 @@ func (r *OpenStackLightspeedReconciler) reconcileDelete( return err } + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return err + } + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) Log.Info("OpenStackLightspeed Reconciling Delete completed") @@ -350,7 +376,17 @@ func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error Watches( &apiextensionsv1.CustomResourceDefinition{}, handler.EnqueueRequestsFromMapFunc(r.NotifyAllOpenStackLightspeeds), - builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + builder.WithPredicates( + predicate.ResourceVersionChangedPredicate{}, + predicate.NewPredicateFuncs(func(obj client.Object) bool { + for gvk := range r.DynamicWatchCRD { + if obj.GetName() == GetCRDName(gvk) { + return true + } + } + return false + }), + ), ). Build(r) if err != nil { diff --git a/internal/controller/openstacklightspeed_controller_test.go b/internal/controller/openstacklightspeed_controller_test.go index 36366bc..f0f88d6 100644 --- a/internal/controller/openstacklightspeed_controller_test.go +++ b/internal/controller/openstacklightspeed_controller_test.go @@ -79,7 +79,8 @@ var _ = Describe("OpenStackLightspeed Controller", func() { Client: k8sClient, Scheme: k8sClient.Scheme(), DynamicWatchCRD: DynamicWatchCRD{ - OpenStackControlPlaneGVK(): new(atomic.Bool), + OpenStackControlPlaneGVK(): new(atomic.Bool), + KeystoneApplicationCredentialGVK(): new(atomic.Bool), }, } 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 0000000..5c7a64e --- /dev/null +++ b/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml @@ -0,0 +1,41 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - 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 0000000..1f79624 --- /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 0000000..75ccf20 --- /dev/null +++ b/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +--- +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 0000000..4df9372 --- /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 0000000..60a851d --- /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 0000000..799c2c1 --- /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/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml b/test/kuttl/common/mock-openstack/oscp-crd.yaml similarity index 67% rename from test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml rename to test/kuttl/common/mock-openstack/oscp-crd.yaml index 0d164d0..c03d5bb 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-oscp-crd.yaml +++ b/test/kuttl/common/mock-openstack/oscp-crd.yaml @@ -18,4 +18,10 @@ spec: schema: openAPIV3Schema: type: object - x-kubernetes-preserve-unknown-fields: true + 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 0000000..68aaf55 --- /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 || true) + if [ "$READY" = "true" ]; then + echo "ERROR: openStackReady should not be true in intermediate state" + exit 1 + fi + + MCP_CFG=$(oc get configmap mcp-config -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + ENABLED=$(echo "$MCP_CFG" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$ENABLED" != "enabled:false" ]; then + echo "ERROR: MCP config should have openstack disabled in intermediate state" + echo "$MCP_CFG" + exit 1 + fi diff --git a/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml b/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml new file mode 100644 index 0000000..9e150ef --- /dev/null +++ b/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml @@ -0,0 +1,22 @@ +############################################################################## +# These resources must NOT exist during the intermediate state. # +# They should only be created after all readiness checks pass. # +############################################################################## +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml b/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml new file mode 100644 index 0000000..fdec14c --- /dev/null +++ b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml @@ -0,0 +1,53 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: ac-lightspeed-test-secret + namespace: openstack + finalizers: + - openstack.org/lightspeed-ac-consumer +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret + namespace: openstack-lightspeed +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +# Script: kuttl YAML asserts match resource structure, not string content within data fields +commands: + - script: | + #!/bin/bash + set -euo pipefail + CLOUDS=$(oc get configmap openstack-config -n openstack-lightspeed \ + -o jsonpath='{.data.clouds\.yaml}') + echo "$CLOUDS" | grep -q "v3applicationcredential" + echo "$CLOUDS" | grep -q "application_credential_id: mock-ac-id-12345" + echo "$CLOUDS" | grep -q "auth_url: http://mock-keystone-server.openstack.svc:5000/v3" + + SECURE=$(oc get secret openstack-config-secret -n openstack-lightspeed \ + -o jsonpath='{.data.secure\.yaml}' | base64 -d) + echo "$SECURE" | grep -q "application_credential_secret: mock-ac-secret-abcde" + + MCP_CFG=$(oc get configmap mcp-config -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + 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 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/10-cleanup-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/09-cleanup-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/application-credentials/10-cleanup-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml b/test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml new file mode 100644 index 0000000..b883cd3 --- /dev/null +++ b/test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +# Script: kuttl errors files check entire resource absence, not specific field absence +commands: + - script: | + #!/bin/bash + set -euo pipefail + 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 + fi diff --git a/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml b/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml new file mode 100644 index 0000000..4fd259f --- /dev/null +++ b/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: keystone.openstack.org/v1beta1 +kind: KeystoneApplicationCredential +metadata: + name: lightspeed + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: lightspeed-password + namespace: openstack diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/12-errors-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/10-errors-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/application-credentials/12-errors-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml b/test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml new file mode 120000 index 0000000..3dd65f0 --- /dev/null +++ b/test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/cleanup-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml b/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml new file mode 120000 index 0000000..96ea740 --- /dev/null +++ b/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/errors-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml b/test/kuttl/tests/application-credentials/15-cleanup-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-mock-objects.yaml rename to test/kuttl/tests/application-credentials/15-cleanup-mock-objects.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-mock-objects.yaml b/test/kuttl/tests/application-credentials/16-errors-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-mock-objects.yaml rename to test/kuttl/tests/application-credentials/16-errors-mock-objects.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml new file mode 120000 index 0000000..5a621a8 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml deleted file mode 100644 index 8809fce..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-oscp-crd.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: openstackcontrolplanes.core.openstack.org -status: - conditions: - - type: NamesAccepted - status: "True" - - type: Established - status: "True" diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml new file mode 100644 index 0000000..6716ed4 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: CRDs must be Established before CRs can be created; kuttl applies all step manifests simultaneously +commands: + - script: | + #!/bin/bash + set -euo pipefail + oc apply -f ../../common/mock-openstack/oscp-crd.yaml + oc apply -f ../../common/mock-openstack/kac-crd.yaml + oc wait --for=condition=Established \ + crd/openstackcontrolplanes.core.openstack.org \ + crd/keystoneapplicationcredentials.keystone.openstack.org \ + --timeout=60s + oc apply -f ../../common/mock-openstack/mock-keystone.yaml + oc apply -f ../../common/mock-openstack/mock-oscp-resources.yaml + timeout: 300 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml deleted file mode 100644 index 0d164d0..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -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 - x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml index e8af4f3..d2483fd 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml @@ -1,43 +1,25 @@ --- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + openStackReady: true +--- apiVersion: kuttl.dev/v1beta1 kind: TestAssert +timeout: 360 +# Script: kuttl YAML asserts match resource structure, not string content within data fields commands: - script: | #!/bin/bash set -euo pipefail - - # Wait for OpenStackLightspeed to report OpenStack as ready - echo "Waiting for status.openStackReady=true..." - for i in $(seq 1 60); do - READY=$(oc get openstacklightspeed openstack-lightspeed \ - -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) - if [ "$READY" = "true" ]; then - echo "OpenStackReady is true" - break - fi - if [ "$i" -eq 60 ]; then - echo "ERROR: Timed out waiting for status.openStackReady=true" - oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml - exit 1 - fi - sleep 5 - done - - # Verify MCP config has OpenStack enabled - echo "Checking MCP config for openstack enabled..." - MCP_DATA=$(oc get configmap mcp-config \ - -n openstack-lightspeed \ + MCP_DATA=$(oc get configmap mcp-config -n openstack-lightspeed \ -o jsonpath='{.data.config\.yaml}') - - # The openstack section should have enabled: true - OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") - if ! echo "$OPENSTACK_LINE" | grep -q "true"; then - echo "ERROR: Expected openstack enabled: true in MCP config" - echo "MCP config:" + ENABLED=$(echo "$MCP_DATA" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$ENABLED" != "enabled:true" ]; then + echo "ERROR: MCP config does not have openstack enabled" echo "$MCP_DATA" exit 1 fi - - echo "OK: OpenStack detected and MCP configured with OpenStack resources" - timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml deleted file mode 100644 index a10aec9..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/04-create-oscp-instance.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Mock OpenStack resources that the operator copies from the OSCP namespace ---- -apiVersion: v1 -kind: Secret -metadata: - name: test-config-secret - namespace: openstack-lightspeed -type: Opaque -stringData: - secure.yaml: | - clouds: - default: - auth: - password: test-password ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config-map - namespace: openstack-lightspeed -data: - clouds.yaml: | - clouds: - default: - auth: - auth_url: http://keystone.example.com:5000 ---- -apiVersion: v1 -kind: Secret -metadata: - name: test-ca-bundle - namespace: openstack-lightspeed -type: Opaque -stringData: - tls-ca-bundle.pem: | - -----BEGIN CERTIFICATE----- - dGVzdC1jYS1jZXJ0aWZpY2F0ZQ== - -----END CERTIFICATE----- - -# OpenStackControlPlane instance with required fields ---- -apiVersion: core.openstack.org/v1beta1 -kind: OpenStackControlPlane -metadata: - name: test-oscp - namespace: openstack-lightspeed -spec: - openstackclient: - template: - openStackConfigSecret: test-config-secret - openStackConfigMap: test-config-map -status: - tls: - caBundleSecretName: test-ca-bundle diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/04-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml index e43c0c1..7e4d6d1 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml @@ -1,59 +1,30 @@ --- apiVersion: kuttl.dev/v1beta1 kind: TestAssert +timeout: 360 +# Script: Go omitempty on bool omits false from serialization, kuttl YAML assert fails with +# "key is missing from map"; also parses MCP config string content within data fields commands: - script: | #!/bin/bash set -euo pipefail - - # Wait for the CRD to be fully removed - echo "Waiting for OpenStackControlPlane CRD to be deleted..." - for i in $(seq 1 30); do - if ! oc get crd openstackcontrolplanes.core.openstack.org 2>/dev/null; then - echo "CRD deleted" - break - fi - if [ "$i" -eq 30 ]; then - echo "ERROR: CRD still exists after timeout" - exit 1 - fi - sleep 2 - done - - # Wait for the operator to reconcile and reset OpenStackReady - echo "Waiting for status.openStackReady to become false..." - for i in $(seq 1 60); do - READY=$(oc get openstacklightspeed openstack-lightspeed \ - -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) - # Empty or "false" both mean not ready (bool defaults to false when omitted) - if [ "$READY" != "true" ]; then - echo "OpenStackReady is false (value: '${READY}')" - break - fi - if [ "$i" -eq 60 ]; then - echo "ERROR: Timed out waiting for status.openStackReady=false" - oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml - exit 1 - fi - sleep 5 - done - - # Verify MCP config has OpenStack disabled - echo "Checking MCP config for openstack disabled..." - MCP_DATA=$(oc get configmap mcp-config \ + READY=$(oc get openstacklightspeed openstack-lightspeed \ -n openstack-lightspeed \ - -o jsonpath='{.data.config\.yaml}') + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$READY" = "true" ]; then + echo "ERROR: openStackReady is still true" + exit 1 + fi - OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") - if ! echo "$OPENSTACK_LINE" | grep -q "false"; then - echo "ERROR: Expected openstack enabled: false in MCP config" - echo "MCP config:" + MCP_DATA=$(oc get configmap mcp-config -n openstack-lightspeed \ + -o jsonpath='{.data.config\.yaml}') + ENABLED=$(echo "$MCP_DATA" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$ENABLED" != "enabled:false" ]; then + echo "ERROR: MCP config does not have openstack disabled" echo "$MCP_DATA" exit 1 fi - # Verify the operator is still healthy (Ready condition) CONDITION=$(oc get openstacklightspeed openstack-lightspeed \ -n openstack-lightspeed \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}') @@ -62,6 +33,3 @@ commands: oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml exit 1 fi - - echo "OK: Operator recovered from CRD removal, MCP running without OpenStack" - timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-openstack-crds.yaml similarity index 56% rename from test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-openstack-crds.yaml index 8eb884a..da6769b 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-oscp-crd.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-openstack-crds.yaml @@ -5,3 +5,6 @@ delete: - 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/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml new file mode 100644 index 0000000..e33fba7 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml new file mode 100644 index 0000000..4c279f4 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml deleted file mode 100644 index 8809fce..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-oscp-crd.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: openstackcontrolplanes.core.openstack.org -status: - conditions: - - type: NamesAccepted - status: "True" - - type: Established - status: "True" diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml new file mode 100644 index 0000000..b33b95d --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: CRDs must be Established before CRs can be created; kuttl applies all step manifests simultaneously +commands: + - script: | + #!/bin/bash + set -euo pipefail + oc apply -f ../../common/mock-openstack/oscp-crd.yaml + oc apply -f ../../common/mock-openstack/kac-crd.yaml + + echo "Waiting for CRDs to be established..." + oc wait --for=condition=Established \ + crd/openstackcontrolplanes.core.openstack.org \ + crd/keystoneapplicationcredentials.keystone.openstack.org \ + --timeout=60s + + oc apply -f ../../common/mock-openstack/mock-oscp-resources.yaml + timeout: 120 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml index 9ffa1b0..d2483fd 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml @@ -1,44 +1,25 @@ --- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + openStackReady: true +--- apiVersion: kuttl.dev/v1beta1 kind: TestAssert +timeout: 360 +# Script: kuttl YAML asserts match resource structure, not string content within data fields commands: - script: | #!/bin/bash set -euo pipefail - - # This is the critical assertion: after CRD removal and recreation, - # the operator must re-register the watch and detect the new OSCP instance. - echo "Waiting for status.openStackReady=true after CRD recreation (proves watch recovery)..." - for i in $(seq 1 60); do - READY=$(oc get openstacklightspeed openstack-lightspeed \ - -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) - if [ "$READY" = "true" ]; then - echo "OpenStackReady is true - watch recovery confirmed!" - break - fi - if [ "$i" -eq 60 ]; then - echo "ERROR: Timed out waiting for status.openStackReady=true" - echo "The operator failed to recover the watch after CRD recreation" - oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed -o yaml - exit 1 - fi - sleep 5 - done - - # Verify MCP config has OpenStack enabled again - echo "Checking MCP config for openstack enabled..." - MCP_DATA=$(oc get configmap mcp-config \ - -n openstack-lightspeed \ + MCP_DATA=$(oc get configmap mcp-config -n openstack-lightspeed \ -o jsonpath='{.data.config\.yaml}') - - OPENSTACK_LINE=$(echo "$MCP_DATA" | grep -A1 "^openstack:" | grep "enabled:") - if ! echo "$OPENSTACK_LINE" | grep -q "true"; then - echo "ERROR: Expected openstack enabled: true in MCP config after recovery" - echo "MCP config:" + ENABLED=$(echo "$MCP_DATA" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$ENABLED" != "enabled:true" ]; then + echo "ERROR: MCP config does not have openstack enabled" echo "$MCP_DATA" exit 1 fi - - echo "OK: Watch recovery verified - operator detected OpenStack after CRD recreation" - timeout: 360 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml deleted file mode 100644 index c66a9ae..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/07-recreate-oscp-instance.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Recreate OpenStackControlPlane instance (mock resources still exist from step 04) ---- -apiVersion: core.openstack.org/v1beta1 -kind: OpenStackControlPlane -metadata: - name: test-oscp - namespace: openstack-lightspeed -spec: - openstackclient: - template: - openStackConfigSecret: test-config-secret - openStackConfigMap: test-config-map -status: - tls: - caBundleSecretName: test-ca-bundle diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-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/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml deleted file mode 100644 index 8e1d33e..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-oscp-crd.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -delete: - - apiVersion: apiextensions.k8s.io/v1 - kind: CustomResourceDefinition - name: openstackcontrolplanes.core.openstack.org - - apiVersion: v1 - kind: Secret - name: test-config-secret - namespace: openstack-lightspeed - - apiVersion: v1 - kind: ConfigMap - name: test-config-map - namespace: openstack-lightspeed - - apiVersion: v1 - kind: Secret - name: test-ca-bundle - namespace: openstack-lightspeed diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/09-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/09-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/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml new file mode 120000 index 0000000..3dd65f0 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/cleanup-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml new file mode 120000 index 0000000..96ea740 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/errors-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/12-cleanup-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/12-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/12-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/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/errors-mock-objects.yaml \ No newline at end of file From d3af8e9e3b9a1f78ba996dcf5714590e1dc1b12c Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Wed, 15 Jul 2026 15:31:20 +0200 Subject: [PATCH 4/4] Support changing all the images We used to be able to change the RAG image using the `ragImage` field in our CR, but we removed it and said we would make a consistent interface to modify ALL possible images via the CRD. 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 - RAG Image To provide a consistent interface, similar to what we can do in the other openstack operators, we can now set all the images under the `images` field. 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 | 22 +++ ...ed.openstack.org_openstacklightspeeds.yaml | 22 +++ .../api_v1beta1_openstacklightspeed.yaml | 10 ++ .../openstacklightspeed_controller.go | 2 + .../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 + 18 files changed, 378 insertions(+), 29 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 585ccf4..9a5bed2 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" @@ -98,6 +100,11 @@ type DatabaseSpec struct { type OpenStackLightspeedSpec struct { OpenStackLightspeedCore `json:",inline"` + // +kubebuilder:validation:Optional + // Images configures container images used by the operator. + // When omitted, each image defaults to its environment variable or hardcoded fallback. + Images OpenStackLightspeedImages `json:"images,omitempty"` + // +kubebuilder:validation:Optional // Database configures persistent storage for PostgreSQL data. // When omitted, an emptyDir volume is used (data is lost on pod reschedule). @@ -271,42 +278,74 @@ func (instance OpenStackLightspeed) IsReady() bool { return instance.Status.Conditions.IsTrue(OpenStackLightspeedReadyCondition) } +// OpenStackLightspeedImages groups container image URLs used by the operator. +type OpenStackLightspeedImages struct { + RAGImageURL string `json:"ragImage,omitempty"` + LCoreImageURL string `json:"lcoreImage,omitempty"` + ExporterImageURL string `json:"exporterImage,omitempty"` + PostgresImageURL string `json:"postgresImage,omitempty"` + ConsoleImageURL string `json:"consoleImage,omitempty"` + ConsoleImagePF5URL string `json:"consoleImagePF5,omitempty"` + OKPImageURL string `json:"okpImage,omitempty"` + MCPServerImageURL string `json:"mcpServerImage,omitempty"` +} + type OpenStackLightspeedDefaults struct { - RAGImageURL string - LCoreImageURL string - ExporterImageURL string - PostgresImageURL string - ConsoleImageURL string - ConsoleImagePF5URL string - OKPImageURL string - 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 0000000..b8a0a8f --- /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 4fec34e..b91e7ad 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -152,6 +152,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. @@ -164,6 +165,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 @@ -200,6 +216,7 @@ func (in *OpenStackLightspeedList) DeepCopyObject() runtime.Object { func (in *OpenStackLightspeedSpec) DeepCopyInto(out *OpenStackLightspeedSpec) { *out = *in in.OpenStackLightspeedCore.DeepCopyInto(&out.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 d29e6b4..aec69d1 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -79,6 +79,28 @@ spec: default: true description: Enable 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) diff --git a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index bf5a291..441faff 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -79,6 +79,28 @@ spec: default: true description: Enable 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) diff --git a/config/samples/api_v1beta1_openstacklightspeed.yaml b/config/samples/api_v1beta1_openstacklightspeed.yaml index a75a927..7af95f4 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -30,3 +30,13 @@ spec: # okpRagOnly: true # okp: # accessKey: okp-access-key-secret + # 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/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 2288da1..87b725e 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -218,6 +218,8 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } + apiv1beta1.OpenStackLightspeedDefaultValues = apiv1beta1.MergeDefaults(&instance.Spec.Images) + if instance.Spec.MaxTokensForResponse == 0 { instance.Spec.MaxTokensForResponse = apiv1beta1.OpenStackLightspeedDefaultValues.MaxTokensForResponse } 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 0000000..8235a1f --- /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 0000000..07f977a --- /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 0000000..4d55ef7 --- /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 0000000..487e0de --- /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 0000000..06788f2 --- /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 0000000..73f721a --- /dev/null +++ b/test/kuttl/tests/dev-defaults/04-update-remove-dev-defaults.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + maxTokensForResponse: 0 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 0000000..74cb862 --- /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 0000000..6b2075b --- /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 0000000..8147244 --- /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 0000000..410c927 --- /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 0000000..696a5e2 --- /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