From e38b91daa05471e0fa964ffb29fe4b65e08da501 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Tue, 9 Jun 2026 17:49:13 +0200 Subject: [PATCH 1/8] MCP: 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 new lightspeed-mcps repository [1]. On installation the `openshift-cli` tool is enabled when OpenStack Lightspeed is configured (therefore deployed), 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, it's a sidecar container and the port is not exposed, not using TLS is not a real security risk. [1]: https://github.com/openstack-lightspeed/lightspeed-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 ++ 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 | 111 ++++++ 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 | 52 +++ .../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 | 52 +++ .../11-assert-configmaps-update.yaml | 9 + 50 files changed, 1678 insertions(+), 11 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 027870e..7695058 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/lightspeed-mcps:latest" + // MaxTokensForResponseDefault is the default maximum number of tokens that should be used for response MaxTokensForResponseDefault = 2048 ) @@ -205,6 +208,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 @@ -229,6 +237,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}} @@ -268,6 +277,7 @@ type OpenStackLightspeedDefaults struct { ConsoleImageURL string ConsoleImagePF5URL string OKPImageURL string + MCPServerImageURL string MaxTokensForResponse int } @@ -291,6 +301,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 4863a73..e348263 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -234,6 +234,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..930bcf3 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/lightspeed-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/lightspeed-mcps:latest + name: mcp-server-image-url-default version: 0.0.1 diff --git a/cmd/main.go b/cmd/main.go index 208def8..9f767b7 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 } @@ -154,7 +160,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, @@ -184,9 +198,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) @@ -224,3 +243,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 12fa78b..85067f3 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -234,6 +234,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..a461cb0 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/lightspeed-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/hack/env.sh b/hack/env.sh index 6d980da..61f5646 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/lightspeed-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 114cc39..a30f42f 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -26,14 +26,21 @@ import ( "math/big" "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) } @@ -230,3 +242,164 @@ func generateRandomString(secretLength int) (string, error) { return "", fmt.Errorf("failed to generate secret: exceeded iterations - %d", maxIterations) } + +// 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 b7c89d2..7b9d76a 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" @@ -249,6 +254,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. @@ -321,6 +330,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..440f4f5 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 (rhoso-ocp-tools) is always included. +// The OpenStack MCP (rhoso-osp-tools) is only included when openStackReady is true. +func buildLCoreMCPServersConfig(openStackReady bool) []interface{} { + mcpServers := []interface{}{ + map[string]interface{}{ + "name": "rhoso-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": "rhoso-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 8f146f1..edba772 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -210,6 +210,27 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } + // MCP sidecar + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts) + + mcpContainer := corev1.Container{ + Name: "rhoso-mcps", + 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 { @@ -438,6 +459,60 @@ func addDataCollectorVolumes(volumes *[]corev1.Volume, volumeDefaultMode int32) }) } +// addMCPVolumesAndMounts adds MCP sidecar volumes and mounts. +// OpenStack-specific volumes are always mounted with Optional so the pod can +// start before those resources exist (they are created when OSCP becomes ready). +func addMCPVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount) { + *volumes = append(*volumes, + corev1.Volume{ + Name: SecureYAMLSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: SecureYAMLSecretName, + Items: []corev1.KeyToPath{{Key: "secure.yaml", Path: "secure.yaml"}}, + Optional: toPtr(true), + }, + }, + }, + corev1.Volume{ + Name: CloudsYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: CloudsYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "clouds.yaml", Path: "clouds.yaml"}}, + Optional: toPtr(true), + }, + }, + }, + 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"}}, + Optional: toPtr(true), + }, + }, + }, + corev1.Volume{ + Name: MCPConfigYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: MCPConfigYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "config.yaml", Path: "config.yaml"}}, + }, + }, + }, + ) + + *mounts = append(*mounts, + corev1.VolumeMount{Name: SecureYAMLSecretName, MountPath: "/app/secure.yaml", SubPath: "secure.yaml"}, + corev1.VolumeMount{Name: CloudsYAMLConfigMapName, MountPath: "/app/clouds.yaml", SubPath: "clouds.yaml"}, + corev1.VolumeMount{Name: CombinedCABundleSecretName, MountPath: "/app/tls-ca-bundle.pem", SubPath: "tls-ca-bundle.pem", ReadOnly: true}, + corev1.VolumeMount{Name: MCPConfigYAMLConfigMapName, MountPath: "/app/config.yaml", SubPath: "config.yaml"}, + ) +} + // addCABundleVolumesAndMounts adds the CA bundle volume and mount. // The CA bundle is always present (created by reconcileCABundleConfigMap) // and mounted at the RHEL system CA path so applications find it automatically. @@ -765,5 +840,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..428dcd1 --- /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"] != "rhoso-ocp-tools" { + t.Errorf("expected first server name 'rhoso-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"] != "rhoso-osp-tools" { + t.Errorf("expected second server name 'rhoso-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"] != "rhoso-ocp-tools" { + t.Errorf("expected first server name 'rhoso-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..4aecfa3 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: rhoso-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..c1b4301 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: rhoso-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..50562f8 --- /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: rhoso-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 421c70d..81be476 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 @@ -314,6 +314,21 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true + - name: rhoso-mcps + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml volumes: - name: ogx-config configMap: @@ -339,6 +354,33 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: openstack-config-secret + secret: + secretName: openstack-config-secret + items: + - key: secure.yaml + path: secure.yaml + optional: true + - name: openstack-config + configMap: + name: openstack-config + items: + - key: clouds.yaml + path: clouds.yaml + optional: true + - name: combined-ca-bundle + secret: + secretName: combined-ca-bundle + items: + - key: tls-ca-bundle.pem + path: tls-ca-bundle.pem + optional: true + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -358,6 +400,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 @@ -453,6 +503,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 fb300f8..8f21bee 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -174,6 +174,21 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true + - name: rhoso-mcps + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml volumes: - name: ogx-config configMap: @@ -194,6 +209,33 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls + - name: openstack-config-secret + secret: + secretName: openstack-config-secret + items: + - key: secure.yaml + path: secure.yaml + optional: true + - name: openstack-config + configMap: + name: openstack-config + items: + - key: clouds.yaml + path: clouds.yaml + optional: true + - name: combined-ca-bundle + secret: + secretName: combined-ca-bundle + items: + - key: tls-ca-bundle.pem + path: tls-ca-bundle.pem + optional: true + - name: mcp-config + configMap: + name: mcp-config + items: + - key: config.yaml + path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -222,6 +264,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 @@ -301,6 +351,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 b6daf60e66c8f50bf22b3bb39014839b82fd18c9 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 15 Jun 2026 11:33:20 +0200 Subject: [PATCH 2/8] MCP: Add feature flag for rhoso-mcps For now we don't want to automatically deploy the MCP tools, because: - Releasing the lightspeed-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 `rhoso_mcps` as shown in the config sample. 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 | 63 ++++++++++++++++++ .../openstacklightspeed_controller.go | 47 ++++++-------- .../lightspeed-stack-update.yaml | 6 +- .../expected-configs/lightspeed-stack.yaml | 6 +- .../assert-openstack-lightspeed-instance.yaml | 51 +-------------- ...-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-rhoso-mcps-resources.yaml | 22 +++++++ .../03-assert-rhoso-mcps-instance.yaml | 65 +++++++++++++++++++ .../04-disable-rhoso-mcps.yaml | 21 ++++++ .../05-errors-rhoso-mcps-cleanup.yaml | 6 ++ ...cleanup-openstack-lightspeed-instance.yaml | 1 + ...-errors-openstack-lightspeed-instance.yaml | 1 + .../08-cleanup-mock-objects.yaml | 1 + .../09-errors-mock-objects.yaml | 1 + .../03-assert-mcp-config.yaml | 1 - .../08-assert-openstacklightspeed-update.yaml | 51 +-------------- .../11-assert-configmaps-update.yaml | 9 --- 26 files changed, 308 insertions(+), 173 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/rhoso-mcps-configuration/00-mock-resources.yaml create mode 120000 test/kuttl/tests/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml create mode 120000 test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml create mode 120000 test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-mock-objects.yaml create mode 120000 test/kuttl/tests/rhoso-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..1b34599 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 = "RHOSO MCP server is disabled (rhoso_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..8bf9cef 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -20,11 +20,11 @@ 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 RHOSO MCPs (MCP server sidecar with OpenStack/OpenShift tools) # dev: # featureFlags: - # - okp + # - rhoso_mcps # 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 a30f42f..acf3d12 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "math/big" + "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 } +// isRHOSOMCPEnabled returns true if the "rhoso_mcps" feature flag is present in the dev config. +func isRHOSOMCPEnabled(instance *apiv1beta1.OpenStackLightspeed) (bool, error) { + config, err := parseDevConfig(instance) + if err != nil { + return false, err + } + return slices.Contains(config.FeatureFlags, "rhoso_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 440f4f5..8e567a9 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 := isRHOSOMCPEnabled(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 edba772..5eae12a 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -210,26 +210,32 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } - // MCP sidecar - mcpMounts := []corev1.VolumeMount{} - addMCPVolumesAndMounts(&volumes, &mcpMounts) - - mcpContainer := corev1.Container{ - Name: "rhoso-mcps", - 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 rhoso_mcps feature flag is enabled) + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return corev1.PodTemplateSpec{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts) + + mcpContainer := corev1.Container{ + Name: "rhoso-mcps", + 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..edfc7fc 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 { + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + 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,36 @@ func extractOSCPFields( }, nil } +// --------------------------------------------------------------------------- +// Deletion +// --------------------------------------------------------------------------- + +// cleanupMCPResources removes MCP server resources when the rhoso_mcps feature +// flag is disabled. +func (r *OpenStackLightspeedReconciler) cleanupMCPResources( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) error { + ns := instance.Namespace + + resources := []client.Object{ + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: MCPConfigYAMLConfigMapName, Namespace: ns}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: CloudsYAMLConfigMapName, Namespace: ns}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: SecureYAMLSecretName, Namespace: ns}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: CombinedCABundleSecretName, Namespace: ns}}, + } + + for _, obj := range resources { + if err := helper.GetClient().Delete(ctx, obj); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete %s %s: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) + } + } + + helper.GetLogger().Info("RHOSO 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..10bd508 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 RHOSO MCP is enabled; + // when disabled the condition was already set in Reconcile). + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + 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 4aecfa3..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: rhoso-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 c1b4301..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: rhoso-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 81be476..6961db3 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 @@ -314,21 +314,6 @@ spec: mountPath: /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem subPath: tls-ca-bundle.pem readOnly: true - - name: rhoso-mcps - volumeMounts: - - name: openstack-config-secret - mountPath: /app/secure.yaml - subPath: secure.yaml - - name: openstack-config - mountPath: /app/clouds.yaml - subPath: clouds.yaml - - name: combined-ca-bundle - mountPath: /app/tls-ca-bundle.pem - subPath: tls-ca-bundle.pem - readOnly: true - - name: mcp-config - mountPath: /app/config.yaml - subPath: config.yaml volumes: - name: ogx-config configMap: @@ -354,33 +339,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: openstack-config-secret - secret: - secretName: openstack-config-secret - items: - - key: secure.yaml - path: secure.yaml - optional: true - - name: openstack-config - configMap: - name: openstack-config - items: - - key: clouds.yaml - path: clouds.yaml - optional: true - - name: combined-ca-bundle - secret: - secretName: combined-ca-bundle - items: - - key: tls-ca-bundle.pem - path: tls-ca-bundle.pem - optional: true - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -400,14 +358,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 @@ -505,6 +455,7 @@ status: message: Setup complete - type: OpenStackLightspeedMCPServerReady status: "True" + message: "RHOSO MCP server is disabled (rhoso_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..dea52c6 --- /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: + - rhoso_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..dea52c6 --- /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: + - rhoso_mcps diff --git a/test/kuttl/tests/rhoso-mcps-configuration/00-mock-resources.yaml b/test/kuttl/tests/rhoso-mcps-configuration/00-mock-resources.yaml new file mode 120000 index 0000000..8235a1f --- /dev/null +++ b/test/kuttl/tests/rhoso-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/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml b/test/kuttl/tests/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml new file mode 120000 index 0000000..07f977a --- /dev/null +++ b/test/kuttl/tests/rhoso-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/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml b/test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml new file mode 100644 index 0000000..dea52c6 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-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: + - rhoso_mcps diff --git a/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml new file mode 100644 index 0000000..e471e5b --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml @@ -0,0 +1,65 @@ +############################################################################## +# 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: rhoso-mcps + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml +status: + replicas: 1 + readyReplicas: 1 + availableReplicas: 1 + +# MCP server ConfigMap +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + +# OpenStackLightspeed CR status +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + conditions: + - type: Ready + status: "True" + reason: Ready + message: Setup complete + - type: OpenStackLightspeedMCPServerReady + status: "True" + - type: OpenStackLightspeedReady + status: "True" + reason: Ready + message: OpenStack Lightspeed created diff --git a/test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml b/test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml new file mode 100644 index 0000000..bca96d8 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-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/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml b/test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml new file mode 100644 index 0000000..22fe537 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/rhoso-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/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/rhoso-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/rhoso-mcps-configuration/08-cleanup-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/rhoso-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/rhoso-mcps-configuration/09-errors-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/09-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/rhoso-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 8f21bee..014dc40 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -174,21 +174,6 @@ spec: - name: tls-certs mountPath: /etc/certs/lightspeed-tls readOnly: true - - name: rhoso-mcps - volumeMounts: - - name: openstack-config-secret - mountPath: /app/secure.yaml - subPath: secure.yaml - - name: openstack-config - mountPath: /app/clouds.yaml - subPath: clouds.yaml - - name: combined-ca-bundle - mountPath: /app/tls-ca-bundle.pem - subPath: tls-ca-bundle.pem - readOnly: true - - name: mcp-config - mountPath: /app/config.yaml - subPath: config.yaml volumes: - name: ogx-config configMap: @@ -209,33 +194,6 @@ spec: - name: tls-certs secret: secretName: lightspeed-tls - - name: openstack-config-secret - secret: - secretName: openstack-config-secret - items: - - key: secure.yaml - path: secure.yaml - optional: true - - name: openstack-config - configMap: - name: openstack-config - items: - - key: clouds.yaml - path: clouds.yaml - optional: true - - name: combined-ca-bundle - secret: - secretName: combined-ca-bundle - items: - - key: tls-ca-bundle.pem - path: tls-ca-bundle.pem - optional: true - - name: mcp-config - configMap: - name: mcp-config - items: - - key: config.yaml - path: config.yaml status: replicas: 1 readyReplicas: 1 @@ -264,14 +222,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 @@ -353,6 +303,7 @@ status: message: Setup complete - type: OpenStackLightspeedMCPServerReady status: "True" + message: "RHOSO MCP server is disabled (rhoso_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 8195be435696d65495a564ff65fc0cf0a06e3798 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Fri, 12 Jun 2026 13:26:17 +0200 Subject: [PATCH 3/8] MCP: Use Keystone Application Credentials Initial implementation of the MCP deployment uses the credentials from the `openstackclient` pod, which means that we are not the owners of that secret, just the copy we make in the `openstack-lightspeed` namespace, so those credentials could be removed/deleted and that would break our `openstack-cli` tool. In this patch we change the credentials and we leverage the `KeystoneApplicationCredential` CR to get our own credentials. Since OpenStack can be deployed on any namespace we are forced to have permissions to access any secret in the deployment. Alternatively we could change how the operator is deployed altogether and do a per-namespace deployment so only those secrets can be accessed. That is currently out of scope of this effort. 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/mcp_server.go | 653 ++++++++++++++++-- .../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 + 66 files changed, 1560 insertions(+), 326 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 1b34599..66f33e2 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 = "RHOSO MCP server is disabled (rhoso_mcps feature flag not set)" diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 7695058..0024a2b 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -213,6 +213,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 e348263..42c9c67 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -187,6 +187,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 930bcf3..9aa8e0c 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 9f767b7..71c5c63 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -259,5 +259,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 85067f3..ef8adf5 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -187,6 +187,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 b416d29..870166d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/onsi/gomega v1.40.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-20260402051712-545e8a4df936 // 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 2c3b091..62802cf 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -218,13 +224,18 @@ golang.org/x/sync v0.21.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= 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 acf3d12..8622e70 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -291,6 +291,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 7b9d76a..93cf17d 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/mcp_server.go b/internal/controller/mcp_server.go index edfc7fc..f33c9db 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -18,19 +18,27 @@ 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" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/yaml" ) // --------------------------------------------------------------------------- @@ -238,7 +246,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().Error(err, "Failed to remove finalizer from old AC secret", "secret", prevSecret) + } + } + } + + 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 +710,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 +806,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 } // --------------------------------------------------------------------------- @@ -360,62 +857,76 @@ func (r *OpenStackLightspeedReconciler) cleanupMCPResources( } } + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return fmt.Errorf("failed to clean up OpenStack resources during RHOSO MCP disable: %w", err) + } + helper.GetLogger().Info("RHOSO 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.Error(err, "Failed to remove finalizer from AC secret") } - 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 10bd508..450becd 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 + // rhoso_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, _ := isRHOSOMCPEnabled(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..af9b903 --- /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 9ef0bd0d1c1d6c15f361e0565035c5969cf3b18a Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Fri, 24 Jul 2026 23:12:49 +0200 Subject: [PATCH 4/8] MCP: Improve kuttl tests Modify kuttl tests to minimize the usage of bash scripts and leverage as much as possible standard kuttl test mechanisms. --- .../assert-openstack-crds.yaml} | 0 .../02-assert-openstack-crds.yaml | 23 +----------- .../02-assert-openstack-crds.yaml | 1 + .../02-create-kac-crd.yaml | 1 + .../02-create-mock-openstack.yaml | 17 --------- .../02-create-oscp-crd.yaml | 1 + .../03-create-mock-keystone.yaml | 1 + .../03-create-mock-oscp-resources.yaml | 1 + ...ack.yaml => 04-assert-mock-openstack.yaml} | 0 ...-config.yaml => 05-assert-mcp-config.yaml} | 0 ...-assert-openstack-lightspeed-instance.yaml | 35 ------------------- ...create-openstack-lightspeed-instance.yaml} | 0 ...assert-openstack-lightspeed-instance.yaml} | 0 .../06-recreate-mock-openstack.yaml | 19 ---------- ...aml => 06-simulate-keystone-operator.yaml} | 0 ...-assert-openstack-lightspeed-instance.yaml | 32 +++++++++++------ ...rds.yaml => 07-delete-openstack-crds.yaml} | 0 ...rds.yaml => 07-errors-openstack-crds.yaml} | 0 .../08-assert-openstack-crds.yaml | 1 + .../08-create-kac-crd.yaml | 1 + .../08-create-oscp-crd.yaml | 1 + .../09-create-mock-oscp-resources.yaml | 1 + ...-assert-openstack-lightspeed-instance.yaml | 25 +++++++++++++ ...aml => 10-simulate-keystone-operator.yaml} | 0 ...leanup-openstack-lightspeed-instance.yaml} | 0 ...errors-openstack-lightspeed-instance.yaml} | 0 ...ck.yaml => 13-cleanup-mock-openstack.yaml} | 0 ...ack.yaml => 14-errors-mock-openstack.yaml} | 0 ...ects.yaml => 15-cleanup-mock-objects.yaml} | 0 ...jects.yaml => 16-errors-mock-objects.yaml} | 0 30 files changed, 56 insertions(+), 104 deletions(-) rename test/kuttl/{tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml => common/mock-openstack/assert-openstack-crds.yaml} (100%) mode change 100644 => 120000 test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml rename test/kuttl/tests/dynamic-crd-watch-recovery/{02-assert-mock-openstack.yaml => 04-assert-mock-openstack.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{03-assert-mcp-config.yaml => 05-assert-mcp-config.yaml} (100%) delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml rename test/kuttl/tests/dynamic-crd-watch-recovery/{03-create-openstack-lightspeed-instance.yaml => 05-create-openstack-lightspeed-instance.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{04-assert-openstack-lightspeed-instance.yaml => 06-assert-openstack-lightspeed-instance.yaml} (100%) delete mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml rename test/kuttl/tests/dynamic-crd-watch-recovery/{04-simulate-keystone-operator.yaml => 06-simulate-keystone-operator.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{05-delete-openstack-crds.yaml => 07-delete-openstack-crds.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{05-errors-openstack-crds.yaml => 07-errors-openstack-crds.yaml} (100%) create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml create mode 120000 test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml create mode 100644 test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml rename test/kuttl/tests/dynamic-crd-watch-recovery/{07-simulate-keystone-operator.yaml => 10-simulate-keystone-operator.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{08-cleanup-openstack-lightspeed-instance.yaml => 11-cleanup-openstack-lightspeed-instance.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{09-errors-openstack-lightspeed-instance.yaml => 12-errors-openstack-lightspeed-instance.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{10-cleanup-mock-openstack.yaml => 13-cleanup-mock-openstack.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{11-errors-mock-openstack.yaml => 14-errors-mock-openstack.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{12-cleanup-mock-objects.yaml => 15-cleanup-mock-objects.yaml} (100%) rename test/kuttl/tests/dynamic-crd-watch-recovery/{13-errors-mock-objects.yaml => 16-errors-mock-objects.yaml} (100%) diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml b/test/kuttl/common/mock-openstack/assert-openstack-crds.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-mock-openstack.yaml rename to test/kuttl/common/mock-openstack/assert-openstack-crds.yaml diff --git a/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml b/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml deleted file mode 100644 index 4c279f4..0000000 --- a/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml +++ /dev/null @@ -1,22 +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" ---- -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/application-credentials/02-assert-openstack-crds.yaml b/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml new file mode 120000 index 0000000..6c9e5bd --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/kac-crd.yaml \ No newline at end of file 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 deleted file mode 100644 index 6716ed4..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-mock-openstack.yaml +++ /dev/null @@ -1,17 +0,0 @@ ---- -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 new file mode 120000 index 0000000..97f74fb --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/oscp-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml new file mode 120000 index 0000000..997b604 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-keystone.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml new file mode 120000 index 0000000..13a8223 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-oscp-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-mock-openstack.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-mock-openstack.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-mock-openstack.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-assert-mcp-config.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-mcp-config.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/03-assert-mcp-config.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-mcp-config.yaml 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 deleted file mode 100644 index 7e4d6d1..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-openstack-lightspeed-instance.yaml +++ /dev/null @@ -1,35 +0,0 @@ ---- -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 - READY=$(oc get openstacklightspeed openstack-lightspeed \ - -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) - if [ "$READY" = "true" ]; then - echo "ERROR: openStackReady is still true" - exit 1 - fi - - 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 - - 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 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-create-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/03-create-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/05-create-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml 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 deleted file mode 100644 index b33b95d..0000000 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/06-recreate-mock-openstack.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -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/04-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-simulate-keystone-operator.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/04-simulate-keystone-operator.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/06-simulate-keystone-operator.yaml 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 d2483fd..7e4d6d1 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,25 +1,35 @@ --- -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 +# 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 + READY=$(oc get openstacklightspeed openstack-lightspeed \ + -n openstack-lightspeed \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$READY" = "true" ]; then + echo "ERROR: openStackReady is still true" + exit 1 + fi + 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:true" ]; then - echo "ERROR: MCP config does not have openstack enabled" + if [ "$ENABLED" != "enabled:false" ]; then + echo "ERROR: MCP config does not have openstack disabled" echo "$MCP_DATA" exit 1 fi + + 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 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-delete-openstack-crds.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/05-delete-openstack-crds.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/07-delete-openstack-crds.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-errors-openstack-crds.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/05-errors-openstack-crds.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/07-errors-openstack-crds.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml new file mode 120000 index 0000000..6c9e5bd --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/kac-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml new file mode 120000 index 0000000..97f74fb --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/oscp-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml new file mode 120000 index 0000000..13a8223 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-oscp-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..d2483fd --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml @@ -0,0 +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 + 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:true" ]; then + echo "ERROR: MCP config does not have openstack enabled" + echo "$MCP_DATA" + exit 1 + fi diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-simulate-keystone-operator.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/07-simulate-keystone-operator.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/10-simulate-keystone-operator.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/08-cleanup-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/09-errors-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/13-cleanup-mock-openstack.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/10-cleanup-mock-openstack.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/13-cleanup-mock-openstack.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/14-errors-mock-openstack.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/11-errors-mock-openstack.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/14-errors-mock-openstack.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/12-cleanup-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/15-cleanup-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/12-cleanup-mock-objects.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/15-cleanup-mock-objects.yaml diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/16-errors-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/dynamic-crd-watch-recovery/13-errors-mock-objects.yaml rename to test/kuttl/tests/dynamic-crd-watch-recovery/16-errors-mock-objects.yaml From d3133f348fd4db85593f098fbfe4b1f128d8304d Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 27 Jul 2026 13:08:26 +0200 Subject: [PATCH 5/8] MCP: Consolidate common kuttl bash scripts There is code in the bash scripts that is repeated in multiple kuttl test files. In this commit we consolidate that code into shared functions that can be used by the different tests. --- test/kuttl/common/shared_funcs.sh | 54 +++++++++++++++++++ .../07-assert-intermediate-state.yaml | 20 ++----- .../09-assert-mcp-credentials.yaml | 10 +--- ...-assert-openstack-lightspeed-instance.yaml | 10 +--- ...-assert-openstack-lightspeed-instance.yaml | 29 ++-------- ...-assert-openstack-lightspeed-instance.yaml | 10 +--- 6 files changed, 67 insertions(+), 66 deletions(-) create mode 100644 test/kuttl/common/shared_funcs.sh diff --git a/test/kuttl/common/shared_funcs.sh b/test/kuttl/common/shared_funcs.sh new file mode 100644 index 0000000..310df02 --- /dev/null +++ b/test/kuttl/common/shared_funcs.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Shared assertion functions for kuttl test scripts. +# +# Usage: source ../../common/shared_funcs.sh +# (from test/kuttl/tests//) + +NAMESPACE="${NAMESPACE:-openstack-lightspeed}" +CR_NAME="${CR_NAME:-openstack-lightspeed}" + +assert_mcp_openstack_enabled() { + local expected="$1" + local mcp_data + mcp_data=$(oc get configmap mcp-config -n "$NAMESPACE" \ + -o jsonpath='{.data.config\.yaml}') + local enabled + enabled=$(echo "$mcp_data" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$enabled" != "enabled:${expected}" ]; then + echo "ERROR: Expected MCP openstack enabled:${expected}, got ${enabled}" + echo "$mcp_data" + exit 1 + fi +} + +assert_openstack_ready() { + local expected="$1" + local ready + ready=$(oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$expected" = "true" ]; then + if [ "$ready" != "true" ]; then + echo "ERROR: Expected openStackReady to be true, got '${ready}'" + exit 1 + fi + else + if [ "$ready" = "true" ]; then + echo "ERROR: Expected openStackReady to not be true, got '${ready}'" + exit 1 + fi + fi +} + +assert_condition_status() { + local condition_type="$1" + local expected_status="$2" + local actual + actual=$(oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" \ + -o jsonpath="{.status.conditions[?(@.type==\"${condition_type}\")].status}") + if [ "$actual" != "$expected_status" ]; then + echo "ERROR: Expected condition ${condition_type}=${expected_status}, got '${actual}'" + oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" -o yaml + exit 1 + fi +} diff --git a/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml b/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml index 5580499..d1c8481 100644 --- a/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml +++ b/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml @@ -10,20 +10,6 @@ commands: - script: | #!/bin/bash set -euo pipefail - - # openStackReady is omitempty, so absent means false - READY=$(oc get openstacklightspeed openstack-lightspeed -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/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 + source ../../common/shared_funcs.sh + assert_openstack_ready false + assert_mcp_openstack_enabled false diff --git a/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml index fdec14c..530fb8d 100644 --- a/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml +++ b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml @@ -43,11 +43,5 @@ commands: -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 + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml index d2483fd..d3e703f 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml @@ -15,11 +15,5 @@ commands: - script: | #!/bin/bash set -euo pipefail - 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:true" ]; then - echo "ERROR: MCP config does not have openstack enabled" - echo "$MCP_DATA" - exit 1 - fi + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled 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 index 7e4d6d1..914803b 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 @@ -8,28 +8,7 @@ commands: - script: | #!/bin/bash set -euo pipefail - READY=$(oc get openstacklightspeed openstack-lightspeed \ - -n openstack-lightspeed \ - -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) - if [ "$READY" = "true" ]; then - echo "ERROR: openStackReady is still true" - exit 1 - fi - - 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 - - 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 + source ../../common/shared_funcs.sh + assert_openstack_ready false + assert_mcp_openstack_enabled false + assert_condition_status Ready True diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml index d2483fd..d3e703f 100644 --- a/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml @@ -15,11 +15,5 @@ commands: - script: | #!/bin/bash set -euo pipefail - 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:true" ]; then - echo "ERROR: MCP config does not have openstack enabled" - echo "$MCP_DATA" - exit 1 - fi + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled true From 0d0c17f5e905946dca2b9770b6464b79f13c53bb Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 27 Jul 2026 16:26:57 +0200 Subject: [PATCH 6/8] MCP: Allow override config Currently there is no way to modify the MCP server configuration, which means we cannot even configure the logs to be in debug mode. In this commit we add a section in the `dev` field (named `rhosMCPConfig`) to allow configuring anything within the lightspeed-mcp service except for enabling the tools, which is still done by the operator itself. Configuring the logging has not been added to the standard location in the CRD because the feature is still experimental, so we can't set its configuration in the CRD as if it were a normal feature. This is to avoid having to make non-compatible changes to the CRD down the road. Added a small comment to the openstacklightspeed_types.go file establishing this rule. It is the users responsibility to ensure that configuration makes sense and works. The operator will not be responsible if the service cannot start. For example, if we increase the number of workers the container will need more memory than it has currently assigned and the container will be restarted. --- api/v1beta1/openstacklightspeed_types.go | 4 +- .../api_v1beta1_openstacklightspeed.yaml | 5 + internal/controller/mcp_server.go | 65 ++++++++++- internal/controller/mcp_server_test.go | 101 +++++++++++++++++- .../04-update-rhos-mcp-config.yaml | 30 ++++++ .../05-assert-rhos-mcp-config-merged.yaml | 51 +++++++++ ...o-mcps.yaml => 06-disable-rhoso-mcps.yaml} | 0 ...yaml => 07-errors-rhoso-mcps-cleanup.yaml} | 0 ...leanup-openstack-lightspeed-instance.yaml} | 0 ...errors-openstack-lightspeed-instance.yaml} | 0 ...ects.yaml => 10-cleanup-mock-objects.yaml} | 0 ...jects.yaml => 11-errors-mock-objects.yaml} | 0 12 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml create mode 100644 test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml rename test/kuttl/tests/rhoso-mcps-configuration/{04-disable-rhoso-mcps.yaml => 06-disable-rhoso-mcps.yaml} (100%) rename test/kuttl/tests/rhoso-mcps-configuration/{05-errors-rhoso-mcps-cleanup.yaml => 07-errors-rhoso-mcps-cleanup.yaml} (100%) rename test/kuttl/tests/rhoso-mcps-configuration/{06-cleanup-openstack-lightspeed-instance.yaml => 08-cleanup-openstack-lightspeed-instance.yaml} (100%) rename test/kuttl/tests/rhoso-mcps-configuration/{07-errors-openstack-lightspeed-instance.yaml => 09-errors-openstack-lightspeed-instance.yaml} (100%) rename test/kuttl/tests/rhoso-mcps-configuration/{08-cleanup-mock-objects.yaml => 10-cleanup-mock-objects.yaml} (100%) rename test/kuttl/tests/rhoso-mcps-configuration/{09-errors-mock-objects.yaml => 11-errors-mock-objects.yaml} (100%) diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 0024a2b..a24741f 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -57,13 +57,15 @@ const ( // May change at any time without backward compatibility. // // Supported fields: -// - featureFlags: list of experimental feature flags to enable +// - featureFlags: list of experimental feature flags to enable. Configuration options for experimental features must also live within the `DevSpec`. // - okpChunkFilterQuery: Solr filter query for OKP searches (default: version-aware query combining detected OpenStack and OCP versions) // - okpRagOnly: when true, only OKP is used as a RAG source (default: true) +// - rhosMCPConfig: custom YAML configuration for the rhos-mcps service; deep-merged on top of the operator defaults, openstack.enabled and openshift.enabled are always overridden by the operator type DevSpec struct { FeatureFlags []string `json:"featureFlags,omitempty"` OKPChunkFilterQuery string `json:"okpChunkFilterQuery,omitempty"` OKPRagOnly *bool `json:"okpRagOnly,omitempty"` + RhosMCPConfig string `json:"rhosMCPConfig,omitempty"` } // OKPSpec defines configuration for the Offline Knowledge Portal (OKP). diff --git a/config/samples/api_v1beta1_openstacklightspeed.yaml b/config/samples/api_v1beta1_openstacklightspeed.yaml index 8bf9cef..5358a77 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -26,5 +26,10 @@ spec: # - rhoso_mcps # okpChunkFilterQuery: "product:(*openstack* OR *openshift*)" # okpRagOnly: true + # # Custom MCP server config (deep-merged with operator defaults; + # # openstack.enabled and openshift.enabled are always overridden): + # rhosMCPConfig: | + # debug: true + # workers: 4 # okp: # accessKey: okp-access-key-secret diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go index f33c9db..c66d377 100644 --- a/internal/controller/mcp_server.go +++ b/internal/controller/mcp_server.go @@ -76,9 +76,29 @@ type mcpServerConfigParams struct { OpenShiftEnabled bool } -// buildMCPServerConfigData renders the MCP server config template with the -// enabled flags for each platform section. -func buildMCPServerConfigData(openStackReady bool) (string, error) { +// deepMerge recursively merges override into base. When both sides have a map +// for the same key the maps are merged; otherwise the override value wins. +func deepMerge(base, override map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(base)) + for k, v := range base { + result[k] = v + } + for k, v := range override { + if baseMap, ok := result[k].(map[string]interface{}); ok { + if overrideMap, ok := v.(map[string]interface{}); ok { + result[k] = deepMerge(baseMap, overrideMap) + continue + } + } + result[k] = v + } + return result +} + +// buildMCPServerConfigData renders the MCP server config template and, when +// rhosMCPConfig is provided, deep-merges the user config on top. The +// openstack.enabled and openshift.enabled flags are always enforced. +func buildMCPServerConfigData(openStackReady bool, rhosMCPConfig string) (string, error) { var buf bytes.Buffer err := mcpServerConfigTmpl.Execute(&buf, mcpServerConfigParams{ OpenStackEnabled: openStackReady, @@ -88,7 +108,41 @@ func buildMCPServerConfigData(openStackReady bool) (string, error) { return "", fmt.Errorf("failed to render MCP server config template: %w", err) } - return buf.String(), nil + if rhosMCPConfig == "" { + return buf.String(), nil + } + + var baseConfig map[string]interface{} + if err := yaml.Unmarshal(buf.Bytes(), &baseConfig); err != nil { + return "", fmt.Errorf("failed to parse base MCP server config: %w", err) + } + + var userConfig map[string]interface{} + if err := yaml.Unmarshal([]byte(rhosMCPConfig), &userConfig); err != nil { + return "", fmt.Errorf("failed to parse rhosMCPConfig: %w", err) + } + + merged := deepMerge(baseConfig, userConfig) + + osSection, ok := merged["openstack"].(map[string]interface{}) + if !ok { + osSection = make(map[string]interface{}) + merged["openstack"] = osSection + } + osSection["enabled"] = openStackReady + + ocpSection, ok := merged["openshift"].(map[string]interface{}) + if !ok { + ocpSection = make(map[string]interface{}) + merged["openshift"] = ocpSection + } + ocpSection["enabled"] = true + + result, err := yaml.Marshal(merged) + if err != nil { + return "", fmt.Errorf("failed to marshal merged MCP server config: %w", err) + } + return string(result), nil } // BuildMCPServerConfigMap creates the ConfigMap for the MCP server configuration. @@ -96,7 +150,8 @@ func BuildMCPServerConfigMap( instance *apiv1beta1.OpenStackLightspeed, openStackReady bool, ) (corev1.ConfigMap, error) { - configData, err := buildMCPServerConfigData(openStackReady) + devConfig, _ := parseDevConfig(instance) + configData, err := buildMCPServerConfigData(openStackReady, devConfig.RhosMCPConfig) if err != nil { return corev1.ConfigMap{}, err } diff --git a/internal/controller/mcp_server_test.go b/internal/controller/mcp_server_test.go index 428dcd1..d84ce5c 100644 --- a/internal/controller/mcp_server_test.go +++ b/internal/controller/mcp_server_test.go @@ -20,10 +20,12 @@ import ( "fmt" "strings" "testing" + + "sigs.k8s.io/yaml" ) func TestBuildMCPServerConfigData_OpenStackNotReady(t *testing.T) { - result, err := buildMCPServerConfigData(false) + result, err := buildMCPServerConfigData(false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -36,7 +38,7 @@ func TestBuildMCPServerConfigData_OpenStackNotReady(t *testing.T) { } func TestBuildMCPServerConfigData_OpenStackReady(t *testing.T) { - result, err := buildMCPServerConfigData(true) + result, err := buildMCPServerConfigData(true, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -48,6 +50,101 @@ func TestBuildMCPServerConfigData_OpenStackReady(t *testing.T) { } } +func TestBuildMCPServerConfigData_CustomConfig_OverridesEnabled(t *testing.T) { + customConfig := ` +openstack: + enabled: true + allow_write: true +openshift: + enabled: false +` + result, err := buildMCPServerConfigData(false, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + osSection := parsed["openstack"].(map[string]interface{}) + if osSection["enabled"] != false { + t.Errorf("expected openstack.enabled=false (operator override), got %v", osSection["enabled"]) + } + if osSection["allow_write"] != true { + t.Errorf("expected openstack.allow_write=true (user value), got %v", osSection["allow_write"]) + } + + ocpSection := parsed["openshift"].(map[string]interface{}) + if ocpSection["enabled"] != true { + t.Errorf("expected openshift.enabled=true (operator override), got %v", ocpSection["enabled"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_DeepMergesWithDefaults(t *testing.T) { + customConfig := ` +debug: true +workers: 4 +` + result, err := buildMCPServerConfigData(false, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if parsed["debug"] != true { + t.Errorf("expected debug=true (user override), got %v", parsed["debug"]) + } + if parsed["workers"] != float64(4) { + t.Errorf("expected workers=4 (user override), got %v", parsed["workers"]) + } + if parsed["port"] != float64(8080) { + t.Errorf("expected port=8080 (template default preserved), got %v", parsed["port"]) + } + + osSection := parsed["openstack"].(map[string]interface{}) + if osSection["ca_cert"] != "./tls-ca-bundle.pem" { + t.Errorf("expected openstack.ca_cert preserved from template, got %v", osSection["ca_cert"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_AddsNewFields(t *testing.T) { + customConfig := ` +custom_section: + key1: value1 + key2: 42 +` + result, err := buildMCPServerConfigData(true, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + custom := parsed["custom_section"].(map[string]interface{}) + if custom["key1"] != "value1" { + t.Errorf("expected custom_section.key1=value1, got %v", custom["key1"]) + } + if custom["key2"] != float64(42) { + t.Errorf("expected custom_section.key2=42, got %v", custom["key2"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_InvalidYAML(t *testing.T) { + _, err := buildMCPServerConfigData(false, "not: valid: yaml: [") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + func TestBuildLCoreMCPServersConfig_WithOpenStack(t *testing.T) { servers := buildLCoreMCPServersConfig(true) diff --git a/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml b/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml new file mode 100644 index 0000000..33ef4d2 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml @@ -0,0 +1,30 @@ +--- +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: + - rhoso_mcps + rhosMCPConfig: | + debug: true + workers: 4 + openstack: + enabled: true + allow_write: true + openshift: + enabled: false diff --git a/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml new file mode 100644 index 0000000..8f5d8a7 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml @@ -0,0 +1,51 @@ +############################################################################## +# Assert that rhosMCPConfig is deep-merged with operator defaults # +############################################################################## +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + + mcp_data=$(oc get configmap mcp-config -n "$NAMESPACE" \ + -o jsonpath='{.data.config\.yaml}') + + assert_yaml_field() { + local field="$1" expected="$2" + local actual + actual=$(echo "$mcp_data" | python3 -c " + import sys, yaml + data = yaml.safe_load(sys.stdin) + keys = '$field'.split('.') + val = data + for k in keys: + val = val[k] + print(val) + ") + if [ "$actual" != "$expected" ]; then + echo "ERROR: Expected $field=$expected, got $actual" + echo "$mcp_data" + exit 1 + fi + } + + # User values should be merged + assert_yaml_field "debug" "True" + assert_yaml_field "workers" "4" + + # Template defaults should be preserved + assert_yaml_field "port" "8080" + assert_yaml_field "ip" "127.0.0.1" + assert_yaml_field "openstack.ca_cert" "./tls-ca-bundle.pem" + assert_yaml_field "openstack.insecure" "False" + assert_yaml_field "openshift.insecure" "False" + + # User value in nested section should be merged + assert_yaml_field "openstack.allow_write" "True" + + # Operator must override enabled fields + assert_yaml_field "openstack.enabled" "False" + assert_yaml_field "openshift.enabled" "True" + timeout: 180 diff --git a/test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml b/test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml diff --git a/test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml b/test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml diff --git a/test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml diff --git a/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/10-cleanup-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-mock-objects.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/10-cleanup-mock-objects.yaml diff --git a/test/kuttl/tests/rhoso-mcps-configuration/09-errors-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/11-errors-mock-objects.yaml similarity index 100% rename from test/kuttl/tests/rhoso-mcps-configuration/09-errors-mock-objects.yaml rename to test/kuttl/tests/rhoso-mcps-configuration/11-errors-mock-objects.yaml From 6796305a93235884217992416d66593af56d8958 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Mon, 27 Jul 2026 17:42:41 +0200 Subject: [PATCH 7/8] MCP: Liveness probe Add a liveness probe on the rhos-mcp container. This commit depends on the lightspeed-mcps PR #18 [1] that adds the `/health` endpoint. [1]: https://github.com/openstack-k8s-operators/lightspeed-mcps/pull/18 --- .../controller/assets/mcp_server_config.yaml.tmpl | 5 ++++- internal/controller/constants.go | 6 ++++++ internal/controller/lcore_deployment.go | 11 +++++++++++ .../05-assert-rhos-mcp-config-merged.yaml | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/controller/assets/mcp_server_config.yaml.tmpl b/internal/controller/assets/mcp_server_config.yaml.tmpl index 353d715..833d183 100644 --- a/internal/controller/assets/mcp_server_config.yaml.tmpl +++ b/internal/controller/assets/mcp_server_config.yaml.tmpl @@ -1,5 +1,8 @@ --- -ip: 127.0.0.1 +# Bind to all interfaces so the kubelet liveness probe can reach /health. +# This is safe: the container has no declared ports or Service, so nothing +# routes external traffic to it. +ip: 0.0.0.0 port: 8080 debug: false workers: 1 diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 93cf17d..ed736f8 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -118,6 +118,12 @@ const ( LightspeedStackStartupProbeFailureThreshold = int32(30) LightspeedStackProbeFailureThreshold = int32(3) + // Health probe settings for the rhoso-mcps container. + MCPServerHealthPath = "/health" + MCPServerProbePeriodSeconds = int32(10) + MCPServerProbeTimeoutSeconds = int32(5) + MCPServerProbeFailureThreshold = int32(3) + // Data Exporter ExporterConfigVolumeName = "exporter-config" ExporterConfigMountPath = "/etc/config" diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 5eae12a..32bbf69 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -232,6 +232,17 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins corev1.ResourceMemory: resource.MustParse("200Mi"), }, }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: MCPServerHealthPath, + Port: intstr.FromInt32(MCPServerPort), + }, + }, + PeriodSeconds: MCPServerProbePeriodSeconds, + TimeoutSeconds: MCPServerProbeTimeoutSeconds, + FailureThreshold: MCPServerProbeFailureThreshold, + }, ImagePullPolicy: corev1.PullIfNotPresent, } containers = append(containers, mcpContainer) diff --git a/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml index 8f5d8a7..719772f 100644 --- a/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml +++ b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml @@ -37,7 +37,7 @@ commands: # Template defaults should be preserved assert_yaml_field "port" "8080" - assert_yaml_field "ip" "127.0.0.1" + assert_yaml_field "ip" "0.0.0.0" assert_yaml_field "openstack.ca_cert" "./tls-ca-bundle.pem" assert_yaml_field "openstack.insecure" "False" assert_yaml_field "openshift.insecure" "False" From 7d601e8e3e753ef783d88942d4578398d6c52686 Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Tue, 28 Jul 2026 12:55:46 +0200 Subject: [PATCH 8/8] MCP: Fix govulncheck vulnerability Error is: ``` Vulnerability #1: GO-2026-6061 Vulnerabilities in the xDS RBAC authorization engine and the HTTP/2 transport server implementation in google.golang.org/grpc More info: https://pkg.go.dev/vuln/GO-2026-6061 Module: google.golang.org/grpc Found in: google.golang.org/grpc@v1.78.0 Fixed in: google.golang.org/grpc@v1.82.1 Example traces found: #1: test/utils/utils.go:113:21: utils.LoadImageToKindClusterWithName calls exec.Command, which eventually calls transport.NewHTTP2Client #2: test/utils/utils.go:113:21: utils.LoadImageToKindClusterWithName calls exec.Command, which eventually calls transport.http2Client.Close #3: test/utils/utils.go:113:21: utils.LoadImageToKindClusterWithName calls exec.Command, which eventually calls transport.http2Client.GracefulClose ``` --- go.mod | 10 +++++----- go.sum | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 870166d..6210b52 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ replace github.com/openshift/api => github.com/openshift/api v0.0.0-202507112000 require k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -89,7 +89,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect @@ -97,9 +97,9 @@ require ( golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 62802cf..5ae982b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -214,8 +214,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -252,14 +252,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=