diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 7de488a..66f33e2 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -27,6 +27,10 @@ const ( // OpenShift Lightspeed Operator Status=True condition which indicates if OpenShift Lightspeed is installed and // operational and it can be used by OpenStack Lightspeed operator. OpenShiftLightspeedOperatorReadyCondition condition.Type = "OpenShiftLightspeedOperatorReady" + + // OpenStackLightspeedMCPServerReadyCondition is set to True when the MCP server + // deployment succeeds. False indicates a failure during MCP server deployment. + OpenStackLightspeedMCPServerReadyCondition condition.Type = "OpenStackLightspeedMCPServerReady" ) // Common Messages used by API objects. @@ -46,6 +50,24 @@ const ( // OpenShiftLightspeedOperatorReady OpenShiftLightspeedOperatorReady = "OpenShift Lightspeed operator is ready." + // OpenStackLightspeedMCPServerInitMessage + OpenStackLightspeedMCPServerInitMessage = "MCP server deployment has not resolved" + + // OpenStackLightspeedMCPServerDeployed + OpenStackLightspeedMCPServerDeployed = "MCP server is ready" + + // OpenStackLightspeedMCPServerWaitingOpenStack + OpenStackLightspeedMCPServerWaitingOpenStack = "MCP server deployed, waiting for OpenStackControlPlane to become ready" + + // OpenStackLightspeedMCPServerCreatingUser + OpenStackLightspeedMCPServerCreatingUser = "Creating OpenStack service user" + + // OpenStackLightspeedMCPServerWaitingAC + OpenStackLightspeedMCPServerWaitingAC = "Waiting for application credential secret" + + // OpenStackLightspeedMCPServerDisabledMessage + OpenStackLightspeedMCPServerDisabledMessage = "RHOSO MCP server is disabled (rhoso_mcps feature flag not set)" + // DeploymentCheckFailedMessage DeploymentCheckFailedMessage = "Failed to check deployment status: %s" diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index 027870e..a24741f 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -46,6 +46,9 @@ const ( // OKPContainerImage is the fall-back container image for OKP (Offline Knowledge Portal) OKPContainerImage = "registry.redhat.io/offline-knowledge-portal/rhokp-rhel9:latest" + // MCPServerContainerImage is the fall-back container image for the MCP server + MCPServerContainerImage = "quay.io/openstack-lightspeed/lightspeed-mcps:latest" + // MaxTokensForResponseDefault is the default maximum number of tokens that should be used for response MaxTokensForResponseDefault = 2048 ) @@ -54,13 +57,15 @@ const ( // May change at any time without backward compatibility. // // Supported fields: -// - featureFlags: list of experimental feature flags to enable +// - featureFlags: list of experimental feature flags to enable. Configuration options for experimental features must also live within the `DevSpec`. // - okpChunkFilterQuery: Solr filter query for OKP searches (default: version-aware query combining detected OpenStack and OCP versions) // - okpRagOnly: when true, only OKP is used as a RAG source (default: true) +// - rhosMCPConfig: custom YAML configuration for the rhos-mcps service; deep-merged on top of the operator defaults, openstack.enabled and openshift.enabled are always overridden by the operator type DevSpec struct { FeatureFlags []string `json:"featureFlags,omitempty"` OKPChunkFilterQuery string `json:"okpChunkFilterQuery,omitempty"` OKPRagOnly *bool `json:"okpRagOnly,omitempty"` + RhosMCPConfig string `json:"rhosMCPConfig,omitempty"` } // OKPSpec defines configuration for the Offline Knowledge Portal (OKP). @@ -205,6 +210,16 @@ type OpenStackLightspeedStatus struct { // ObservedGeneration - the most recent generation observed for this object. ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // +optional + // OpenStackReady indicates whether an OpenStackControlPlane was detected and + // is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + OpenStackReady bool `json:"openStackReady,omitempty"` + + // +optional + // ApplicationCredentialSecret is the name of the current AC secret in the + // OpenStack namespace. Tracked for rotation detection. + ApplicationCredentialSecret string `json:"applicationCredentialSecret,omitempty"` } // +kubebuilder:object:root=true @@ -229,6 +244,7 @@ type OpenStackLightspeedStatus struct { // +operator-sdk:csv:customresourcedefinitions:resources={{PersistentVolumeClaim,v1,openstack-lightspeed-database}} // +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRole,v1,lightspeed-app-server-sar-role}} // +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRoleBinding,v1,lightspeed-app-server-sar-role-binding}} +// +operator-sdk:csv:customresourcedefinitions:resources={{ConfigMap,v1,mcp-config}} // +operator-sdk:csv:customresourcedefinitions:resources={{Subscription,v1alpha1}} // +operator-sdk:csv:customresourcedefinitions:resources={{ClusterServiceVersion,v1alpha1}} // +operator-sdk:csv:customresourcedefinitions:resources={{InstallPlan,v1alpha1}} @@ -268,6 +284,7 @@ type OpenStackLightspeedDefaults struct { ConsoleImageURL string ConsoleImagePF5URL string OKPImageURL string + MCPServerImageURL string MaxTokensForResponse int } @@ -291,6 +308,8 @@ func SetupDefaults() { "RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT", ConsoleContainerImagePF5), OKPImageURL: util.GetEnvVar( "RELATED_IMAGE_OKP_IMAGE_URL_DEFAULT", OKPContainerImage), + MCPServerImageURL: util.GetEnvVar( + "RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT", MCPServerContainerImage), MaxTokensForResponse: MaxTokensForResponseDefault, } diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index 4863a73..42c9c67 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -187,6 +187,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret is the name of the current AC secret in the + OpenStack namespace. Tracked for rotation detection. + type: string conditions: description: Conditions items: @@ -234,6 +239,11 @@ spec: for this object. format: int64 type: integer + openStackReady: + description: |- + OpenStackReady indicates whether an OpenStackControlPlane was detected and + is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + type: boolean type: object type: object served: true diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index cf7f1fb..9aa8e0c 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: ] capabilities: Basic Install categories: AI/Machine Learning - createdAt: "2026-07-13T09:32:13Z" + createdAt: "2026-07-13T15:45:52Z" description: AI-powered virtual assistant for Red Hat OpenStack Services on OpenShift features.operators.openshift.io/cnf: "false" features.operators.openshift.io/cni: "false" @@ -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,30 @@ spec: spec: clusterPermissions: - rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - apiGroups: + - "" + resources: + - secrets + verbs: + - create + - get + - update + - apiGroups: + - "" + resourceNames: + - lightspeed-password + resources: + - secrets + verbs: + - delete + - get + - update - apiGroups: - "" resourceNames: @@ -170,6 +197,14 @@ spec: - secrets verbs: - get + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -190,6 +225,32 @@ spec: - patch - update - watch + - apiGroups: + - core.openstack.org + resources: + - openstackcontrolplanes + verbs: + - 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: @@ -305,6 +366,8 @@ spec: value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12 - name: RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 + - name: RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT + value: quay.io/openstack-lightspeed/lightspeed-mcps:latest image: quay.io/openstack-lightspeed/operator:latest livenessProbe: httpGet: @@ -500,4 +563,6 @@ spec: name: console-image-url-default - image: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 name: console-pf5-image-url-default + - image: quay.io/openstack-lightspeed/lightspeed-mcps:latest + name: mcp-server-image-url-default version: 0.0.1 diff --git a/cmd/main.go b/cmd/main.go index 208def8..71c5c63 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,13 +22,17 @@ import ( "fmt" "os" "strings" + "sync/atomic" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -62,6 +66,8 @@ func init() { utilruntime.Must(consolev1.AddToScheme(scheme)) utilruntime.Must(openshiftv1.AddToScheme(scheme)) + + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -154,7 +160,15 @@ func main() { setupLog.Info(fmt.Sprintf("openstack-lightspeed operator watches %s namespace", namespace)) } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + cfg := ctrl.GetConfigOrDie() + + kclient, err := kubernetes.NewForConfig(cfg) + if err != nil { + setupLog.Error(err, "unable to create kubernetes client") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, @@ -184,9 +198,14 @@ func main() { // Defaults for OpenStackLightspeed apiv1beta1.SetupDefaults() + dynamicWatchCRDs := getDynamicWatchCRDs() + if err = (&controller.OpenStackLightspeedReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Kclient: kclient, + Scheme: mgr.GetScheme(), + Cache: mgr.GetCache(), + DynamicWatchCRD: dynamicWatchCRDs, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "OpenStackLightspeed") os.Exit(1) @@ -224,3 +243,26 @@ 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), + { + 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 12fa78b..ef8adf5 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -187,6 +187,11 @@ spec: status: description: OpenStackLightspeedStatus defines the observed state of OpenStackLightspeed properties: + applicationCredentialSecret: + description: |- + ApplicationCredentialSecret is the name of the current AC secret in the + OpenStack namespace. Tracked for rotation detection. + type: string conditions: description: Conditions items: @@ -234,6 +239,11 @@ spec: for this object. format: int64 type: integer + openStackReady: + description: |- + OpenStackReady indicates whether an OpenStackControlPlane was detected and + is ready. When true, the OpenStack MCP tools are included in lightspeed-stack config. + type: boolean type: object type: object served: true diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index ac2b535..a461cb0 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -87,6 +87,8 @@ spec: value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12 - name: RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT value: registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12 + - name: RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT + value: quay.io/openstack-lightspeed/lightspeed-mcps:latest securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml index 5d55817..f3eac11 100644 --- a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -87,6 +87,9 @@ spec: - kind: ConfigMap name: llama-stack-config version: v1 + - kind: ConfigMap + name: mcp-config + version: v1 - kind: Secret name: metrics-reader-token version: v1 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 613a800..d47c08d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,30 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - get + - update +- apiGroups: + - "" + resourceNames: + - lightspeed-password + resources: + - secrets + verbs: + - delete + - get + - update - apiGroups: - "" resourceNames: @@ -12,6 +36,14 @@ rules: - secrets verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -32,6 +64,32 @@ rules: - patch - update - watch +- apiGroups: + - core.openstack.org + resources: + - openstackcontrolplanes + verbs: + - 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/config/samples/api_v1beta1_openstacklightspeed.yaml b/config/samples/api_v1beta1_openstacklightspeed.yaml index ed262d0..5358a77 100644 --- a/config/samples/api_v1beta1_openstacklightspeed.yaml +++ b/config/samples/api_v1beta1_openstacklightspeed.yaml @@ -20,11 +20,16 @@ spec: # ogxLogLevel: "all=info" # lightspeedStackLogLevel: "INFO" # dataverseExporterLogLevel: "INFO" - # Uncomment to enable OKP (Offline Knowledge Portal) as an Inline RAG source: - # okp: - # accessKey: okp-access-key-secret + # Uncomment to enable RHOSO MCPs (MCP server sidecar with OpenStack/OpenShift tools) # dev: # featureFlags: - # - okp + # - rhoso_mcps # okpChunkFilterQuery: "product:(*openstack* OR *openshift*)" # okpRagOnly: true + # # Custom MCP server config (deep-merged with operator defaults; + # # openstack.enabled and openshift.enabled are always overridden): + # rhosMCPConfig: | + # debug: true + # workers: 4 + # okp: + # accessKey: okp-access-key-secret diff --git a/go.mod b/go.mod index b416d29..6210b52 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/onsi/gomega v1.40.0 github.com/openshift/api v3.9.0+incompatible // from lib-common github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 + github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0 github.com/operator-framework/api v0.37.0 k8s.io/api v0.34.2 k8s.io/apiextensions-apiserver v0.34.2 @@ -24,8 +25,10 @@ require ( // must be consistent within modules and service operators replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e +require k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -50,6 +53,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud v1.14.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -85,7 +89,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect @@ -93,9 +97,9 @@ require ( golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -104,7 +108,6 @@ require ( k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2c3b091..5ae982b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -71,6 +71,8 @@ github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oX github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= +github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -113,6 +115,8 @@ github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e h1:E1OdwSpqWuDPCedyU github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 h1:2TD4hi+MLt67jKxJUs2tuBKFMxibrLJQqKqhsTMsHeQ= github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0/go.mod h1:rgpcv2tLD+/vudXx/gpIQSTuRpk4GOxHx84xwfvQalM= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0 h1:8BniQwsPk8qjqoniLFDLnBEJgA0FLOwIrPDv93URiMo= +github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.0/go.mod h1:tfMa+ochq7Dyilq9hQr2CEPfPtsj6IUgMmMqi4CWDmo= github.com/operator-framework/api v0.37.0 h1:2XCMWitBnumtJTqzip6LQKUwpM2pXVlt3gkpdlkbaCE= github.com/operator-framework/api v0.37.0/go.mod h1:NZs4vB+Jiamyv3pdPDjZtuC4U7KX0eq4z2r5hKY5fUA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -196,6 +200,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -206,10 +211,11 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -218,13 +224,18 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -241,14 +252,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -258,6 +269,7 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/env.sh b/hack/env.sh index 6d980da..61f5646 100644 --- a/hack/env.sh +++ b/hack/env.sh @@ -9,4 +9,5 @@ export RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT="quay.io/openstack-l export RELATED_IMAGE_OKP_IMAGE_URL_DEFAULT="registry.redhat.io/offline-knowledge-portal/rhokp-rhel9:latest" export RELATED_IMAGE_CONSOLE_IMAGE_URL_DEFAULT="registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-rhel9:1.0.12" export RELATED_IMAGE_CONSOLE_PF5_IMAGE_URL_DEFAULT="registry.redhat.io/openshift-lightspeed/lightspeed-console-plugin-pf5-rhel9:1.0.12" +export RELATED_IMAGE_MCP_SERVER_IMAGE_URL_DEFAULT="quay.io/openstack-lightspeed/lightspeed-mcps:latest" export WATCH_NAMESPACE="openstack-lightspeed" diff --git a/internal/controller/assets/mcp_server_config.yaml.tmpl b/internal/controller/assets/mcp_server_config.yaml.tmpl new file mode 100644 index 0000000..833d183 --- /dev/null +++ b/internal/controller/assets/mcp_server_config.yaml.tmpl @@ -0,0 +1,27 @@ +--- +# Bind to all interfaces so the kubelet liveness probe can reach /health. +# This is safe: the container has no declared ports or Service, so nothing +# routes external traffic to it. +ip: 0.0.0.0 +port: 8080 +debug: false +workers: 1 +processes_pool_size: 10 + +openstack: + enabled: {{ .OpenStackEnabled }} + allow_write: false + ca_cert: ./tls-ca-bundle.pem + insecure: false + +openshift: + enabled: {{ .OpenShiftEnabled }} + allow_write: false + insecure: false + +mcp_transport_security: + enable_dns_rebinding_protection: true + allowed_hosts: + - "127.0.0.1:*" + - "localhost:*" + allowed_origins: [] diff --git a/internal/controller/common.go b/internal/controller/common.go index 114cc39..8622e70 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -24,16 +24,24 @@ import ( "errors" "fmt" "math/big" + "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-k8s-operators/lightspeed-operator/api/v1beta1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) // toPtr returns a pointer to the given value. @@ -86,8 +94,13 @@ func getConfigMapResourceVersion(ctx context.Context, h *common_helper.Helper, n // getSecretResourceVersion retrieves the resource version of a Secret. func getSecretResourceVersion(ctx context.Context, h *common_helper.Helper, name string, namespace string) (string, error) { + rawClient, err := getRawClient(h) + if err != nil { + return "", fmt.Errorf("failed to get raw client: %w", err) + } + secret := &corev1.Secret{} - err := h.GetClient().Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, secret) + err = rawClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, secret) if err != nil { return "", fmt.Errorf("failed to get secret %s: %w", name, err) } @@ -153,6 +166,15 @@ func parseDevConfig(instance *apiv1beta1.OpenStackLightspeed) (apiv1beta1.DevSpe return config, nil } +// isRHOSOMCPEnabled returns true if the "rhoso_mcps" feature flag is present in the dev config. +func isRHOSOMCPEnabled(instance *apiv1beta1.OpenStackLightspeed) (bool, error) { + config, err := parseDevConfig(instance) + if err != nil { + return false, err + } + return slices.Contains(config.FeatureFlags, "rhoso_mcps"), nil +} + // getOKPChunkFilterQuery returns the chunk filter query from the dev config, or a version-aware default. func getOKPChunkFilterQuery(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) string { config, _ := parseDevConfig(instance) @@ -230,3 +252,173 @@ func generateRandomString(secretLength int) (string, error) { return "", fmt.Errorf("failed to generate secret: exceeded iterations - %d", maxIterations) } + +// GetCRDName returns the name of the CustomResourceDefinition (CRD) for a given +// GroupVersionKind (GVK). The CRD name is constructed as "s." string. +func GetCRDName(gvk schema.GroupVersionKind) string { + return fmt.Sprintf("%ss.%s", strings.ToLower(gvk.Kind), gvk.Group) +} + +// IsCRDEstablished checks if a CRD exists and is in "Established" state (ready for use). +// Returns (true, nil) if the CRD exists and is established, (false, nil) if it doesn't exist, +// and (false, error) for other errors. +func IsCRDEstablished(ctx context.Context, helper *common_helper.Helper, gvk schema.GroupVersionKind) (bool, error) { + crdName := GetCRDName(gvk) + crd := &apiextensionsv1.CustomResourceDefinition{} + err := helper.GetClient().Get(ctx, client.ObjectKey{Name: crdName}, crd) + if err != nil { + if k8s_errors.IsNotFound(err) { + return false, nil + } + return false, err + } + + for _, cond := range crd.Status.Conditions { + if cond.Type == apiextensionsv1.Established && cond.Status == apiextensionsv1.ConditionTrue { + return true, nil + } + } + + return false, nil +} + +// OpenStackControlPlaneGVK returns the GroupVersionKind for OpenStackControlPlane. +func OpenStackControlPlaneGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: OpenStackControlPlaneGroup, + Version: OpenStackControlPlaneVersion, + Kind: OpenStackControlPlaneKind, + } +} + +// KeystoneApplicationCredentialGVK returns the GroupVersionKind for KeystoneApplicationCredential. +func KeystoneApplicationCredentialGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: KeystoneApplicationCredentialGroup, + Version: KeystoneApplicationCredentialVersion, + Kind: KeystoneApplicationCredentialKind, + } +} + +// IsDynamicCRDWatched reports whether a controller-runtime watch is currently +// registered for the given GVK. The flag is reset to false when a CRD is +// removed and its broken informer is cleaned up. This does NOT indicate +// whether the CRD currently exists — use IsCRDEstablished for that. +func IsDynamicCRDWatched( + dynamicWatchCRD DynamicWatchCRD, + gvk schema.GroupVersionKind, +) (bool, error) { + seen, exists := dynamicWatchCRD[gvk] + if !exists { + return false, fmt.Errorf("GVK %v not found in DynamicWatchCRD map", gvk) + } + return seen.Load(), nil +} + +// SetChecksumAnnotation sets or updates the checksum annotation on the provided object. +func SetChecksumAnnotation(object client.Object, checksum string) { + annotations := object.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[OpenStackLightspeedChecksumAnnotation] = checksum + object.SetAnnotations(annotations) +} + +// GetChecksumAnnotation retrieves the checksum annotation from the given object. +// If the annotation is not found, it returns an empty string. +func GetChecksumAnnotation(object client.Object) string { + annotations := object.GetAnnotations() + if annotations == nil { + return "" + } + checksum, ok := annotations[OpenStackLightspeedChecksumAnnotation] + if !ok { + return "" + } + return checksum +} + +// CopyResource copies a resource (Secret or ConfigMap) from one namespace to another, +// setting a controller reference on the copy and computing checksums. +func CopyResource( + ctx context.Context, + helper *common_helper.Helper, + sourceObject client.Object, + targetObject client.Object, + owner client.Object, + scheme *runtime.Scheme, +) (client.Object, error) { + var copyObject client.Object + var err error + + switch source := sourceObject.(type) { + case *corev1.Secret: + fetched, fetchErr := helper.GetKClient().CoreV1().Secrets(source.GetNamespace()).Get(ctx, source.GetName(), metav1.GetOptions{}) + if fetchErr != nil { + return nil, fetchErr + } + + copySecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetObject.GetName(), + Namespace: targetObject.GetNamespace(), + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), copySecret, func() error { + copySecret.Data = fetched.Data + copySecret.StringData = fetched.StringData + copySecret.Type = fetched.Type + if err := controllerutil.SetControllerReference(owner, copySecret, scheme); err != nil { + return err + } + + checksum, err := common_secret.Hash(copySecret) + if err != nil { + return err + } + SetChecksumAnnotation(copySecret, checksum) + return nil + }) + + copyObject = copySecret + case *corev1.ConfigMap: + fetched, fetchErr := helper.GetKClient().CoreV1().ConfigMaps(source.GetNamespace()).Get(ctx, source.GetName(), metav1.GetOptions{}) + if fetchErr != nil { + return nil, fetchErr + } + + copyConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetObject.GetName(), + Namespace: targetObject.GetNamespace(), + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), copyConfigMap, func() error { + copyConfigMap.Data = fetched.Data + copyConfigMap.BinaryData = fetched.BinaryData + if err := controllerutil.SetControllerReference(owner, copyConfigMap, scheme); err != nil { + return err + } + + checksum, err := common_cm.Hash(copyConfigMap) + if err != nil { + return err + } + SetChecksumAnnotation(copyConfigMap, checksum) + return nil + }) + + copyObject = copyConfigMap + default: + return nil, errors.New("cannot copy resource (invalid type)") + } + + if err != nil { + return nil, err + } + + return copyObject, nil +} diff --git a/internal/controller/constants.go b/internal/controller/constants.go index b7c89d2..ed736f8 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -118,6 +118,12 @@ const ( LightspeedStackStartupProbeFailureThreshold = int32(30) LightspeedStackProbeFailureThreshold = int32(3) + // Health probe settings for the rhoso-mcps container. + MCPServerHealthPath = "/health" + MCPServerProbePeriodSeconds = int32(10) + MCPServerProbeTimeoutSeconds = int32(5) + MCPServerProbeFailureThreshold = int32(3) + // Data Exporter ExporterConfigVolumeName = "exporter-config" ExporterConfigMountPath = "/etc/config" @@ -161,6 +167,24 @@ const ( OpenAIProviderName = "openai" WatsonXProviderName = "watsonx" + // OpenStack Control Plane + OpenStackControlPlaneGroup = "core.openstack.org" + OpenStackControlPlaneVersion = "v1beta1" + OpenStackControlPlaneKind = "OpenStackControlPlane" + + // Keystone Application Credential + KeystoneApplicationCredentialGroup = "keystone.openstack.org" + KeystoneApplicationCredentialVersion = "v1beta1" + KeystoneApplicationCredentialKind = "KeystoneApplicationCredential" + + // Lightspeed Service User in OpenStack created by the Keystone Application Credential + LightspeedServiceUserName = "lightspeed" + LightspeedServiceUserDomain = "default" + LightspeedPasswordSecretName = "lightspeed-password" + LightspeedPasswordSecretKey = "password" + LightspeedACCRName = "lightspeed" + LightspeedACFinalizerName = "openstack.org/lightspeed-ac-consumer" + // EnvVarSuffixAPIKey is the environment variable suffix for API key credentials EnvVarSuffixAPIKey = "_API_KEY" @@ -249,6 +273,10 @@ const ( LlamaStackConfigMapResourceVersionAnnotation = "ols.openshift.io/llamastack-configmap-version" LCoreConfigMapResourceVersionAnnotation = "ols.openshift.io/lcore-configmap-version" CABundleConfigMapVersionAnnotation = "ols.openshift.io/ca-bundle-configmap-version" + MCPConfigMapResourceVersionAnnotation = "ols.openshift.io/mcp-configmap-version" + CloudsYAMLConfigMapVersionAnnotation = "ols.openshift.io/clouds-yaml-configmap-version" + SecureYAMLSecretVersionAnnotation = "ols.openshift.io/secure-yaml-secret-version" // #nosec G101 -- annotation key, not a credential + CombinedCABundleSecretVersionAnnotation = "ols.openshift.io/combined-ca-bundle-secret-version" // #nosec G101 -- annotation key, not a credential // Volume Permissions // These constants define file permissions for volumes mounted in containers. @@ -321,6 +349,9 @@ const ( // that signs TLS certificates auto-provisioned for Services via the // service.beta.openshift.io/serving-cert-secret-name annotation. OpenShiftServiceCAConfigMap = "openshift-service-ca.crt" + + // OpenStackLightspeedChecksumAnnotation is the annotation key used to store the checksum of resources. + OpenStackLightspeedChecksumAnnotation = "openstack.org/checksum" ) // PostgreSQL Bootstrap Script - creates database, extensions, and schemas diff --git a/internal/controller/lcore_config.go b/internal/controller/lcore_config.go index d498444..8e567a9 100644 --- a/internal/controller/lcore_config.go +++ b/internal/controller/lcore_config.go @@ -33,6 +33,11 @@ import ( //go:embed assets/system_prompt.txt var systemPrompt string +// mcpServerConfigTemplate stores the embedded config template for the MCP server. +// +//go:embed assets/mcp_server_config.yaml.tmpl +var mcpServerConfigTemplate string + // getSystemPrompt returns the OpenStackLightspeed system prompt func getSystemPrompt() string { return systemPrompt @@ -217,8 +222,43 @@ func buildOKPConfig(ctx context.Context, h *common_helper.Helper, instance *apiv } } +// buildLCoreMCPServersConfig generates the mcp_servers section for lightspeed-stack config. +// The OpenShift MCP (rhoso-ocp-tools) is always included. +// The OpenStack MCP (rhoso-osp-tools) is only included when openStackReady is true. +func buildLCoreMCPServersConfig(openStackReady bool) []interface{} { + mcpServers := []interface{}{ + map[string]interface{}{ + "name": "rhoso-ocp-tools", + "url": fmt.Sprintf("%s/openshift/", GetMCPServerURL()), + "authorization_headers": map[string]interface{}{ + "OCP_TOKEN": "kubernetes", + }, + }, + } + + if openStackReady { + mcpServers = append(mcpServers, map[string]interface{}{ + "name": "rhoso-osp-tools", + "url": fmt.Sprintf("%s/openstack/", GetMCPServerURL()), + }) + } + + return mcpServers +} + +func buildLCoreMCPServersConfigIfEnabled(instance *apiv1beta1.OpenStackLightspeed) ([]interface{}, error) { + enabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return nil, fmt.Errorf("failed to parse dev config: %w", err) + } + if !enabled { + return []interface{}{}, nil + } + return buildLCoreMCPServersConfig(instance.Status.OpenStackReady), nil +} + // buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML. -// NOTE: MCP servers, quota handlers, and tools approval features are disabled for OpenStack Lightspeed. +// NOTE: quota handlers, and tools approval features are disabled for OpenStack Lightspeed. func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) { ragInline := []interface{}{"okp"} @@ -226,6 +266,11 @@ func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance "inline": ragInline, } + mcpServers, err := buildLCoreMCPServersConfigIfEnabled(instance) + if err != nil { + return "", err + } + // Build the complete config as a map config := map[string]interface{}{ "name": "Lightspeed Core Service (LCS)", @@ -239,6 +284,7 @@ func buildLCoreConfigYAML(ctx context.Context, h *common_helper.Helper, instance "conversation_cache": buildLCoreConversationCacheConfig(h, instance), "byok_rag": []interface{}{}, "rag": ragConfig, + "mcp_servers": mcpServers, } config["okp"] = buildOKPConfig(ctx, h, instance) diff --git a/internal/controller/lcore_deployment.go b/internal/controller/lcore_deployment.go index 8f146f1..32bbf69 100644 --- a/internal/controller/lcore_deployment.go +++ b/internal/controller/lcore_deployment.go @@ -210,6 +210,44 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins containers = append(containers, exporterContainer) } + // MCP sidecar (only when rhoso_mcps feature flag is enabled) + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return corev1.PodTemplateSpec{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + mcpMounts := []corev1.VolumeMount{} + addMCPVolumesAndMounts(&volumes, &mcpMounts) + + mcpContainer := corev1.Container{ + Name: "rhoso-mcps", + Image: apiv1beta1.OpenStackLightspeedDefaultValues.MCPServerImageURL, + VolumeMounts: mcpMounts, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("200Mi"), + }, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: MCPServerHealthPath, + Port: intstr.FromInt32(MCPServerPort), + }, + }, + PeriodSeconds: MCPServerProbePeriodSeconds, + TimeoutSeconds: MCPServerProbeTimeoutSeconds, + FailureThreshold: MCPServerProbeFailureThreshold, + }, + ImagePullPolicy: corev1.PullIfNotPresent, + } + containers = append(containers, mcpContainer) + } + // Build configmap resource version annotations for change detection annotations, err := buildConfigMapAnnotations(h, ctx) if err != nil { @@ -438,6 +476,60 @@ func addDataCollectorVolumes(volumes *[]corev1.Volume, volumeDefaultMode int32) }) } +// addMCPVolumesAndMounts adds MCP sidecar volumes and mounts. +// OpenStack-specific volumes are always mounted with Optional so the pod can +// start before those resources exist (they are created when OSCP becomes ready). +func addMCPVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount) { + *volumes = append(*volumes, + corev1.Volume{ + Name: SecureYAMLSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: SecureYAMLSecretName, + Items: []corev1.KeyToPath{{Key: "secure.yaml", Path: "secure.yaml"}}, + Optional: toPtr(true), + }, + }, + }, + corev1.Volume{ + Name: CloudsYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: CloudsYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "clouds.yaml", Path: "clouds.yaml"}}, + Optional: toPtr(true), + }, + }, + }, + corev1.Volume{ + Name: CombinedCABundleSecretName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: CombinedCABundleSecretName, + Items: []corev1.KeyToPath{{Key: "tls-ca-bundle.pem", Path: "tls-ca-bundle.pem"}}, + Optional: toPtr(true), + }, + }, + }, + corev1.Volume{ + Name: MCPConfigYAMLConfigMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: MCPConfigYAMLConfigMapName}, + Items: []corev1.KeyToPath{{Key: "config.yaml", Path: "config.yaml"}}, + }, + }, + }, + ) + + *mounts = append(*mounts, + corev1.VolumeMount{Name: SecureYAMLSecretName, MountPath: "/app/secure.yaml", SubPath: "secure.yaml"}, + corev1.VolumeMount{Name: CloudsYAMLConfigMapName, MountPath: "/app/clouds.yaml", SubPath: "clouds.yaml"}, + corev1.VolumeMount{Name: CombinedCABundleSecretName, MountPath: "/app/tls-ca-bundle.pem", SubPath: "tls-ca-bundle.pem", ReadOnly: true}, + corev1.VolumeMount{Name: MCPConfigYAMLConfigMapName, MountPath: "/app/config.yaml", SubPath: "config.yaml"}, + ) +} + // addCABundleVolumesAndMounts adds the CA bundle volume and mount. // The CA bundle is always present (created by reconcileCABundleConfigMap) // and mounted at the RHEL system CA path so applications find it automatically. @@ -765,5 +857,41 @@ func buildConfigMapAnnotations(h *common_helper.Helper, ctx context.Context) (ma annotations[PostgresSecretResourceVersionAnnotation] = postgresSecretVersion } + mcpVersion, err := getConfigMapResourceVersion(ctx, h, MCPConfigYAMLConfigMapName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get MCP config configmap resource version: %w", err) + } + } else { + annotations[MCPConfigMapResourceVersionAnnotation] = mcpVersion + } + + cloudsVersion, err := getConfigMapResourceVersion(ctx, h, CloudsYAMLConfigMapName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get clouds.yaml configmap resource version: %w", err) + } + } else { + annotations[CloudsYAMLConfigMapVersionAnnotation] = cloudsVersion + } + + secureVersion, err := getSecretResourceVersion(ctx, h, SecureYAMLSecretName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get secure.yaml secret resource version: %w", err) + } + } else { + annotations[SecureYAMLSecretVersionAnnotation] = secureVersion + } + + caBundleSecretVersion, err := getSecretResourceVersion(ctx, h, CombinedCABundleSecretName, h.GetBeforeObject().GetNamespace()) + if err != nil { + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get CA bundle secret resource version: %w", err) + } + } else { + annotations[CombinedCABundleSecretVersionAnnotation] = caBundleSecretVersion + } + return annotations, nil } diff --git a/internal/controller/mcp_server.go b/internal/controller/mcp_server.go new file mode 100644 index 0000000..c66d377 --- /dev/null +++ b/internal/controller/mcp_server.go @@ -0,0 +1,987 @@ +/* +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" + "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-k8s-operators/lightspeed-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/yaml" +) + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ( + // CloudsYAMLConfigMapName is the name of the ConfigMap containing clouds.yaml. + CloudsYAMLConfigMapName string = "openstack-config" + + // SecureYAMLSecretName is the name of the Secret containing secure.yaml. + SecureYAMLSecretName string = "openstack-config-secret" // #nosec G101 -- Kubernetes Secret resource name, not a credential + + // CombinedCABundleSecretName is the name of the Secret containing the TLS CA bundle. + CombinedCABundleSecretName string = "combined-ca-bundle" // #nosec G101 -- Kubernetes Secret resource name, not a credential + + // MCPConfigYAMLConfigMapName is the name of the ConfigMap containing config.yaml for the MCP server. + MCPConfigYAMLConfigMapName string = "mcp-config" + + // MCPServerPort is the port on which the MCP server listens. + MCPServerPort = 8080 +) + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +// mcpServerConfigTmpl is the parsed MCP server config template. +// Parsed once at package init from the //go:embed string mcpServerConfigTemplate. +var mcpServerConfigTmpl = template.Must(template.New("mcp-config").Parse(mcpServerConfigTemplate)) + +// mcpServerConfigParams holds the template parameters for the MCP server config. +type mcpServerConfigParams struct { + OpenStackEnabled bool + OpenShiftEnabled bool +} + +// deepMerge recursively merges override into base. When both sides have a map +// for the same key the maps are merged; otherwise the override value wins. +func deepMerge(base, override map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(base)) + for k, v := range base { + result[k] = v + } + for k, v := range override { + if baseMap, ok := result[k].(map[string]interface{}); ok { + if overrideMap, ok := v.(map[string]interface{}); ok { + result[k] = deepMerge(baseMap, overrideMap) + continue + } + } + result[k] = v + } + return result +} + +// buildMCPServerConfigData renders the MCP server config template and, when +// rhosMCPConfig is provided, deep-merges the user config on top. The +// openstack.enabled and openshift.enabled flags are always enforced. +func buildMCPServerConfigData(openStackReady bool, rhosMCPConfig string) (string, error) { + var buf bytes.Buffer + err := mcpServerConfigTmpl.Execute(&buf, mcpServerConfigParams{ + OpenStackEnabled: openStackReady, + OpenShiftEnabled: true, + }) + if err != nil { + return "", fmt.Errorf("failed to render MCP server config template: %w", err) + } + + if rhosMCPConfig == "" { + return buf.String(), nil + } + + var baseConfig map[string]interface{} + if err := yaml.Unmarshal(buf.Bytes(), &baseConfig); err != nil { + return "", fmt.Errorf("failed to parse base MCP server config: %w", err) + } + + var userConfig map[string]interface{} + if err := yaml.Unmarshal([]byte(rhosMCPConfig), &userConfig); err != nil { + return "", fmt.Errorf("failed to parse rhosMCPConfig: %w", err) + } + + merged := deepMerge(baseConfig, userConfig) + + osSection, ok := merged["openstack"].(map[string]interface{}) + if !ok { + osSection = make(map[string]interface{}) + merged["openstack"] = osSection + } + osSection["enabled"] = openStackReady + + ocpSection, ok := merged["openshift"].(map[string]interface{}) + if !ok { + ocpSection = make(map[string]interface{}) + merged["openshift"] = ocpSection + } + ocpSection["enabled"] = true + + result, err := yaml.Marshal(merged) + if err != nil { + return "", fmt.Errorf("failed to marshal merged MCP server config: %w", err) + } + return string(result), nil +} + +// BuildMCPServerConfigMap creates the ConfigMap for the MCP server configuration. +func BuildMCPServerConfigMap( + instance *apiv1beta1.OpenStackLightspeed, + openStackReady bool, +) (corev1.ConfigMap, error) { + devConfig, _ := parseDevConfig(instance) + configData, err := buildMCPServerConfigData(openStackReady, devConfig.RhosMCPConfig) + 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 +// --------------------------------------------------------------------------- + +// ReconcileMCPServerTask reconciles the MCP server as a ReconcileFunc. +func (r *OpenStackLightspeedReconciler) ReconcileMCPServerTask(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + openStackReady, mcpErr := r.ReconcileMCPServer(ctx, h, instance) + if mcpErr != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.DeploymentCheckFailedMessage, + mcpErr.Error(), + )) + return mcpErr + } + instance.Status.OpenStackReady = openStackReady + } else { + if err := r.cleanupMCPResources(ctx, h, instance); err != nil { + return err + } + instance.Status.OpenStackReady = false + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerDisabledMessage, + ) + } + return nil +} + +// ReconcileMCPServer performs the reconciliation of the MCP server. +// The MCP server runs as a sidecar in the LCore pod. The OpenStack MCP tools +// are only configured in lightspeed-stack when OpenStackControlPlane exists and is Ready. +// Returns whether OpenStackControlPlane is ready (for config generation). +func (r *OpenStackLightspeedReconciler) ReconcileMCPServer( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) (openStackReady bool, err error) { + crdReady, err := IsDynamicCRDWatched(r.DynamicWatchCRD, OpenStackControlPlaneGVK()) + if err != nil { + return false, err + } + + if !crdReady { + helper.GetLogger().Info("OpenStackControlPlane CRD not available, deploying MCP server without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + // The watch is registered, but the CRD may have been removed since. + crdEstablished, err := IsCRDEstablished(ctx, helper, OpenStackControlPlaneGVK()) + if err != nil { + return false, err + } + if !crdEstablished { + helper.GetLogger().Info("OpenStackControlPlane CRD was removed, deploying MCP server without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + openStackControlPlaneList, err := r.listOpenStackControlPlanes(ctx, helper) + if err != nil { + return false, err + } + + switch l := len(openStackControlPlaneList.Items); l { + case 0: + helper.GetLogger().Info("No OpenStackControlPlane found, deploying MCP server without OpenStack resources") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + + case 1: + oscp := &openStackControlPlaneList.Items[0] + return r.reconcileMCPServerWithOpenStack(ctx, helper, instance, oscp) + + default: + return false, errors.New("more than one OpenStackControlPlane found") + } +} + +// listOpenStackControlPlanes lists all OpenStackControlPlane instances. +// It first tries the cached client. If the cache returns 0 items (which +// may happen when the cache ByObject config does not cover the namespace +// where OpenStackControlPlane lives), it falls back to a direct API call. +func (r *OpenStackLightspeedReconciler) listOpenStackControlPlanes( + ctx context.Context, + helper *common_helper.Helper, +) (*uns.UnstructuredList, error) { + openStackControlPlaneList := &uns.UnstructuredList{} + openStackControlPlaneList.SetGroupVersionKind(OpenStackControlPlaneGVK().GroupVersion().WithKind("OpenStackControlPlaneList")) + + err := r.List(ctx, openStackControlPlaneList) + if err != nil && !k8s_errors.IsNotFound(err) { + return nil, err + } + + if len(openStackControlPlaneList.Items) > 0 { + return openStackControlPlaneList, nil + } + + rawClient, err := getRawClient(helper) + if err != nil { + return nil, fmt.Errorf("failed to get raw client for OpenStackControlPlane fallback list: %w", err) + } + + fallbackList := &uns.UnstructuredList{} + fallbackList.SetGroupVersionKind(OpenStackControlPlaneGVK().GroupVersion().WithKind("OpenStackControlPlaneList")) + if err := rawClient.List(ctx, fallbackList); err != nil { + if k8s_errors.IsNotFound(err) { + return fallbackList, nil + } + return nil, err + } + + if len(fallbackList.Items) > 0 { + helper.GetLogger().Info( + fmt.Sprintf("OpenStackControlPlane not found in cache but found %d via direct API call (cache may not cover the namespace)", len(fallbackList.Items)), + ) + } + + return fallbackList, nil +} + +// --------------------------------------------------------------------------- +// Cloud config parsing +// --------------------------------------------------------------------------- + +type cloudsYAML struct { + Clouds map[string]cloudYAMLEntry `json:"clouds"` +} + +type cloudYAMLEntry struct { + Auth cloudYAMLAuth `json:"auth"` + RegionName string `json:"region_name"` +} + +type cloudYAMLAuth struct { + AuthURL string `json:"auth_url"` + Username string `json:"username"` + Password string `json:"password"` +} + +// parseCloudConfig reads the openstackclient's clouds.yaml and secure.yaml +// from the OSCP namespace and returns the merged config for the first cloud. +func parseCloudConfig( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, +) (*cloudYAMLEntry, error) { + configMapName, _, _ := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigMap") + configSecretName, _, _ := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigSecret") + oscpNS := oscp.GetNamespace() + + kclient := helper.GetKClient() + + cm, err := kclient.CoreV1().ConfigMaps(oscpNS).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to read clouds.yaml configmap %s/%s: %w", oscpNS, configMapName, err) + } + + var clouds cloudsYAML + if err := yaml.Unmarshal([]byte(cm.Data["clouds.yaml"]), &clouds); err != nil { + return nil, fmt.Errorf("failed to parse clouds.yaml: %w", err) + } + + sec, err := kclient.CoreV1().Secrets(oscpNS).Get(ctx, configSecretName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to read secure.yaml secret %s/%s: %w", oscpNS, configSecretName, err) + } + + var secClouds cloudsYAML + if err := yaml.Unmarshal(sec.Data["secure.yaml"], &secClouds); err != nil { + return nil, fmt.Errorf("failed to parse secure.yaml: %w", err) + } + + var name string + switch len(clouds.Clouds) { + case 0: + return nil, errors.New("no cloud entry found in clouds.yaml") + case 1: + for name = range clouds.Clouds { + } + default: + if _, ok := clouds.Clouds["default"]; ok { + name = "default" + } else { + names := make([]string, 0, len(clouds.Clouds)) + for n := range clouds.Clouds { + names = append(names, n) + } + sort.Strings(names) + return nil, fmt.Errorf("clouds.yaml has multiple entries (%s) and none is named \"default\"", strings.Join(names, ", ")) + } + } + + entry := clouds.Clouds[name] + if secEntry, ok := secClouds.Clouds[name]; ok { + if secEntry.Auth.Password != "" { + entry.Auth.Password = secEntry.Auth.Password + } + } + return &entry, nil +} + +// --------------------------------------------------------------------------- +// OpenStack client +// --------------------------------------------------------------------------- + +func getOpenStackClient( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, + caPEM []byte, +) (*openstack_lib.OpenStack, *cloudYAMLEntry, error) { + log := helper.GetLogger() + + cloudCfg, err := parseCloudConfig(ctx, helper, oscp) + if err != nil { + return nil, nil, err + } + + // If caPEM was not provided, read it from the OSCP CA bundle secret. + if caPEM == nil { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + if caBundleSecretName != "" { + caSecret, err := helper.GetKClient().CoreV1().Secrets(oscp.GetNamespace()).Get(ctx, caBundleSecretName, metav1.GetOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to read CA bundle secret: %w", err) + } + caPEM = caSecret.Data["tls-ca-bundle.pem"] + } + } + + var tlsCfg *openstack_lib.TLSConfig + if len(caPEM) > 0 { + tlsCfg = &openstack_lib.TLSConfig{CACerts: []string{string(caPEM)}} + } + + osClient, err := openstack_lib.NewOpenStack(log, openstack_lib.AuthOpts{ + AuthURL: cloudCfg.Auth.AuthURL, + Username: cloudCfg.Auth.Username, + Password: cloudCfg.Auth.Password, + TenantName: "admin", + DomainName: "Default", + Region: cloudCfg.RegionName, + TLS: tlsCfg, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to authenticate with keystone: %w", err) + } + + return osClient, cloudCfg, nil +} + +// --------------------------------------------------------------------------- +// Service user management +// --------------------------------------------------------------------------- + +func ensurePasswordSecret( + ctx context.Context, + helper *common_helper.Helper, + namespace string, +) (string, error) { + kclient := helper.GetKClient() + + sec, err := kclient.CoreV1().Secrets(namespace).Get(ctx, LightspeedPasswordSecretName, metav1.GetOptions{}) + if err == nil { + return string(sec.Data[LightspeedPasswordSecretKey]), nil + } + if !k8s_errors.IsNotFound(err) { + return "", err + } + + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + password := hex.EncodeToString(b) + + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: LightspeedPasswordSecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + LightspeedPasswordSecretKey: []byte(password), + }, + } + _, err = kclient.CoreV1().Secrets(namespace).Create(ctx, newSecret, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create password secret: %w", err) + } + + return password, nil +} + +func ensureServiceUser( + ctx context.Context, + helper *common_helper.Helper, + osClient *openstack_lib.OpenStack, + oscpNamespace string, +) error { + log := helper.GetLogger() + + password, err := ensurePasswordSecret(ctx, helper, oscpNamespace) + if err != nil { + return err + } + + userID, err := osClient.CreateUser(log, openstack_lib.User{ + Name: LightspeedServiceUserName, + Password: password, + DomainID: LightspeedServiceUserDomain, + }) + if err != nil { + return fmt.Errorf("failed to create keystone user: %w", err) + } + + serviceProject, err := osClient.GetProject(log, "service", LightspeedServiceUserDomain) + if err != nil { + return fmt.Errorf("failed to get service project: %w", err) + } + + if err := osClient.AssignUserRole(log, "admin", userID, serviceProject.ID); err != nil { + return fmt.Errorf("failed to assign admin role: %w", err) + } + + return nil +} + +// --------------------------------------------------------------------------- +// Application credential management +// --------------------------------------------------------------------------- + +func ensureApplicationCredential( + ctx context.Context, + helper *common_helper.Helper, + namespace string, +) error { + rawClient, err := getRawClient(helper) + if err != nil { + return fmt.Errorf("failed to get raw client: %w", err) + } + + acCR := &uns.Unstructured{} + acCR.SetGroupVersionKind(KeystoneApplicationCredentialGVK()) + acCR.SetName(LightspeedACCRName) + acCR.SetNamespace(namespace) + + err = rawClient.Get(ctx, types.NamespacedName{Name: LightspeedACCRName, Namespace: namespace}, acCR) + if err == nil { + return nil + } + if !k8s_errors.IsNotFound(err) { + return err + } + + acCR.SetAnnotations(map[string]string{ + "keystone.openstack.org/edpm-service": "false", + }) + if err := uns.SetNestedField(acCR.Object, LightspeedServiceUserName, "spec", "userName"); err != nil { + return err + } + if err := uns.SetNestedField(acCR.Object, LightspeedPasswordSecretName, "spec", "secret"); err != nil { + return err + } + if err := uns.SetNestedField(acCR.Object, LightspeedPasswordSecretKey, "spec", "passwordSelector"); err != nil { + return err + } + if err := uns.SetNestedStringSlice(acCR.Object, []string{"admin"}, "spec", "roles"); err != nil { + return err + } + + helper.GetLogger().Info("Creating KeystoneApplicationCredential CR", "namespace", namespace) + return rawClient.Create(ctx, acCR) +} + +// reconcileACSecret reads the AC secret name from the CR status, manages +// finalizers for rotation safety, and returns the credential values. +func reconcileACSecret( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscpNamespace string, +) (acID, acSecret string, ready bool, err error) { + rawClient, err := getRawClient(helper) + if err != nil { + return "", "", false, err + } + + acCR := &uns.Unstructured{} + acCR.SetGroupVersionKind(KeystoneApplicationCredentialGVK()) + if err := rawClient.Get(ctx, types.NamespacedName{Name: LightspeedACCRName, Namespace: oscpNamespace}, acCR); err != nil { + return "", "", false, fmt.Errorf("failed to read AC CR: %w", err) + } + + secretName, _, _ := uns.NestedString(acCR.Object, "status", "secretName") + if secretName == "" { + return "", "", false, nil + } + + kclient := helper.GetKClient() + + // Add finalizer to current AC secret + acSecretObj, err := kclient.CoreV1().Secrets(oscpNamespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + if k8s_errors.IsNotFound(err) { + return "", "", false, nil + } + return "", "", false, err + } + + if !controllerutil.ContainsFinalizer(acSecretObj, LightspeedACFinalizerName) { + controllerutil.AddFinalizer(acSecretObj, LightspeedACFinalizerName) + if _, err := kclient.CoreV1().Secrets(oscpNamespace).Update(ctx, acSecretObj, metav1.UpdateOptions{}); err != nil { + return "", "", false, fmt.Errorf("failed to add finalizer to AC secret: %w", err) + } + } + + // Handle rotation: remove finalizer from previous secret + prevSecret := instance.Status.ApplicationCredentialSecret + if prevSecret != "" && prevSecret != secretName { + helper.GetLogger().Info("AC secret rotated", "old", prevSecret, "new", secretName) + oldSecret, err := kclient.CoreV1().Secrets(oscpNamespace).Get(ctx, prevSecret, metav1.GetOptions{}) + if err == nil && controllerutil.RemoveFinalizer(oldSecret, LightspeedACFinalizerName) { + if _, err := kclient.CoreV1().Secrets(oscpNamespace).Update(ctx, oldSecret, metav1.UpdateOptions{}); err != nil { + helper.GetLogger().Error(err, "Failed to remove finalizer from old AC secret", "secret", prevSecret) + } + } + } + + instance.Status.ApplicationCredentialSecret = secretName + + return string(acSecretObj.Data["AC_ID"]), string(acSecretObj.Data["AC_SECRET"]), true, nil +} + +// --------------------------------------------------------------------------- +// MCP credential generation +// --------------------------------------------------------------------------- + +func generateMCPCredentials( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + cloudCfg *cloudYAMLEntry, + acID, acSecret string, +) error { + type acAuth struct { + AuthURL string `json:"auth_url,omitempty"` + ApplicationCredentialID string `json:"application_credential_id,omitempty"` + ApplicationCredentialSecret string `json:"application_credential_secret,omitempty"` + } + type acEntry struct { + AuthType string `json:"auth_type,omitempty"` + Auth acAuth `json:"auth"` + RegionName string `json:"region_name,omitempty"` + IdentityAPIVersion int `json:"identity_api_version,omitempty"` + } + type acClouds struct { + Clouds map[string]acEntry `json:"clouds"` + } + + cloudsBytes, err := yaml.Marshal(acClouds{ + Clouds: map[string]acEntry{ + "default": { + AuthType: "v3applicationcredential", + Auth: acAuth{ + AuthURL: cloudCfg.Auth.AuthURL, + ApplicationCredentialID: acID, + }, + RegionName: cloudCfg.RegionName, + IdentityAPIVersion: 3, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to marshal clouds.yaml: %w", err) + } + + cloudsConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: CloudsYAMLConfigMapName, + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), cloudsConfigMap, func() error { + cloudsConfigMap.Data = map[string]string{"clouds.yaml": string(cloudsBytes)} + return controllerutil.SetControllerReference(instance, cloudsConfigMap, helper.GetScheme()) + }) + if err != nil { + return fmt.Errorf("failed to create clouds.yaml configmap: %w", err) + } + + secureBytes, err := yaml.Marshal(acClouds{ + Clouds: map[string]acEntry{ + "default": { + Auth: acAuth{ + ApplicationCredentialSecret: acSecret, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to marshal secure.yaml: %w", err) + } + + secureSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureYAMLSecretName, + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, helper.GetClient(), secureSecret, func() error { + secureSecret.Data = map[string][]byte{"secure.yaml": secureBytes} + if err := controllerutil.SetControllerReference(instance, secureSecret, helper.GetScheme()); err != nil { + return err + } + checksum, err := common_secret.Hash(secureSecret) + if err != nil { + return err + } + SetChecksumAnnotation(secureSecret, checksum) + return nil + }) + if err != nil { + return fmt.Errorf("failed to create secure.yaml secret: %w", err) + } + + return nil +} + +// --------------------------------------------------------------------------- +// CA bundle copy +// --------------------------------------------------------------------------- + +func readCABundle( + ctx context.Context, + helper *common_helper.Helper, + oscp *uns.Unstructured, +) ([]byte, error) { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + + secret, err := helper.GetKClient().CoreV1().Secrets(oscp.GetNamespace()).Get(ctx, caBundleSecretName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return secret.Data["tls-ca-bundle.pem"], nil +} + +func copyCABundle( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, +) error { + caBundleSecretName, _, _ := uns.NestedString(oscp.Object, "status", "tls", "caBundleSecretName") + + source := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: caBundleSecretName, + Namespace: oscp.GetNamespace(), + }, + } + target := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: CombinedCABundleSecretName, + Namespace: instance.Namespace, + }, + } + + _, err := CopyResource(ctx, helper, source, target, instance, helper.GetScheme()) + return err +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +// reconcileMCPServerWithOpenStack creates a service user, application credential, +// generates credential files, and reconciles the MCP config. +func (r *OpenStackLightspeedReconciler) reconcileMCPServerWithOpenStack( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, + oscp *uns.Unstructured, +) (bool, error) { + log := helper.GetLogger() + oscpNS := oscp.GetNamespace() + + fieldsReady, err := extractOSCPFields(helper, oscp) + if err != nil { + log.Info(fmt.Sprintf("OpenStackControlPlane field check failed: %v", err)) + return false, err + } + if !fieldsReady { + log.Info("OpenStackControlPlane fields not ready, deploying MCP without OpenStack") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + caPEM, err := readCABundle(ctx, helper, oscp) + if err != nil { + if k8s_errors.IsNotFound(err) { + log.Info("CA bundle not found, deploying MCP without OpenStack") + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + return false, err + } + + osClient, cloudCfg, err := getOpenStackClient(ctx, helper, oscp, caPEM) + if err != nil { + return false, fmt.Errorf("failed to get OpenStack client: %w", err) + } + + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackLightspeedMCPServerCreatingUser, + )) + + if err := ensureServiceUser(ctx, helper, osClient, oscpNS); err != nil { + return false, fmt.Errorf("failed to ensure service user: %w", err) + } + + if err := ensureApplicationCredential(ctx, helper, oscpNS); err != nil { + return false, fmt.Errorf("failed to ensure application credential: %w", err) + } + + acID, acSec, ready, err := reconcileACSecret(ctx, helper, instance, oscpNS) + if err != nil { + return false, err + } + if !ready { + log.Info("Application credential secret not ready, deploying MCP without OpenStack") + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackLightspeedMCPServerWaitingAC, + )) + return false, r.reconcileMCPServerDeploy(ctx, helper, instance, false) + } + + if err := copyCABundle(ctx, helper, instance, oscp); err != nil { + return false, fmt.Errorf("failed to copy CA bundle: %w", err) + } + + if err := generateMCPCredentials(ctx, helper, instance, cloudCfg, acID, acSec); err != nil { + return false, err + } + + if err := r.reconcileMCPServerDeploy(ctx, helper, instance, true); err != nil { + return false, err + } + + log.Info("MCP server reconciled with application credentials") + 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 +} + +// 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, +) (bool, error) { + configSecret, found, err := uns.NestedString(oscp.Object, "spec", "openstackclient", "template", "openStackConfigSecret") + if err != nil || !found || configSecret == "" { + 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 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 false, nil + } + + return true, nil +} + +// --------------------------------------------------------------------------- +// Deletion +// --------------------------------------------------------------------------- + +// cleanupMCPResources removes MCP server resources when the rhoso_mcps feature +// flag is disabled. +func (r *OpenStackLightspeedReconciler) cleanupMCPResources( + ctx context.Context, + helper *common_helper.Helper, + instance *apiv1beta1.OpenStackLightspeed, +) error { + ns := instance.Namespace + + resources := []client.Object{ + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: MCPConfigYAMLConfigMapName, Namespace: ns}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: CloudsYAMLConfigMapName, Namespace: ns}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: SecureYAMLSecretName, Namespace: ns}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: CombinedCABundleSecretName, Namespace: ns}}, + } + + for _, obj := range resources { + if err := helper.GetClient().Delete(ctx, obj); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("failed to delete %s %s: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err) + } + } + + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return fmt.Errorf("failed to clean up OpenStack resources during RHOSO MCP disable: %w", err) + } + + helper.GetLogger().Info("RHOSO MCP resources cleaned up") + return nil +} + +// 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, +) error { + log := helper.GetLogger() + + crdReady, err := IsDynamicCRDWatched(r.DynamicWatchCRD, OpenStackControlPlaneGVK()) + if err != nil || !crdReady { + return err + } + + openStackControlPlaneList, err := r.listOpenStackControlPlanes(ctx, helper) + if err != nil { + return fmt.Errorf("failed to list OpenStackControlPlanes during deletion: %w", err) + } + if len(openStackControlPlaneList.Items) != 1 { + return nil + } + oscp := &openStackControlPlaneList.Items[0] + oscpNS := oscp.GetNamespace() + + // Remove finalizer from AC secret + if instance.Status.ApplicationCredentialSecret != "" { + acSecret, err := helper.GetKClient().CoreV1().Secrets(oscpNS).Get( + ctx, instance.Status.ApplicationCredentialSecret, metav1.GetOptions{}) + if err == nil && controllerutil.RemoveFinalizer(acSecret, LightspeedACFinalizerName) { + if _, err := helper.GetKClient().CoreV1().Secrets(oscpNS).Update(ctx, acSecret, metav1.UpdateOptions{}); err != nil { + log.Error(err, "Failed to remove finalizer from AC secret") + } + } + } + + // 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) + } + } 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 nil +} diff --git a/internal/controller/mcp_server_test.go b/internal/controller/mcp_server_test.go new file mode 100644 index 0000000..d84ce5c --- /dev/null +++ b/internal/controller/mcp_server_test.go @@ -0,0 +1,200 @@ +/* +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" + + "sigs.k8s.io/yaml" +) + +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 TestBuildMCPServerConfigData_CustomConfig_OverridesEnabled(t *testing.T) { + customConfig := ` +openstack: + enabled: true + allow_write: true +openshift: + enabled: false +` + result, err := buildMCPServerConfigData(false, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + osSection := parsed["openstack"].(map[string]interface{}) + if osSection["enabled"] != false { + t.Errorf("expected openstack.enabled=false (operator override), got %v", osSection["enabled"]) + } + if osSection["allow_write"] != true { + t.Errorf("expected openstack.allow_write=true (user value), got %v", osSection["allow_write"]) + } + + ocpSection := parsed["openshift"].(map[string]interface{}) + if ocpSection["enabled"] != true { + t.Errorf("expected openshift.enabled=true (operator override), got %v", ocpSection["enabled"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_DeepMergesWithDefaults(t *testing.T) { + customConfig := ` +debug: true +workers: 4 +` + result, err := buildMCPServerConfigData(false, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if parsed["debug"] != true { + t.Errorf("expected debug=true (user override), got %v", parsed["debug"]) + } + if parsed["workers"] != float64(4) { + t.Errorf("expected workers=4 (user override), got %v", parsed["workers"]) + } + if parsed["port"] != float64(8080) { + t.Errorf("expected port=8080 (template default preserved), got %v", parsed["port"]) + } + + osSection := parsed["openstack"].(map[string]interface{}) + if osSection["ca_cert"] != "./tls-ca-bundle.pem" { + t.Errorf("expected openstack.ca_cert preserved from template, got %v", osSection["ca_cert"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_AddsNewFields(t *testing.T) { + customConfig := ` +custom_section: + key1: value1 + key2: 42 +` + result, err := buildMCPServerConfigData(true, customConfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + custom := parsed["custom_section"].(map[string]interface{}) + if custom["key1"] != "value1" { + t.Errorf("expected custom_section.key1=value1, got %v", custom["key1"]) + } + if custom["key2"] != float64(42) { + t.Errorf("expected custom_section.key2=42, got %v", custom["key2"]) + } +} + +func TestBuildMCPServerConfigData_CustomConfig_InvalidYAML(t *testing.T) { + _, err := buildMCPServerConfigData(false, "not: valid: yaml: [") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestBuildLCoreMCPServersConfig_WithOpenStack(t *testing.T) { + servers := buildLCoreMCPServersConfig(true) + + if len(servers) != 2 { + t.Errorf("expected 2 MCP servers, got %d", len(servers)) + } + + // Verify first server is OpenShift MCP + first := servers[0].(map[string]interface{}) + if first["name"] != "rhoso-ocp-tools" { + t.Errorf("expected first server name 'rhoso-ocp-tools', got '%s'", first["name"]) + } + expectedURL := fmt.Sprintf("http://127.0.0.1:%d/openshift/", MCPServerPort) + if first["url"] != expectedURL { + t.Errorf("expected first server url '%s', got '%s'", expectedURL, first["url"]) + } + authHeaders := first["authorization_headers"].(map[string]interface{}) + if authHeaders["OCP_TOKEN"] != "kubernetes" { + t.Errorf("expected OCP_TOKEN authorization_header 'kubernetes', got '%s'", authHeaders["OCP_TOKEN"]) + } + + // Verify second server is OpenStack MCP + second := servers[1].(map[string]interface{}) + if second["name"] != "rhoso-osp-tools" { + t.Errorf("expected second server name 'rhoso-osp-tools', got '%s'", second["name"]) + } + expectedURL = fmt.Sprintf("http://127.0.0.1:%d/openstack/", MCPServerPort) + if second["url"] != expectedURL { + t.Errorf("expected second server url '%s', got '%s'", expectedURL, second["url"]) + } +} + +func TestBuildLCoreMCPServersConfig_WithoutOpenStack(t *testing.T) { + servers := buildLCoreMCPServersConfig(false) + + if len(servers) != 1 { + t.Errorf("expected 1 MCP server, got %d", len(servers)) + } + + // Verify only OpenShift MCP is present + first := servers[0].(map[string]interface{}) + if first["name"] != "rhoso-ocp-tools" { + t.Errorf("expected first server name 'rhoso-ocp-tools', got '%s'", first["name"]) + } +} + +func TestGetMCPServerURL(t *testing.T) { + expected := fmt.Sprintf("http://127.0.0.1:%d", MCPServerPort) + actual := GetMCPServerURL() + if actual != expected { + t.Errorf("expected '%s', got '%s'", expected, actual) + } +} diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index 5556164..450becd 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "fmt" + "sync/atomic" "github.com/go-logr/logr" consolev1 "github.com/openshift/api/console/v1" @@ -28,25 +29,45 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" ) +// DynamicWatchCRD maps GroupVersionKinds to a flag indicating whether a +// controller-runtime watch is currently registered. The flag is set to true +// when a watch is registered and reset to false when the CRD is removed and +// the broken informer is cleaned up via cache.RemoveInformer. CRDs in this +// map do not need to exist at operator startup. +type DynamicWatchCRD map[schema.GroupVersionKind]*atomic.Bool + // OpenStackLightspeedReconciler reconciles a OpenStackLightspeed object type OpenStackLightspeedReconciler struct { client.Client - Scheme *runtime.Scheme - Kclient kubernetes.Interface + Scheme *runtime.Scheme + Kclient kubernetes.Interface + controller controller.Controller + Cache cache.Cache + + // DynamicWatchCRD contains the list of CRDs that the operator should monitor. + // These CRDs do not need to exist when the operator starts. Once the operator + // detects that a CRD exists, it automatically registers a watch for it. + DynamicWatchCRD DynamicWatchCRD } // GetLogger returns a logger object with a prefix of "controller.name" and additional controller context fields @@ -62,7 +83,20 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,verbs=get;list;watch // +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,namespace=openstack-lightspeed,verbs=update;patch;delete // +kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch +// SAR role escalation: the operator creates a ClusterRole granting pull-secret GET, +// so it must hold that permission itself (K8s RBAC escalation prevention). // +kubebuilder:rbac:groups="",resources=secrets,resourceNames=pull-secret,verbs=get +// Cross-namespace secrets: GET reads OSCP secrets (CA bundle, clouds.yaml, secure.yaml needed to create user); +// CREATE creates lightspeed-password in the OSCP namespace (resourceNames can't restrict create); +// UPDATE adds/removes finalizers on the AC secret (dynamic name set by keystone-operator). +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;create;update +// lightspeed-password lifecycle: update credentials on rotation, delete on CR teardown. +// +kubebuilder:rbac:groups="",resources=secrets,resourceNames=lightspeed-password,verbs=get;update;delete +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get +// +kubebuilder:rbac:groups=core.openstack.org,resources=openstackcontrolplanes,verbs=get;list;watch +// +kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials/status,verbs=get // +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update // +kubebuilder:rbac:groups=apps,resources=deployments,namespace=openstack-lightspeed,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=configmaps,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update;delete @@ -73,7 +107,7 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups=operator.openshift.io,resources=consoles,verbs=watch;list;get;update // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update -func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, e error) { Log := r.GetLogger(ctx) Log.Info("OpenStackLightspeed Reconciling") @@ -98,6 +132,11 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } + err = r.WatchDynamicCRD(ctx, helper) + if err != nil { + return ctrl.Result{}, err + } + // Save a copy of the conditions so that we can restore the LastTransitionTime // when a condition's state doesn't change. savedConditions := instance.Status.Conditions.DeepCopy() @@ -129,6 +168,27 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. return } + // Poll when any dynamically watched CRD is missing, or when + // rhoso_mcps is enabled — the cache-based watch only covers + // the operator namespace, so polling detects cross-namespace + // OpenStackControlPlane changes (readiness, CA rotations, etc.). + if result.RequeueAfter == 0 { + needsPoll := false + for _, seen := range r.DynamicWatchCRD { + if !seen.Load() { + needsPoll = true + break + } + } + if !needsPoll { + if enabled, _ := isRHOSOMCPEnabled(instance); enabled { + needsPoll = true + } + } + if needsPoll { + result.RequeueAfter = ResourceCreationTimeout + } + } }() cl := condition.CreateList( @@ -137,6 +197,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) @@ -163,6 +228,7 @@ func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl. } reconcileTasks := []ReconcileTask{ + {Name: "MCPServer", Task: r.ReconcileMCPServerTask}, {Name: "PostgresResources", Task: ReconcilePostgresResources}, {Name: "PostgresDeployment", Task: ReconcilePostgresDeployment}, {Name: "OKPDeployment", Task: ReconcileOKPDeployment}, @@ -208,6 +274,10 @@ func (r *OpenStackLightspeedReconciler) reconcileDelete( return err } + if err := r.reconcileDeleteOpenStackResources(ctx, helper, instance); err != nil { + return err + } + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) Log.Info("OpenStackLightspeed Reconciling Delete completed") @@ -249,6 +319,26 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( } } + // Mark MCP server condition based on readiness (only when RHOSO MCP is enabled; + // when disabled the condition was already set in Reconcile). + rhosoMCPEnabled, err := isRHOSOMCPEnabled(instance) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse dev config: %w", err) + } + if rhosoMCPEnabled { + if instance.Status.OpenStackReady { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerDeployed, + ) + } else { + instance.Status.Conditions.MarkTrue( + apiv1beta1.OpenStackLightspeedMCPServerReadyCondition, + apiv1beta1.OpenStackLightspeedMCPServerWaitingOpenStack, + ) + } + } + instance.Status.Conditions.MarkTrue( apiv1beta1.OpenStackLightspeedReadyCondition, apiv1beta1.OpenStackLightspeedReadyMessage, @@ -261,7 +351,8 @@ func (r *OpenStackLightspeedReconciler) reconcileStatus( // SetupWithManager sets up the controller with the Manager. func (r *OpenStackLightspeedReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + // Use Build instead of Complete to get the controller reference needed by WatchDynamicCRD. + c, err := ctrl.NewControllerManagedBy(mgr). For(&apiv1beta1.OpenStackLightspeed{}). Owns(&operatorsv1alpha1.ClusterServiceVersion{}). Owns(&appsv1.Deployment{}). @@ -282,7 +373,27 @@ 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{}, + predicate.NewPredicateFuncs(func(obj client.Object) bool { + for gvk := range r.DynamicWatchCRD { + if obj.GetName() == GetCRDName(gvk) { + return true + } + } + return false + }), + ), + ). + Build(r) + if err != nil { + return err + } + r.controller = c + return nil } // NotifyOpenStackLightspeedsByCAConfigMap watches ConfigMaps and triggers reconciliation when @@ -338,3 +449,64 @@ func (r *OpenStackLightspeedReconciler) NotifyAllOpenStackLightspeeds(ctx contex return requests } + +// WatchDynamicCRD dynamically registers watches for resources whose CRDs are +// listed in r.DynamicWatchCRD. When a CRD is detected as established, it +// registers a watch and sets the flag to true. When a previously-watched CRD +// is removed, it cleans up the broken informer via cache.RemoveInformer and +// resets the flag to false. The next reconcile after CRD recreation will +// register a fresh watch. +func (r *OpenStackLightspeedReconciler) WatchDynamicCRD( + ctx context.Context, + helper *common_helper.Helper, +) error { + Log := r.GetLogger(ctx) + + for gvk, seen := range r.DynamicWatchCRD { + crdAvailable, err := IsCRDEstablished(ctx, helper, gvk) + if err != nil { + return err + } + + if seen.Load() { + if crdAvailable { + continue + } + + // CRD removed — clean up the broken informer so a fresh one + // can be created when the CRD reappears. + Log.Info("CRD removed, cleaning up stale informer", "crd", GetCRDName(gvk)) + obj := &uns.Unstructured{} + obj.SetGroupVersionKind(gvk) + if err := r.Cache.RemoveInformer(ctx, obj); err != nil { + return fmt.Errorf("failed to remove informer for %s: %w", GetCRDName(gvk), err) + } + seen.Store(false) + continue + } + + if !crdAvailable { + continue + } + + GVKUnstructObj := &uns.Unstructured{} + GVKUnstructObj.SetGroupVersionKind(gvk) + err = r.controller.Watch( + source.Kind( + r.Cache, + GVKUnstructObj, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o *uns.Unstructured) []ctrl.Request { + return r.NotifyAllOpenStackLightspeeds(ctx, o) + }), + predicate.TypedResourceVersionChangedPredicate[*uns.Unstructured]{}, + ), + ) + if err != nil { + return fmt.Errorf("failed to set up watch for %s: %w", GetCRDName(gvk), err) + } + + seen.Store(true) + } + + return nil +} diff --git a/internal/controller/openstacklightspeed_controller_test.go b/internal/controller/openstacklightspeed_controller_test.go index feec7c6..f0f88d6 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,10 @@ var _ = Describe("OpenStackLightspeed Controller", func() { controllerReconciler := &OpenStackLightspeedReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), + DynamicWatchCRD: DynamicWatchCRD{ + OpenStackControlPlaneGVK(): new(atomic.Bool), + KeystoneApplicationCredentialGVK(): new(atomic.Bool), + }, } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ diff --git a/internal/controller/postgres_reconciler.go b/internal/controller/postgres_reconciler.go index 5adfb93..75e53a8 100644 --- a/internal/controller/postgres_reconciler.go +++ b/internal/controller/postgres_reconciler.go @@ -99,7 +99,6 @@ func reconcilePostgresBootstrapSecret(h *common_helper.Helper, ctx context.Conte PostgresBootstrapScript: PostgresBootStrapScriptContent, PostgresBootstrapSQLScript: PostgresBootStrapSQLContent, } - // Set owner reference return controllerutil.SetControllerReference(h.GetBeforeObject(), secret, h.GetScheme()) }) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml index 78dc28c..1224e4a 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack-update.yaml @@ -71,6 +71,7 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false +mcp_servers: [] name: Lightspeed Core Service (LCS) okp: chunk_filter_query: product:(*openstack* OR *openshift*) diff --git a/test/kuttl/common/expected-configs/lightspeed-stack.yaml b/test/kuttl/common/expected-configs/lightspeed-stack.yaml index d9c3db8..2e2a6e3 100644 --- a/test/kuttl/common/expected-configs/lightspeed-stack.yaml +++ b/test/kuttl/common/expected-configs/lightspeed-stack.yaml @@ -71,6 +71,7 @@ inference: llama_stack: url: http://localhost:8321 use_as_library_client: false +mcp_servers: [] name: Lightspeed Core Service (LCS) okp: chunk_filter_query: product:(*openstack* OR *openshift*) diff --git a/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml b/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml new file mode 100644 index 0000000..5c7a64e --- /dev/null +++ b/test/kuttl/common/mock-openstack/assert-mock-openstack.yaml @@ -0,0 +1,41 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack +status: + phase: Running +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: openstack-galera-network-isolation + namespace: openstack diff --git a/test/kuttl/common/mock-openstack/assert-openstack-crds.yaml b/test/kuttl/common/mock-openstack/assert-openstack-crds.yaml new file mode 100644 index 0000000..4c279f4 --- /dev/null +++ b/test/kuttl/common/mock-openstack/assert-openstack-crds.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +status: + conditions: + - type: NamesAccepted + status: "True" + - type: Established + status: "True" diff --git a/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml b/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml new file mode 100644 index 0000000..1f79624 --- /dev/null +++ b/test/kuttl/common/mock-openstack/cleanup-mock-openstack.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: core.openstack.org/v1beta1 + kind: OpenStackControlPlane + name: openstack-galera-network-isolation + namespace: openstack + - apiVersion: v1 + kind: Pod + name: mock-keystone-server + namespace: openstack + - apiVersion: v1 + kind: Service + name: mock-keystone-server + namespace: openstack + - apiVersion: v1 + kind: ConfigMap + name: mock-keystone-code + namespace: openstack + - apiVersion: v1 + kind: ConfigMap + name: openstack-config-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: openstack-config-secret-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: combined-ca-bundle-mock + namespace: openstack + - apiVersion: v1 + kind: Secret + name: ac-lightspeed-test-secret + namespace: openstack + - apiVersion: v1 + kind: Namespace + name: openstack + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: openstackcontrolplanes.core.openstack.org + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: keystoneapplicationcredentials.keystone.openstack.org diff --git a/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml b/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml new file mode 100644 index 0000000..75ccf20 --- /dev/null +++ b/test/kuttl/common/mock-openstack/errors-mock-openstack.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-keystone-server + namespace: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-keystone-code + namespace: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config-mock + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret-mock + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle-mock + namespace: openstack +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack diff --git a/test/kuttl/common/mock-openstack/kac-crd.yaml b/test/kuttl/common/mock-openstack/kac-crd.yaml new file mode 100644 index 0000000..4df9372 --- /dev/null +++ b/test/kuttl/common/mock-openstack/kac-crd.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org +spec: + group: keystone.openstack.org + names: + kind: KeystoneApplicationCredential + listKind: KeystoneApplicationCredentialList + plural: keystoneapplicationcredentials + singular: keystoneapplicationcredential + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/test/kuttl/common/mock-openstack/mock-keystone.yaml b/test/kuttl/common/mock-openstack/mock-keystone.yaml new file mode 100644 index 0000000..60a851d --- /dev/null +++ b/test/kuttl/common/mock-openstack/mock-keystone.yaml @@ -0,0 +1,144 @@ +############################################################################## +# Mock Keystone v3 API server for application credential kuttl tests # +############################################################################## +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openstack +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-keystone-code + namespace: openstack +data: + app.py: | + from http.server import HTTPServer, BaseHTTPRequestHandler + import json + + URL = "http://mock-keystone-server.openstack.svc:5000/v3" + TOKEN = "mock-token-12345" + USER_ID = "mock-user-id-lightspeed" + PROJECT_ID = "mock-project-id-service" + ROLE_ID = "mock-role-id-admin" + + AUTH_RESPONSE = json.dumps({"token": { + "methods": ["password"], + "expires_at": "2099-12-31T23:59:59.000000Z", + "user": {"id": "admin-user-id", "name": "admin", + "domain": {"id": "default", "name": "Default"}}, + "project": {"id": "admin-project-id", "name": "admin", + "domain": {"id": "default", "name": "Default"}}, + "catalog": [{"name": "keystone", "type": "identity", "endpoints": [ + {"region_id": "regionOne", "region": "regionOne", + "url": URL, "interface": i, "id": "ep-" + i} + for i in ("admin", "internal", "public") + ]}] + }}) + + class Handler(BaseHTTPRequestHandler): + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length else b"" + + def _json(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _no_content(self): + self.send_response(204) + self.end_headers() + + def do_POST(self): + self._read_body() + if self.path == "/v3/auth/tokens": + data = AUTH_RESPONSE.encode() + self.send_response(201) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("X-Subject-Token", TOKEN) + self.end_headers() + self.wfile.write(data) + elif self.path == "/v3/users": + self._json(201, {"user": {"id": USER_ID, "name": "lightspeed", + "domain_id": "default", "enabled": True, + "password_expires_at": None, + "links": {"self": URL + "/users/" + USER_ID}}}) + else: + self._json(404, {"error": "not found"}) + + def do_GET(self): + if self.path.startswith("/v3/projects"): + self._json(200, {"projects": [ + {"id": PROJECT_ID, "name": "service", "domain_id": "default", + "enabled": True, "links": {"self": URL + "/projects/" + PROJECT_ID}} + ], "links": {"self": URL + "/projects", "next": None, "previous": None}}) + elif self.path.startswith("/v3/roles"): + self._json(200, {"roles": [ + {"id": ROLE_ID, "name": "admin", + "links": {"self": URL + "/roles/" + ROLE_ID}} + ], "links": {"self": URL + "/roles", "next": None, "previous": None}}) + elif self.path.startswith("/v3/users"): + self._json(200, {"users": [ + {"id": USER_ID, "name": "lightspeed", "domain_id": "default", + "enabled": True, "links": {"self": URL + "/users/" + USER_ID}} + ], "links": {"self": URL + "/users", "next": None, "previous": None}}) + else: + self._json(200, {}) + + def do_PUT(self): + self._read_body() + self._no_content() + + def do_DELETE(self): + self._no_content() + + def do_PATCH(self): + self._read_body() + self._json(200, {"user": {"id": USER_ID}}) + + print("Mock keystone starting on :5000", flush=True) + HTTPServer(("", 5000), Handler).serve_forever() +--- +apiVersion: v1 +kind: Pod +metadata: + name: mock-keystone-server + namespace: openstack + labels: + app: mock-keystone-server +spec: + containers: + - name: mock-keystone + image: registry.redhat.io/ubi8/python-311:latest + ports: + - containerPort: 5000 + volumeMounts: + - name: app-code + mountPath: /app + workingDir: /app + command: + - python3 + - /app/app.py + volumes: + - name: app-code + configMap: + name: mock-keystone-code +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-keystone-server + namespace: openstack +spec: + selector: + app: mock-keystone-server + ports: + - protocol: TCP + port: 5000 + targetPort: 5000 diff --git a/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml b/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml new file mode 100644 index 0000000..af9b903 --- /dev/null +++ b/test/kuttl/common/mock-openstack/mock-oscp-resources.yaml @@ -0,0 +1,86 @@ +############################################################################## +# Mock OpenStackControlPlane and openstackclient resources for AC tests # +############################################################################## + +# openstackclient clouds.yaml ConfigMap +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config-mock + namespace: openstack +data: + clouds.yaml: | + clouds: + default: + auth: + auth_url: http://mock-keystone-server.openstack.svc:5000/v3 + username: admin + region_name: regionOne + +# openstackclient secure.yaml Secret +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: openstack-config-secret-mock + namespace: openstack +stringData: + secure.yaml: | + clouds: + default: + auth: + password: admin-password + +# CA bundle Secret (dummy cert, keystone uses HTTP) +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: combined-ca-bundle-mock + namespace: openstack +stringData: + tls-ca-bundle.pem: | + -----BEGIN CERTIFICATE----- + MIIEMDCCAxigAwIBAgIJANqb7HHzA7AZMA0GCSqGSIb3DQEBCwUAMIGkMQswCQYD + VQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEk + MCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U + cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRydXN0Q29y + IFJvb3RDZXJ0IENBLTEwHhcNMTYwMjA0MTIzMjE2WhcNMjkxMjMxMTcyMzE2WjCB + pDELMAkGA1UEBhMCUEExDzANBgNVBAgMBlBhbmFtYTEUMBIGA1UEBwwLUGFuYW1h + IENpdHkxJDAiBgNVBAoMG1RydXN0Q29yIFN5c3RlbXMgUy4gZGUgUi5MLjEnMCUG + A1UECwweVHJ1c3RDb3IgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MR8wHQYDVQQDDBZU + cnVzdENvciBSb290Q2VydCBDQS0xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEAv463leLCJhJrMxnHQFgKq1mqjQCj/IDHUHuO1CAmujIS2CNUSSUQIpid + RtLByZ5OGy4sDjjzGiVoHKZaBeYei0i/mJZ0PmnK6bV4pQa81QBeCQryJ3pS/C3V + seq0iWEk8xoT26nPUu0MJLq5nux+AHT6k61sKZKuUbS701e/s/OojZz0JEsq1pme + 9J7+wH5COucLlVPat2gOkEz7cD+PSiyU8ybdY2mplNgQTsVHCJCZGxdNuWxu72CV + EY4hgLW9oHPY0LJ3xEXqWib7ZnZ2+AYfYW0PVcWDtxBWcgYHpfOxGgMFZA6dWorW + hnAbJN7+KIor0Gqw/Hqi3LJ5DotlDwIDAQABo2MwYTAdBgNVHQ4EFgQU7mtJPHo/ + DeOxCbeKyKsZn3MzUOcwHwYDVR0jBBgwFoAU7mtJPHo/DeOxCbeKyKsZn3MzUOcw + DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD + ggEBACUY1JGPE+6PHh0RU9otRCkZoB5rMZ5NDp6tPVxBb5UrJKF5mDo4Nvu7Zp5I + /5CQ7z3UuJu0h3U/IJvOcs+hVcFNZKIZBqEHMwwLKeXx6quj7LUKdJDHfXLy11yf + ke+Ri7fc7Waiz45mO7yfOgLgJ90WmMCV1Aqk5IGadZQ1nJBfiDcGrVmVCrDRZ9MZ + yonnMlo2HD6CqFqTvsbQZJG2z9m2GM/bftJlo6bEjhcxwft+dtvTheNYsnd6djts + L1Ac59v2Z3kf9YKVmgenFK+P3CghZwnS1k1aHBkcjndcw5QkPTJrS37UeJSDvjdN + zl/HHk484IkzlQsPpTLWPFp5LBk= + -----END CERTIFICATE----- + +# OpenStackControlPlane instance +--- +apiVersion: core.openstack.org/v1beta1 +kind: OpenStackControlPlane +metadata: + name: openstack-galera-network-isolation + namespace: openstack +spec: + openstackclient: + template: + openStackConfigMap: openstack-config-mock + openStackConfigSecret: openstack-config-secret-mock +status: + tls: + caBundleSecretName: combined-ca-bundle-mock diff --git a/test/kuttl/common/mock-openstack/oscp-crd.yaml b/test/kuttl/common/mock-openstack/oscp-crd.yaml new file mode 100644 index 0000000..c03d5bb --- /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 0000000..68aaf55 --- /dev/null +++ b/test/kuttl/common/mock-openstack/simulate-keystone-operator.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Simulates what the keystone-operator would do when processing a +# KeystoneApplicationCredential CR: create an AC secret and update the +# CR status with the secret name. +set -euo pipefail + +NAMESPACE="openstack" +AC_CR_NAME="lightspeed" +AC_SECRET_NAME="ac-lightspeed-test-secret" +AC_ID="mock-ac-id-12345" +AC_SECRET="mock-ac-secret-abcde" + +echo "Waiting for KeystoneApplicationCredential CR to exist..." +for i in $(seq 1 60); do + if oc get keystoneapplicationcredentials.keystone.openstack.org "$AC_CR_NAME" \ + -n "$NAMESPACE" 2>/dev/null; then + echo "AC CR found" + break + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: AC CR not found after 120s" + exit 1 + fi + sleep 2 +done + +echo "Creating AC secret..." +oc apply -f - </) + +NAMESPACE="${NAMESPACE:-openstack-lightspeed}" +CR_NAME="${CR_NAME:-openstack-lightspeed}" + +assert_mcp_openstack_enabled() { + local expected="$1" + local mcp_data + mcp_data=$(oc get configmap mcp-config -n "$NAMESPACE" \ + -o jsonpath='{.data.config\.yaml}') + local enabled + enabled=$(echo "$mcp_data" | grep -A1 "openstack:" | grep "enabled:" | tr -d ' ') + if [ "$enabled" != "enabled:${expected}" ]; then + echo "ERROR: Expected MCP openstack enabled:${expected}, got ${enabled}" + echo "$mcp_data" + exit 1 + fi +} + +assert_openstack_ready() { + local expected="$1" + local ready + ready=$(oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" \ + -o jsonpath='{.status.openStackReady}' 2>/dev/null || true) + if [ "$expected" = "true" ]; then + if [ "$ready" != "true" ]; then + echo "ERROR: Expected openStackReady to be true, got '${ready}'" + exit 1 + fi + else + if [ "$ready" = "true" ]; then + echo "ERROR: Expected openStackReady to not be true, got '${ready}'" + exit 1 + fi + fi +} + +assert_condition_status() { + local condition_type="$1" + local expected_status="$2" + local actual + actual=$(oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" \ + -o jsonpath="{.status.conditions[?(@.type==\"${condition_type}\")].status}") + if [ "$actual" != "$expected_status" ]; then + echo "ERROR: Expected condition ${condition_type}=${expected_status}, got '${actual}'" + oc get openstacklightspeed "$CR_NAME" -n "$NAMESPACE" -o yaml + exit 1 + fi +} diff --git a/test/kuttl/tests/application-credentials/00-mock-resources.yaml b/test/kuttl/tests/application-credentials/00-mock-resources.yaml new file mode 120000 index 0000000..8235a1f --- /dev/null +++ b/test/kuttl/tests/application-credentials/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/application-credentials/01-assert-mock-objects-created.yaml b/test/kuttl/tests/application-credentials/01-assert-mock-objects-created.yaml new file mode 120000 index 0000000..07f977a --- /dev/null +++ b/test/kuttl/tests/application-credentials/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/application-credentials/02-assert-openstack-crds.yaml b/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/application-credentials/02-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/02-create-kac-crd.yaml b/test/kuttl/tests/application-credentials/02-create-kac-crd.yaml new file mode 120000 index 0000000..6c9e5bd --- /dev/null +++ b/test/kuttl/tests/application-credentials/02-create-kac-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/kac-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/02-create-oscp-crd.yaml b/test/kuttl/tests/application-credentials/02-create-oscp-crd.yaml new file mode 120000 index 0000000..97f74fb --- /dev/null +++ b/test/kuttl/tests/application-credentials/02-create-oscp-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/oscp-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/03-create-mock-keystone.yaml b/test/kuttl/tests/application-credentials/03-create-mock-keystone.yaml new file mode 120000 index 0000000..997b604 --- /dev/null +++ b/test/kuttl/tests/application-credentials/03-create-mock-keystone.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-keystone.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/03-create-mock-oscp-resources.yaml b/test/kuttl/tests/application-credentials/03-create-mock-oscp-resources.yaml new file mode 120000 index 0000000..13a8223 --- /dev/null +++ b/test/kuttl/tests/application-credentials/03-create-mock-oscp-resources.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-oscp-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/04-assert-mock-openstack.yaml b/test/kuttl/tests/application-credentials/04-assert-mock-openstack.yaml new file mode 120000 index 0000000..5a621a8 --- /dev/null +++ b/test/kuttl/tests/application-credentials/04-assert-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..dea52c6 --- /dev/null +++ b/test/kuttl/tests/application-credentials/05-create-openstack-lightspeed-instance.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhoso_mcps diff --git a/test/kuttl/tests/application-credentials/06-assert-ac-resources.yaml b/test/kuttl/tests/application-credentials/06-assert-ac-resources.yaml new file mode 100644 index 0000000..9fbcbc3 --- /dev/null +++ b/test/kuttl/tests/application-credentials/06-assert-ac-resources.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: lightspeed-password + namespace: openstack +--- +apiVersion: keystone.openstack.org/v1beta1 +kind: KeystoneApplicationCredential +metadata: + name: lightspeed + namespace: openstack + annotations: + keystone.openstack.org/edpm-service: "false" +spec: + userName: lightspeed + secret: lightspeed-password +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +# Script: kuttl YAML asserts can match exact values but cannot express "field is non-empty" +commands: + - script: | + #!/bin/bash + set -euo pipefail + PW=$(oc get secret lightspeed-password -n openstack \ + -o jsonpath='{.data.password}' | base64 -d) + if [ -z "$PW" ]; then + echo "ERROR: Password secret has empty password key" + exit 1 + fi diff --git a/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml b/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml new file mode 100644 index 0000000..d1c8481 --- /dev/null +++ b/test/kuttl/tests/application-credentials/07-assert-intermediate-state.yaml @@ -0,0 +1,15 @@ +############################################################################## +# Assert intermediate state: OSCP exists but AC not yet ready. # +# The operator must NOT create OpenStack resources prematurely. # +############################################################################## +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 30 +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + assert_openstack_ready false + assert_mcp_openstack_enabled false diff --git a/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml b/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml new file mode 100644 index 0000000..9e150ef --- /dev/null +++ b/test/kuttl/tests/application-credentials/07-errors-intermediate-state.yaml @@ -0,0 +1,22 @@ +############################################################################## +# These resources must NOT exist during the intermediate state. # +# They should only be created after all readiness checks pass. # +############################################################################## +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml b/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/application-credentials/08-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml new file mode 100644 index 0000000..530fb8d --- /dev/null +++ b/test/kuttl/tests/application-credentials/09-assert-mcp-credentials.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: combined-ca-bundle + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: ac-lightspeed-test-secret + namespace: openstack + finalizers: + - openstack.org/lightspeed-ac-consumer +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: openstack-config + namespace: openstack-lightspeed +--- +apiVersion: v1 +kind: Secret +metadata: + name: openstack-config-secret + namespace: openstack-lightspeed +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +# Script: kuttl YAML asserts match resource structure, not string content within data fields +commands: + - script: | + #!/bin/bash + set -euo pipefail + CLOUDS=$(oc get configmap openstack-config -n openstack-lightspeed \ + -o jsonpath='{.data.clouds\.yaml}') + echo "$CLOUDS" | grep -q "v3applicationcredential" + echo "$CLOUDS" | grep -q "application_credential_id: mock-ac-id-12345" + echo "$CLOUDS" | grep -q "auth_url: http://mock-keystone-server.openstack.svc:5000/v3" + + SECURE=$(oc get secret openstack-config-secret -n openstack-lightspeed \ + -o jsonpath='{.data.secure\.yaml}' | base64 -d) + echo "$SECURE" | grep -q "application_credential_secret: mock-ac-secret-abcde" + + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled true diff --git a/test/kuttl/tests/application-credentials/10-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/10-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/application-credentials/10-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/11-assert-ac-cleanup.yaml b/test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml new file mode 100644 index 0000000..b883cd3 --- /dev/null +++ b/test/kuttl/tests/application-credentials/11-assert-ac-cleanup.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +# Script: kuttl errors files check entire resource absence, not specific field absence +commands: + - script: | + #!/bin/bash + set -euo pipefail + if oc get secret ac-lightspeed-test-secret -n openstack 2>/dev/null; then + FINALIZERS=$(oc get secret ac-lightspeed-test-secret -n openstack \ + -o jsonpath='{.metadata.finalizers}' 2>/dev/null || true) + if echo "$FINALIZERS" | grep -q "openstack.org/lightspeed-ac-consumer"; then + echo "ERROR: AC secret still has lightspeed-ac-consumer finalizer" + exit 1 + fi + fi diff --git a/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml b/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml new file mode 100644 index 0000000..4fd259f --- /dev/null +++ b/test/kuttl/tests/application-credentials/11-errors-ac-cleanup.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: keystone.openstack.org/v1beta1 +kind: KeystoneApplicationCredential +metadata: + name: lightspeed + namespace: openstack +--- +apiVersion: v1 +kind: Secret +metadata: + name: lightspeed-password + namespace: openstack diff --git a/test/kuttl/tests/application-credentials/12-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/application-credentials/12-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/application-credentials/12-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/13-cleanup-mock-openstack.yaml b/test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml new file mode 120000 index 0000000..3dd65f0 --- /dev/null +++ b/test/kuttl/tests/application-credentials/13-cleanup-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/cleanup-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml b/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml new file mode 120000 index 0000000..96ea740 --- /dev/null +++ b/test/kuttl/tests/application-credentials/14-errors-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/errors-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/application-credentials/15-cleanup-mock-objects.yaml b/test/kuttl/tests/application-credentials/15-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/application-credentials/15-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/16-errors-mock-objects.yaml b/test/kuttl/tests/application-credentials/16-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/application-credentials/16-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/dynamic-crd-watch-recovery/00-mock-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/00-mock-resources.yaml new file mode 120000 index 0000000..8235a1f --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/00-mock-resources.yaml @@ -0,0 +1 @@ +../../common/mock-objects/mock-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml new file mode 120000 index 0000000..07f977a --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/01-assert-mock-objects-created.yaml @@ -0,0 +1 @@ +../../common/mock-objects/assert-mock-objects-created.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml new file mode 120000 index 0000000..6c9e5bd --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-kac-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/kac-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml new file mode 120000 index 0000000..97f74fb --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/02-create-oscp-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/oscp-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml new file mode 120000 index 0000000..997b604 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-keystone.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-keystone.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml new file mode 120000 index 0000000..13a8223 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/03-create-mock-oscp-resources.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-oscp-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-mock-openstack.yaml new file mode 120000 index 0000000..5a621a8 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/04-assert-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-mcp-config.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-mcp-config.yaml new file mode 120000 index 0000000..6c2d5ae --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-assert-mcp-config.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/assert-mcp-config.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/05-create-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/05-create-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..dea52c6 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/05-create-openstack-lightspeed-instance.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhoso_mcps diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..d3e703f --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + openStackReady: true +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 360 +# Script: kuttl YAML asserts match resource structure, not string content within data fields +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/06-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/06-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/06-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..914803b --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 360 +# Script: Go omitempty on bool omits false from serialization, kuttl YAML assert fails with +# "key is missing from map"; also parses MCP config string content within data fields +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + assert_openstack_ready false + assert_mcp_openstack_enabled false + assert_condition_status Ready True diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-delete-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-delete-openstack-crds.yaml new file mode 100644 index 0000000..da6769b --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-delete-openstack-crds.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: openstackcontrolplanes.core.openstack.org + - apiVersion: apiextensions.k8s.io/v1 + kind: CustomResourceDefinition + name: keystoneapplicationcredentials.keystone.openstack.org diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/07-errors-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/07-errors-openstack-crds.yaml new file mode 100644 index 0000000..e33fba7 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/07-errors-openstack-crds.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: openstackcontrolplanes.core.openstack.org +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keystoneapplicationcredentials.keystone.openstack.org diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml new file mode 120000 index 0000000..6f66d20 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-assert-openstack-crds.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/assert-openstack-crds.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml new file mode 120000 index 0000000..6c9e5bd --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-kac-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/kac-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml new file mode 120000 index 0000000..97f74fb --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/08-create-oscp-crd.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/oscp-crd.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml new file mode 120000 index 0000000..13a8223 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/09-create-mock-oscp-resources.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/mock-oscp-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml new file mode 100644 index 0000000..d3e703f --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-assert-openstack-lightspeed-instance.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + openStackReady: true +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 360 +# Script: kuttl YAML asserts match resource structure, not string content within data fields +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + assert_mcp_openstack_enabled true diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/10-simulate-keystone-operator.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/10-simulate-keystone-operator.yaml new file mode 100644 index 0000000..93f3877 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/10-simulate-keystone-operator.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +# Script: simulates a Keystone operator reconciliation loop, no kuttl equivalent +commands: + - script: | + #!/bin/bash + set -euo pipefail + ../../common/mock-openstack/simulate-keystone-operator.sh + timeout: 180 diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/11-cleanup-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/cleanup-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/12-errors-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/13-cleanup-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/13-cleanup-mock-openstack.yaml new file mode 120000 index 0000000..3dd65f0 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/13-cleanup-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/cleanup-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/14-errors-mock-openstack.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/14-errors-mock-openstack.yaml new file mode 120000 index 0000000..96ea740 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/14-errors-mock-openstack.yaml @@ -0,0 +1 @@ +../../common/mock-openstack/errors-mock-openstack.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/15-cleanup-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/15-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/15-cleanup-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/cleanup-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/dynamic-crd-watch-recovery/16-errors-mock-objects.yaml b/test/kuttl/tests/dynamic-crd-watch-recovery/16-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/dynamic-crd-watch-recovery/16-errors-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/errors-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml b/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml index d213ad1..ced9abd 100644 --- a/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml +++ b/test/kuttl/tests/persistent-database/04-assert-openstack-lightspeed-instance.yaml @@ -44,6 +44,8 @@ status: status: "True" reason: Ready message: Setup complete + - type: OpenStackLightspeedMCPServerReady + status: "True" - type: OpenStackLightspeedReady status: "True" reason: Ready diff --git a/test/kuttl/tests/rhoso-mcps-configuration/00-mock-resources.yaml b/test/kuttl/tests/rhoso-mcps-configuration/00-mock-resources.yaml new file mode 120000 index 0000000..8235a1f --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/00-mock-resources.yaml @@ -0,0 +1 @@ +../../common/mock-objects/mock-resources.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml b/test/kuttl/tests/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml new file mode 120000 index 0000000..07f977a --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/01-assert-mock-objects-created.yaml @@ -0,0 +1 @@ +../../common/mock-objects/assert-mock-objects-created.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml b/test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml new file mode 100644 index 0000000..dea52c6 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/02-create-rhoso-mcps-resources.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhoso_mcps diff --git a/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml new file mode 100644 index 0000000..e471e5b --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/03-assert-rhoso-mcps-instance.yaml @@ -0,0 +1,65 @@ +############################################################################## +# Assert that the operator created all expected RHOS MCP resources # +############################################################################## + +# Main deployment with RHOS MCP sidecar +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lightspeed-stack-deployment + namespace: openstack-lightspeed +spec: + template: + spec: + containers: + - name: llama-stack + - name: lightspeed-service-api + - name: lightspeed-to-dataverse-exporter + - name: rhoso-mcps + volumeMounts: + - name: openstack-config-secret + mountPath: /app/secure.yaml + subPath: secure.yaml + - name: openstack-config + mountPath: /app/clouds.yaml + subPath: clouds.yaml + - name: combined-ca-bundle + mountPath: /app/tls-ca-bundle.pem + subPath: tls-ca-bundle.pem + readOnly: true + - name: mcp-config + mountPath: /app/config.yaml + subPath: config.yaml +status: + replicas: 1 + readyReplicas: 1 + availableReplicas: 1 + +# MCP server ConfigMap +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed + +# OpenStackLightspeed CR status +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +status: + conditions: + - type: Ready + status: "True" + reason: Ready + message: Setup complete + - type: OpenStackLightspeedMCPServerReady + status: "True" + - type: OpenStackLightspeedReady + status: "True" + reason: Ready + message: OpenStack Lightspeed created diff --git a/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml b/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml new file mode 100644 index 0000000..33ef4d2 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/04-update-rhos-mcp-config.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: + - rhoso_mcps + rhosMCPConfig: | + debug: true + workers: 4 + openstack: + enabled: true + allow_write: true + openshift: + enabled: false diff --git a/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml new file mode 100644 index 0000000..719772f --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/05-assert-rhos-mcp-config-merged.yaml @@ -0,0 +1,51 @@ +############################################################################## +# Assert that rhosMCPConfig is deep-merged with operator defaults # +############################################################################## +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: + - script: | + #!/bin/bash + set -euo pipefail + source ../../common/shared_funcs.sh + + mcp_data=$(oc get configmap mcp-config -n "$NAMESPACE" \ + -o jsonpath='{.data.config\.yaml}') + + assert_yaml_field() { + local field="$1" expected="$2" + local actual + actual=$(echo "$mcp_data" | python3 -c " + import sys, yaml + data = yaml.safe_load(sys.stdin) + keys = '$field'.split('.') + val = data + for k in keys: + val = val[k] + print(val) + ") + if [ "$actual" != "$expected" ]; then + echo "ERROR: Expected $field=$expected, got $actual" + echo "$mcp_data" + exit 1 + fi + } + + # User values should be merged + assert_yaml_field "debug" "True" + assert_yaml_field "workers" "4" + + # Template defaults should be preserved + assert_yaml_field "port" "8080" + assert_yaml_field "ip" "0.0.0.0" + assert_yaml_field "openstack.ca_cert" "./tls-ca-bundle.pem" + assert_yaml_field "openstack.insecure" "False" + assert_yaml_field "openshift.insecure" "False" + + # User value in nested section should be merged + assert_yaml_field "openstack.allow_write" "True" + + # Operator must override enabled fields + assert_yaml_field "openstack.enabled" "False" + assert_yaml_field "openshift.enabled" "True" + timeout: 180 diff --git a/test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml b/test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml new file mode 100644 index 0000000..bca96d8 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackLightspeed +metadata: + name: openstack-lightspeed + namespace: openstack-lightspeed +spec: + llmEndpoint: http://mock-llm-api-server-pod:8000/v1 + llmEndpointType: openai + llmCredentials: openstack-lightspeed-apitoken + modelName: ibm-granite/granite-3.1-8b-instruct + tlsCACertBundle: openstack-lightspeed-cert + llmProjectID: test-project-id + llmDeploymentName: test-deployment-name + llmAPIVersion: v1 + logging: + ogxLogLevel: DEBUG + lightspeedStackLogLevel: WARNING + dataverseExporterLogLevel: DEBUG + dev: + featureFlags: [] diff --git a/test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml b/test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml new file mode 100644 index 0000000..22fe537 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-config + namespace: openstack-lightspeed diff --git a/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..6b2075b --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/cleanup-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml b/test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml new file mode 120000 index 0000000..8147244 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml @@ -0,0 +1 @@ +../../common/openstack-lightspeed-instance/errors-openstack-lightspeed-instance.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhoso-mcps-configuration/10-cleanup-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/10-cleanup-mock-objects.yaml new file mode 120000 index 0000000..410c927 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/10-cleanup-mock-objects.yaml @@ -0,0 +1 @@ +../../common/mock-objects/cleanup-mock-objects.yaml \ No newline at end of file diff --git a/test/kuttl/tests/rhoso-mcps-configuration/11-errors-mock-objects.yaml b/test/kuttl/tests/rhoso-mcps-configuration/11-errors-mock-objects.yaml new file mode 120000 index 0000000..696a5e2 --- /dev/null +++ b/test/kuttl/tests/rhoso-mcps-configuration/11-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/08-assert-openstacklightspeed-update.yaml b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml index fb300f8..014dc40 100644 --- a/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml +++ b/test/kuttl/tests/update-openstacklightspeed/08-assert-openstacklightspeed-update.yaml @@ -301,6 +301,9 @@ status: status: "True" reason: Ready message: Setup complete + - type: OpenStackLightspeedMCPServerReady + status: "True" + message: "RHOSO MCP server is disabled (rhoso_mcps feature flag not set)" - type: OpenStackLightspeedReady status: "True" reason: Ready