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