Skip to content

Commit 43db8fe

Browse files
Add dataverse exporter sidecar for feedback/transcripts collection
Deploy a dataverse exporter sidecar container that collect user feedback and transcripts from a shared EmptyDir volume and uploads them to Red Hat Dataverse. The exporter is conditionally deployed only when data collection is enabled (FeedbackDisabled/TranscriptsDisabled both default to false). Key changes: - Add exporter container, shared data volume, and config volume to pod spec - Add exporter ConfigMap reconciliation (create when enabled, delete when disabled) - Add RBAC for pull-secret and clusterversions access (exporter authentication) - Add KUTTL test assertions for exporter ConfigMap and updated ClusterRole
1 parent e49e5a1 commit 43db8fe

27 files changed

Lines changed: 265 additions & 5 deletions

bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ spec:
146146
spec:
147147
clusterPermissions:
148148
- rules:
149+
- apiGroups:
150+
- ""
151+
resourceNames:
152+
- pull-secret
153+
resources:
154+
- secrets
155+
verbs:
156+
- get
149157
- apiGroups:
150158
- config.openshift.io
151159
resources:

config/rbac/role.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ kind: ClusterRole
44
metadata:
55
name: manager-role
66
rules:
7+
- apiGroups:
8+
- ""
9+
resourceNames:
10+
- pull-secret
11+
resources:
12+
- secrets
13+
verbs:
14+
- get
715
- apiGroups:
816
- config.openshift.io
917
resources:

internal/controller/constants.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,14 @@ const (
9797
ForceReloadAnnotationKey = "ols.openshift.io/force-reload"
9898

9999
// Data Exporter
100-
ExporterConfigVolumeName = "exporter-config"
101-
ExporterConfigMountPath = "/etc/config"
102-
ExporterConfigFilename = "config.yaml"
103-
RHOSOLightspeedOwnerIDLabel = "openstack.org/lightspeed-owner-id"
104-
ServiceIDRHOSO = "rhos-lightspeed"
100+
ExporterConfigVolumeName = "exporter-config"
101+
ExporterConfigMountPath = "/etc/config"
102+
ExporterConfigFilename = "config.yaml"
103+
ExporterConfigCmName = "lightspeed-exporter-config"
104+
DataverseExporterContainerName = "lightspeed-to-dataverse-exporter"
105+
UserDataVolumeName = "ols-user-data"
106+
RHOSOLightspeedOwnerIDLabel = "openstack.org/lightspeed-owner-id"
107+
ServiceIDRHOSO = "rhos-lightspeed"
105108

106109
// Azure
107110
AzureOpenAIType = "azure_openai"

internal/controller/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var (
3535
ErrGetTLSSecret = errors.New("failed to get TLS secret")
3636
ErrCreateLlamaStackConfigMap = errors.New("failed to create Llama Stack configmap")
3737
ErrGenerateLlamaStackConfigMap = errors.New("failed to generate Llama Stack configmap")
38+
ErrCreateExporterConfigMap = errors.New("failed to create exporter configmap")
3839

3940
// Postgres Errors
4041
ErrCreatePostgresDeployment = errors.New("failed to create Postgres deployment")

internal/controller/lcore_config.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222

2323
common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper"
2424
apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1"
25+
corev1 "k8s.io/api/core/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2527
"sigs.k8s.io/yaml"
2628
)
2729

@@ -197,6 +199,36 @@ func buildLCoreConversationCacheConfig(h *common_helper.Helper, _ *apiv1beta1.Op
197199
}
198200
}
199201

202+
// isDataCollectionEnabled returns true if at least one of feedback or transcripts is enabled.
203+
func isDataCollectionEnabled(instance *apiv1beta1.OpenStackLightspeed) bool {
204+
return !instance.Spec.FeedbackDisabled || !instance.Spec.TranscriptsDisabled
205+
}
206+
207+
// buildExporterConfigMap creates the ConfigMap for the dataverse exporter sidecar.
208+
func buildExporterConfigMap(h *common_helper.Helper, _ *apiv1beta1.OpenStackLightspeed) *corev1.ConfigMap {
209+
exporterConfig := fmt.Sprintf(`service_id: "%s"
210+
ingress_server_url: "https://console.redhat.com/api/ingress/v1/upload"
211+
allowed_subdirs:
212+
- feedback
213+
- transcripts
214+
- config_status
215+
collection_interval: 300
216+
cleanup_after_send: true
217+
ingress_connection_timeout: 30
218+
`, ServiceIDRHOSO)
219+
220+
return &corev1.ConfigMap{
221+
ObjectMeta: metav1.ObjectMeta{
222+
Name: ExporterConfigCmName,
223+
Namespace: h.GetBeforeObject().GetNamespace(),
224+
Labels: generateAppServerSelectorLabels(),
225+
},
226+
Data: map[string]string{
227+
ExporterConfigFilename: exporterConfig,
228+
},
229+
}
230+
}
231+
200232
// buildLCoreConfigYAML assembles the complete Lightspeed Core Service configuration and converts to YAML.
201233
// NOTE: MCP servers, quota handlers, and tools approval features are disabled for OpenStack Lightspeed.
202234
func buildLCoreConfigYAML(h *common_helper.Helper, instance *apiv1beta1.OpenStackLightspeed) (string, error) {

internal/controller/lcore_deployment.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22+
"path"
2223

2324
common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper"
2425
apiv1beta1 "github.com/openstack-lightspeed/operator/api/v1beta1"
2526
corev1 "k8s.io/api/core/v1"
2627
"k8s.io/apimachinery/pkg/api/errors"
28+
"k8s.io/apimachinery/pkg/api/resource"
2729
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2830
"k8s.io/apimachinery/pkg/types"
2931
"k8s.io/apimachinery/pkg/util/intstr"
@@ -86,13 +88,27 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins
8688
ImagePullPolicy: corev1.PullIfNotPresent,
8789
}
8890

91+
// Data collection volumes (shared folder + exporter config)
92+
dataCollectionEnabled := isDataCollectionEnabled(instance)
93+
if dataCollectionEnabled {
94+
addDataCollectorVolumes(&volumes, VolumeDefaultMode)
95+
}
96+
8997
// Lightspeed Stack container mounts: its config + shared + TLS (only API container needs TLS)
9098
lightspeedStackMounts := []corev1.VolumeMount{lcoreMount}
9199
lightspeedStackMounts = append(lightspeedStackMounts, sharedMounts...)
92100
tlsMounts := []corev1.VolumeMount{}
93101
addTLSVolumesAndMounts(&volumes, &tlsMounts, VolumeDefaultMode)
94102
lightspeedStackMounts = append(lightspeedStackMounts, tlsMounts...)
95103

104+
// Mount shared data folder on lightspeed-service-api for feedback/transcripts
105+
if dataCollectionEnabled {
106+
lightspeedStackMounts = append(lightspeedStackMounts, corev1.VolumeMount{
107+
Name: UserDataVolumeName,
108+
MountPath: LCoreUserDataMountPath,
109+
})
110+
}
111+
96112
lightspeedStackContainer := corev1.Container{
97113
Name: "lightspeed-service-api",
98114
Image: apiv1beta1.OpenStackLightspeedDefaultValues.LCoreImageURL,
@@ -107,6 +123,42 @@ func buildLCorePodTemplateSpec(h *common_helper.Helper, ctx context.Context, ins
107123

108124
containers := []corev1.Container{llamaStackContainer, lightspeedStackContainer}
109125

126+
// Add dataverse exporter sidecar when data collection is enabled
127+
if dataCollectionEnabled {
128+
exporterContainer := corev1.Container{
129+
Name: DataverseExporterContainerName,
130+
Image: apiv1beta1.OpenStackLightspeedDefaultValues.ExporterImageURL,
131+
ImagePullPolicy: corev1.PullAlways,
132+
Args: []string{
133+
"--mode", "openshift",
134+
"--config", path.Join(ExporterConfigMountPath, ExporterConfigFilename),
135+
"--log-level", "INFO",
136+
"--data-dir", LCoreUserDataMountPath,
137+
},
138+
VolumeMounts: []corev1.VolumeMount{
139+
{
140+
Name: UserDataVolumeName,
141+
MountPath: LCoreUserDataMountPath,
142+
},
143+
{
144+
Name: ExporterConfigVolumeName,
145+
MountPath: ExporterConfigMountPath,
146+
ReadOnly: true,
147+
},
148+
},
149+
Resources: corev1.ResourceRequirements{
150+
Requests: corev1.ResourceList{
151+
corev1.ResourceCPU: resource.MustParse("50m"),
152+
corev1.ResourceMemory: resource.MustParse("64Mi"),
153+
},
154+
Limits: corev1.ResourceList{
155+
corev1.ResourceMemory: resource.MustParse("200Mi"),
156+
},
157+
},
158+
}
159+
containers = append(containers, exporterContainer)
160+
}
161+
110162
// Build configmap resource version annotations for change detection
111163
annotations, err := buildConfigMapAnnotations(h, ctx)
112164
if err != nil {
@@ -248,6 +300,28 @@ func addLlamaCacheVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.Vo
248300
})
249301
}
250302

303+
// addDataCollectorVolumes adds the shared data EmptyDir and exporter config volumes.
304+
func addDataCollectorVolumes(volumes *[]corev1.Volume, volumeDefaultMode int32) {
305+
*volumes = append(*volumes, corev1.Volume{
306+
Name: UserDataVolumeName,
307+
VolumeSource: corev1.VolumeSource{
308+
EmptyDir: &corev1.EmptyDirVolumeSource{},
309+
},
310+
})
311+
312+
*volumes = append(*volumes, corev1.Volume{
313+
Name: ExporterConfigVolumeName,
314+
VolumeSource: corev1.VolumeSource{
315+
ConfigMap: &corev1.ConfigMapVolumeSource{
316+
LocalObjectReference: corev1.LocalObjectReference{
317+
Name: ExporterConfigCmName,
318+
},
319+
DefaultMode: toPtr(volumeDefaultMode),
320+
},
321+
},
322+
})
323+
}
324+
251325
// addUserCAVolumesAndMounts adds user-provided additional CA certificate volume and mount
252326
// if instance.Spec.TLSCACertBundle is set.
253327
func addUserCAVolumesAndMounts(volumes *[]corev1.Volume, mounts *[]corev1.VolumeMount, instance *apiv1beta1.OpenStackLightspeed, volumeDefaultMode int32) {

internal/controller/lcore_reconciler.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func ReconcileLCoreResources(h *common_helper.Helper, ctx context.Context, insta
4646
{Name: "SARRoleBinding", Task: reconcileSARRoleBinding},
4747
{Name: "LlamaStackConfigMap", Task: reconcileLlamaStackConfigMap},
4848
{Name: "LcoreConfigMap", Task: reconcileLcoreConfigMap},
49+
{Name: "ExporterConfigMap", Task: reconcileExporterConfigMap},
4950
{Name: "OpenStackLightspeedAdditionalCAConfigMap", Task: reconcileOpenStackLightspeedAdditionalCAConfigMap},
5051
{Name: "ProxyCAConfigMap", Task: reconcileProxyCAConfigMap},
5152
{Name: "NetworkPolicy", Task: reconcileNetworkPolicy},
@@ -115,6 +116,17 @@ func reconcileSARRole(h *common_helper.Helper, ctx context.Context, instance *ap
115116
Resources: []string{"tokenreviews"},
116117
Verbs: []string{"create"},
117118
},
119+
{
120+
APIGroups: []string{"config.openshift.io"},
121+
Resources: []string{"clusterversions"},
122+
Verbs: []string{"get"},
123+
},
124+
{
125+
APIGroups: []string{""},
126+
Resources: []string{"secrets"},
127+
ResourceNames: []string{"pull-secret"},
128+
Verbs: []string{"get"},
129+
},
118130
}
119131
// Note: ClusterRole is cluster-scoped, no owner reference needed
120132
return nil
@@ -233,6 +245,43 @@ func reconcileLcoreConfigMap(h *common_helper.Helper, ctx context.Context, insta
233245
return nil
234246
}
235247

248+
// reconcileExporterConfigMap ensures the dataverse exporter ConfigMap exists when data
249+
// collection is enabled, and deletes it when disabled.
250+
func reconcileExporterConfigMap(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error {
251+
logger := h.GetLogger()
252+
253+
if !isDataCollectionEnabled(instance) {
254+
cm := &corev1.ConfigMap{}
255+
cm.Name = ExporterConfigCmName
256+
cm.Namespace = h.GetBeforeObject().GetNamespace()
257+
if err := h.GetClient().Delete(ctx, cm); err != nil && !errors.IsNotFound(err) {
258+
return fmt.Errorf("failed to delete exporter configmap: %w", err)
259+
}
260+
return nil
261+
}
262+
263+
cm := &corev1.ConfigMap{
264+
ObjectMeta: metav1.ObjectMeta{
265+
Name: ExporterConfigCmName,
266+
Namespace: h.GetBeforeObject().GetNamespace(),
267+
},
268+
}
269+
270+
result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), cm, func() error {
271+
desiredCm := buildExporterConfigMap(h, instance)
272+
cm.Data = desiredCm.Data
273+
cm.Labels = desiredCm.Labels
274+
return controllerutil.SetControllerReference(h.GetBeforeObject(), cm, h.GetScheme())
275+
})
276+
277+
if err != nil {
278+
return fmt.Errorf("%w: %v", ErrCreateExporterConfigMap, err)
279+
}
280+
281+
logger.Info("Exporter ConfigMap reconciled", "name", cm.Name, "result", result)
282+
return nil
283+
}
284+
236285
// reconcileOpenStackLightspeedAdditionalCAConfigMap verifies that the additional CA config map
237286
// exists if one is specified in the configuration.
238287
func reconcileOpenStackLightspeedAdditionalCAConfigMap(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error {

internal/controller/openstacklightspeed_controller.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg
6363
// +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,verbs=get;list;watch
6464
// +kubebuilder:rbac:groups=operators.coreos.com,resources=clusterserviceversions,namespace=openstack-lightspeed,verbs=update;patch;delete
6565
// +kubebuilder:rbac:groups=config.openshift.io,resources=clusterversions,verbs=get;list;watch
66+
// +kubebuilder:rbac:groups="",resources=secrets,resourceNames=pull-secret,verbs=get
6667
// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update
6768
// +kubebuilder:rbac:groups=apps,resources=deployments,namespace=openstack-lightspeed,verbs=get;list;watch;create;update;patch
6869
// +kubebuilder:rbac:groups="",resources=configmaps,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update;delete
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
apiVersion: v1
3+
kind: ConfigMap
4+
metadata:
5+
name: lightspeed-exporter-config
6+
namespace: openstack-lightspeed
7+
data:
8+
config.yaml: |
9+
service_id: "rhos-lightspeed"
10+
ingress_server_url: "https://console.redhat.com/api/ingress/v1/upload"
11+
allowed_subdirs:
12+
- feedback
13+
- transcripts
14+
- config_status
15+
collection_interval: 300
16+
cleanup_after_send: true
17+
ingress_connection_timeout: 30

test/kuttl/common/openstack-lightspeed-instance/assert-openstack-lightspeed-instance.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ rules:
6868
- tokenreviews
6969
verbs:
7070
- create
71+
- apiGroups:
72+
- config.openshift.io
73+
resources:
74+
- clusterversions
75+
verbs:
76+
- get
77+
- apiGroups:
78+
- ""
79+
resources:
80+
- secrets
81+
resourceNames:
82+
- pull-secret
83+
verbs:
84+
- get
7185
---
7286
apiVersion: rbac.authorization.k8s.io/v1
7387
kind: ClusterRoleBinding
@@ -100,6 +114,12 @@ metadata:
100114
namespace: openstack-lightspeed
101115
---
102116
apiVersion: v1
117+
kind: ConfigMap
118+
metadata:
119+
name: lightspeed-exporter-config
120+
namespace: openstack-lightspeed
121+
---
122+
apiVersion: v1
103123
kind: Service
104124
metadata:
105125
name: lightspeed-app-server
@@ -110,6 +130,13 @@ kind: Deployment
110130
metadata:
111131
name: lightspeed-stack-deployment
112132
namespace: openstack-lightspeed
133+
spec:
134+
template:
135+
spec:
136+
containers:
137+
- name: llama-stack
138+
- name: lightspeed-service-api
139+
- name: lightspeed-to-dataverse-exporter
113140
status:
114141
replicas: 1
115142
readyReplicas: 1

0 commit comments

Comments
 (0)