From c5da5b5957bcd2370b845b3e44d6cd5e27a0a679 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 5 Oct 2026 16:41:58 +0200 Subject: [PATCH 01/16] Deliver observability dashboards via Perses CRs Replace the ConfigMap-based dashboard delivery (dashboards stored as ConfigMaps in the shared openshift-config-managed namespace plus a managed UIPlugin) with perses.dev PersesDatasource and PersesDashboard objects created in the openstack namespace and owned by the MetricStorage CR. The MetricStorage controller reconciles a PersesDatasource pointing at the MetricStorage Prometheus and a PersesDashboard for each implemented, non-disabled dashboard, removes the legacy dashboard ConfigMaps on upgrade, and lets administrators disable individual dashboards by name via MetricStorage.spec.disabledDashboards. UIPlugin management is removed from the operator. The Perses backend (the Perses instance and its UIPlugin) is managed outside the telemetry-operator. The perses-operator's attachment state is surfaced on the dashboard conditions' messages (and logged on change) but does not gate MetricStorage/Telemetry readiness, since the backend is an external prerequisite the operator does not own. Only the openstack-cloud dashboard is implemented, as a minimal placeholder; conversion of the remaining Grafana dashboards is delivered separately. Related-To: OSPRH-37817 Signed-off-by: Victoria Martinez de la Cruz Assisted-By: Claude Opus 4.8 --- ...elemetry.openstack.org_metricstorages.yaml | 10 + .../telemetry.openstack.org_telemetries.yaml | 10 + api/v1beta1/conditions.go | 5 - api/v1beta1/metricstorage_types.go | 8 + api/v1beta1/zz_generated.deepcopy.go | 5 + cmd/main.go | 4 +- ...elemetry.openstack.org_metricstorages.yaml | 10 + .../telemetry.openstack.org_telemetries.yaml | 10 + config/rbac/role.yaml | 15 +- go.mod | 25 +- go.sum | 57 +++- .../controller/metricstorage_controller.go | 272 +++++++++++------- .../metricstorage_controller_test.go | 76 +++++ internal/dashboards/perses.go | 68 +++++ internal/dashboards/perses_openstack-cloud.go | 55 ++++ .../metricstorage/dashboard_datasource.go | 105 ++++--- internal/metricstorage/dashboard_objects.go | 87 ++++-- test/kuttl/tests/default/01-assert.yaml | 2 - test/kuttl/tests/metricstorage/00-deps.yaml | 4 + test/kuttl/tests/metricstorage/01-assert.yaml | 69 ++--- test/kuttl/tests/metricstorage/02-errors.yaml | 10 + test/kuttl/tests/metricstorage/07-assert.yaml | 23 +- test/kuttl/tests/metricstorage/07-errors.yaml | 13 + test/kuttl/tests/metricstorage/08-assert.yaml | 11 + .../metricstorage/08-disable-by-name.yaml | 20 ++ test/kuttl/tests/metricstorage/08-errors.yaml | 6 + 26 files changed, 746 insertions(+), 234 deletions(-) create mode 100644 internal/controller/metricstorage_controller_test.go create mode 100644 internal/dashboards/perses.go create mode 100644 internal/dashboards/perses_openstack-cloud.go create mode 100644 test/kuttl/tests/metricstorage/07-errors.yaml create mode 100644 test/kuttl/tests/metricstorage/08-assert.yaml create mode 100644 test/kuttl/tests/metricstorage/08-disable-by-name.yaml create mode 100644 test/kuttl/tests/metricstorage/08-errors.yaml diff --git a/api/bases/telemetry.openstack.org_metricstorages.yaml b/api/bases/telemetry.openstack.org_metricstorages.yaml index 93f845ab0..ff1ab05bb 100644 --- a/api/bases/telemetry.openstack.org_metricstorages.yaml +++ b/api/bases/telemetry.openstack.org_metricstorages.yaml @@ -1745,6 +1745,16 @@ spec: to scrape dataplane node_exporter endpoints pattern: ^[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9]$ type: string + disabledDashboards: + description: |- + DisabledDashboards is a list of dashboard names that should not be + created even when DashboardsEnabled is true. Valid names match the + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). + items: + type: string + type: array + x-kubernetes-list-type: atomic monitoringStack: description: |- MonitoringStack allows to define a metric storage with diff --git a/api/bases/telemetry.openstack.org_telemetries.yaml b/api/bases/telemetry.openstack.org_telemetries.yaml index 4d457780f..c9fdcfc0e 100644 --- a/api/bases/telemetry.openstack.org_telemetries.yaml +++ b/api/bases/telemetry.openstack.org_telemetries.yaml @@ -3147,6 +3147,16 @@ spec: used to scrape dataplane node_exporter endpoints pattern: ^[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9]$ type: string + disabledDashboards: + description: |- + DisabledDashboards is a list of dashboard names that should not be + created even when DashboardsEnabled is true. Valid names match the + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). + items: + type: string + type: array + x-kubernetes-list-type: atomic enabled: default: false description: Enabled - Whether a MetricStorage should be deployed diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index d2ccd731e..01947cb49 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -68,8 +68,6 @@ const ( DashboardPrometheusRuleReadyCondition condition.Type = "DashboardPrometheusRuleReady" - DashboardPluginReadyCondition condition.Type = "DashboardPluginReady" - DashboardDatasourceReadyCondition condition.Type = "DashboardDatasourceReady" DashboardDefinitionReadyCondition condition.Type = "DashboardDefinitionReady" @@ -314,9 +312,6 @@ const ( DashboardPrometheusRuleReadyInitMessage = "Dashboard PrometheusRule not started" DashboardPrometheusRuleUnableToOwnMessage = "Error occured when trying to own %s" - DashboardPluginReadyInitMessage = "Dashboard Plugin not started" - DashboardPluginFailedMessage = "Error occured when trying to install the dashboard plugin: %s" - DashboardDatasourceReadyInitMessage = "Dashboard Datasource not started" DashboardDatasourceFailedMessage = "Error occured when trying to install the dashboard datasource: %s" diff --git a/api/v1beta1/metricstorage_types.go b/api/v1beta1/metricstorage_types.go index 30a5da9db..95af2dd38 100644 --- a/api/v1beta1/metricstorage_types.go +++ b/api/v1beta1/metricstorage_types.go @@ -89,6 +89,14 @@ type MetricStorageSpec struct { // +kubebuilder:default=false DashboardsEnabled bool `json:"dashboardsEnabled"` + // DisabledDashboards is a list of dashboard names that should not be + // created even when DashboardsEnabled is true. Valid names match the + // dashboards managed by the operator (for example "openstack-cloud", + // "openstack-node", "openstack-vm"). + // +kubebuilder:validation:Optional + // +listType=atomic + DisabledDashboards []string `json:"disabledDashboards,omitempty"` + // DataplaneNetwork defines the network that will be used to scrape dataplane node_exporter endpoints // +kubebuilder:validation:Optional // +kubebuilder:default=ctlplane diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 39f1bfcf3..942385b44 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1532,6 +1532,11 @@ func (in *MetricStorageSection) DeepCopy() *MetricStorageSection { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetricStorageSpec) DeepCopyInto(out *MetricStorageSpec) { *out = *in + if in.DisabledDashboards != nil { + in, out := &in.DisabledDashboards, &out.DisabledDashboards + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.DataplaneNetwork != nil { in, out := &in.DataplaneNetwork, &out.DataplaneNetwork *out = new(networkv1beta1.NetNameStr) diff --git a/cmd/main.go b/cmd/main.go index 75bf87def..e43dfecf0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -58,10 +58,10 @@ import ( "github.com/openstack-k8s-operators/lib-common/modules/common/operator" mariadbv1beta1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" ovnv1 "github.com/openstack-k8s-operators/ovn-operator/api/v1beta1" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" monv1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1" monv1alpha1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1alpha1" obov1 "github.com/rhobs/observability-operator/pkg/apis/monitoring/v1alpha1" - obsuiv1 "github.com/rhobs/observability-operator/pkg/apis/uiplugin/v1alpha1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/client/config" ) @@ -78,7 +78,6 @@ func init() { utilruntime.Must(rabbitmqv1.AddToScheme(scheme)) utilruntime.Must(telemetryv1beta1.AddToScheme(scheme)) utilruntime.Must(obov1.AddToScheme(scheme)) - utilruntime.Must(obsuiv1.AddToScheme(scheme)) utilruntime.Must(monv1.AddToScheme(scheme)) utilruntime.Must(monv1alpha1.AddToScheme(scheme)) utilruntime.Must(mariadbv1beta1.AddToScheme(scheme)) @@ -90,6 +89,7 @@ func init() { utilruntime.Must(topologyv1.AddToScheme(scheme)) utilruntime.Must(lokistackv1.AddToScheme(scheme)) utilruntime.Must(certmgrv1.AddToScheme(scheme)) + utilruntime.Must(persesv1alpha1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } diff --git a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml index 93f845ab0..ff1ab05bb 100644 --- a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml +++ b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml @@ -1745,6 +1745,16 @@ spec: to scrape dataplane node_exporter endpoints pattern: ^[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9]$ type: string + disabledDashboards: + description: |- + DisabledDashboards is a list of dashboard names that should not be + created even when DashboardsEnabled is true. Valid names match the + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). + items: + type: string + type: array + x-kubernetes-list-type: atomic monitoringStack: description: |- MonitoringStack allows to define a metric storage with diff --git a/config/crd/bases/telemetry.openstack.org_telemetries.yaml b/config/crd/bases/telemetry.openstack.org_telemetries.yaml index 4d457780f..c9fdcfc0e 100644 --- a/config/crd/bases/telemetry.openstack.org_telemetries.yaml +++ b/config/crd/bases/telemetry.openstack.org_telemetries.yaml @@ -3147,6 +3147,16 @@ spec: used to scrape dataplane node_exporter endpoints pattern: ^[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9]$ type: string + disabledDashboards: + description: |- + DisabledDashboards is a list of dashboard names that should not be + created even when DashboardsEnabled is true. Valid names match the + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). + items: + type: string + type: array + x-kubernetes-list-type: atomic enabled: default: false description: Enabled - Whether a MetricStorage should be deployed diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d0d47e791..c6fb4a3d0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -256,22 +256,25 @@ rules: - list - watch - apiGroups: - - observability.openshift.io + - ovn.openstack.org resources: - - uiplugins + - ovnnorthds verbs: - - create - get - list - - patch - watch - apiGroups: - - ovn.openstack.org + - perses.dev resources: - - ovnnorthds + - persesdashboards + - persesdatasources verbs: + - create + - delete - get - list + - patch + - update - watch - apiGroups: - rabbitmq.openstack.org diff --git a/go.mod b/go.mod index b2aaabf8c..4f6de24b3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/openstack-k8s-operators/mariadb-operator/api v0.6.1-0.20260927135032-f0eb46b149d8 github.com/openstack-k8s-operators/ovn-operator/api v0.6.1-0.20261003111325-949d7b834383 github.com/openstack-k8s-operators/telemetry-operator/api v0.0.0-00010101000000-000000000000 + github.com/perses/perses v0.51.0 + github.com/perses/perses-operator v0.2.0 github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring v0.77.1-rhobs1 github.com/rhobs/observability-operator v1.0.0 k8s.io/api v0.33.13 @@ -31,9 +33,12 @@ require ( require ( cel.dev/expr v0.25.2 // indirect + github.com/PaesslerAG/gval v1.2.4 // indirect + github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f // 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 + github.com/brunoga/deep v1.2.5 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -42,6 +47,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect @@ -57,22 +63,37 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.4 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/nexucis/lamenv v0.5.2 // indirect github.com/openshift/api v3.9.0+incompatible // indirect github.com/openstack-k8s-operators/lib-common/modules/openstack v0.6.1-0.20261001122809-0e19abbc9e47 // indirect github.com/openstack-k8s-operators/lib-common/modules/storage v0.6.1-0.20261001122809-0e19abbc9e47 // indirect + github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/zitadel/oidc/v3 v3.38.1 // indirect + github.com/zitadel/schema v1.3.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect @@ -86,7 +107,8 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect @@ -101,6 +123,7 @@ require ( 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 + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.33.13 // indirect k8s.io/apiserver v0.33.13 // indirect diff --git a/go.sum b/go.sum index 6bbea3267..f4cb63655 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,20 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/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/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU= +github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f h1:TxDCeKRCgHea2hUiMOjWwqzWmrIGqSOZYkEPuClXzDo= +github.com/PaesslerAG/jsonpath v0.1.2-0.20240726212847-3a740cf7976f/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/brunoga/deep v1.2.5 h1:bigq4eooqbeJXfvTfZBn3AH3B1iW+rtetxVeh0GiLrg= +github.com/brunoga/deep v1.2.5/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cert-manager/cert-manager v1.18.6 h1:Upk+QQCHbWFmjCxuLKgd1yEyK0KF9JROUXjljgB5Yn0= @@ -31,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -76,6 +86,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 h1:z4P744DR+PIpkjwXSEc6TvN3L6LVzmUquFgmNm8wSUc= @@ -90,16 +102,30 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M= +github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= @@ -129,6 +155,12 @@ github.com/openstack-k8s-operators/mariadb-operator/api v0.6.1-0.20260927135032- github.com/openstack-k8s-operators/mariadb-operator/api v0.6.1-0.20260927135032-f0eb46b149d8/go.mod h1:OxDSWaCJvHJpMDagO0IaBlakxS3ZHQsWR9OWtOzPTKE= github.com/openstack-k8s-operators/ovn-operator/api v0.6.1-0.20261003111325-949d7b834383 h1:JvxQwuVeKqF/lGCq4K0iljkDCNp5WH7l1DrV/YDaqms= github.com/openstack-k8s-operators/ovn-operator/api v0.6.1-0.20261003111325-949d7b834383/go.mod h1:IET3K4BXXG0sPxj7Tra72xEq7I6Osz+20IEvkURnEwU= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e h1:AormqtWdtHdoQyGO90U1fRoElR0XQHmP0W9oJUsCOZY= +github.com/perses/common v0.27.1-0.20250326140707-96e439b14e0e/go.mod h1:CMTbKu0uWCFKgo4oDVoT8GcMC0bKyDH4cNG3GVfi+rA= +github.com/perses/perses v0.51.0 h1:lLssvsMjxFg2oP+vKX6pz2SFTfrUyso/A2/A/6oFens= +github.com/perses/perses v0.51.0/go.mod h1:DrGiL+itTLl2mwEvNa0wGokELfZTsqOc3TEg+2B0uwY= +github.com/perses/perses-operator v0.2.0 h1:gIhKUWca8ncaxyvOk2USaGfQ32eNcXzjDN97UlQAP0M= +github.com/perses/perses-operator v0.2.0/go.mod h1:91gFy0XicXrWSYSr4ChkMp16GSOkeXjKdkXlfEECw5g= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -149,6 +181,11 @@ github.com/rhobs/observability-operator v1.0.0/go.mod h1:OheVnMXdnGj+GPoVA3UDgsJ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -158,12 +195,21 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zitadel/oidc/v3 v3.38.1 h1:VTf1Bv/33UbSwJnIWbfEIdpUGYKfoHetuBNIqVTcjvA= +github.com/zitadel/oidc/v3 v3.38.1/go.mod h1:muukzAasaWmn3vBwEVMglJfuTE0PKCvLJGombPwXIRw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -197,8 +243,10 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= @@ -219,6 +267,8 @@ golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= @@ -258,6 +308,9 @@ 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +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= k8s.io/api v0.33.13 h1:Au/I/J8SXmcCBxp+KiS82451AEaKjVHouB1x3lUm1Wk= diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index a87065281..3fcdc16b8 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -31,6 +31,7 @@ import ( discoveryv1 "k8s.io/api/discovery/v1" rbacv1 "k8s.io/api/rbac/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" @@ -63,10 +64,10 @@ import ( metricstorage "github.com/openstack-k8s-operators/telemetry-operator/internal/metricstorage" telemetry "github.com/openstack-k8s-operators/telemetry-operator/internal/telemetry" utils "github.com/openstack-k8s-operators/telemetry-operator/internal/utils" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" monv1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1" monv1alpha1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1alpha1" obov1 "github.com/rhobs/observability-operator/pkg/apis/monitoring/v1alpha1" - obsui "github.com/rhobs/observability-operator/pkg/apis/uiplugin/v1alpha1" ) // fields to index to reconcile when change @@ -116,7 +117,7 @@ func (r *MetricStorageReconciler) GetLogger(ctx context.Context) logr.Logger { //+kubebuilder:rbac:groups=network.openstack.org,resources=ipsets,verbs=get;list;watch //+kubebuilder:rbac:groups=rabbitmq.openstack.org,resources=rabbitmqs,verbs=get;list;watch //+kubebuilder:rbac:groups=ovn.openstack.org,resources=ovnnorthds,verbs=get;list;watch -//+kubebuilder:rbac:groups=observability.openshift.io,resources=uiplugins,verbs=get;list;watch;create;patch +//+kubebuilder:rbac:groups=perses.dev,resources=persesdashboards;persesdatasources,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch //+kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch @@ -194,7 +195,6 @@ func (r *MetricStorageReconciler) Reconcile(ctx context.Context, req ctrl.Reques condition.UnknownCondition(telemetryv1.MonitoringStackReadyCondition, condition.InitReason, telemetryv1.MonitoringStackReadyInitMessage), condition.UnknownCondition(telemetryv1.ScrapeConfigReadyCondition, condition.InitReason, telemetryv1.ScrapeConfigReadyInitMessage), condition.UnknownCondition(telemetryv1.DashboardPrometheusRuleReadyCondition, condition.InitReason, telemetryv1.DashboardPrometheusRuleReadyInitMessage), - condition.UnknownCondition(telemetryv1.DashboardPluginReadyCondition, condition.InitReason, telemetryv1.DashboardPluginReadyInitMessage), condition.UnknownCondition(telemetryv1.DashboardDatasourceReadyCondition, condition.InitReason, telemetryv1.DashboardDatasourceReadyInitMessage), condition.UnknownCondition(telemetryv1.DashboardDefinitionReadyCondition, condition.InitReason, telemetryv1.DashboardDefinitionReadyInitMessage), condition.UnknownCondition(telemetryv1.PrometheusReadyCondition, condition.InitReason, telemetryv1.PrometheusReadyInitMessage), @@ -507,6 +507,13 @@ func (r *MetricStorageReconciler) reconcileNormal( return res, err } + // Remove the legacy ConfigMap-based dashboard artifacts created by earlier + // versions of the operator in the openshift-config-managed namespace. This + // runs on every reconcile so upgrades converge regardless of DashboardsEnabled. + if res, err := metricstorage.CleanupLegacyDashboardConfigMaps(ctx, instance, helper); err != nil { + return res, err + } + if !instance.Spec.DashboardsEnabled { if res, err := metricstorage.DeleteDashboardObjects(ctx, instance, helper); err != nil { return res, err @@ -514,7 +521,6 @@ func (r *MetricStorageReconciler) reconcileNormal( instance.Status.Conditions.MarkTrue(telemetryv1.DashboardPrometheusRuleReadyCondition, telemetryv1.DashboardsNotEnabledMessage) instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDatasourceReadyCondition, telemetryv1.DashboardsNotEnabledMessage) instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDefinitionReadyCondition, telemetryv1.DashboardsNotEnabledMessage) - instance.Status.Conditions.MarkTrue(telemetryv1.DashboardPluginReadyCondition, telemetryv1.DashboardsNotEnabledMessage) } else { if res, err := r.createDashboardObjects(ctx, instance, helper, eventHandler, serviceLabels); err != nil { return res, err @@ -1381,32 +1387,55 @@ func (r *MetricStorageReconciler) createInstanceHAScrapeConfig( ) } -func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper, eventHandler handler.EventHandler, serviceLabels map[string]string) (ctrl.Result, error) { - Log := r.GetLogger(ctx) - uiPluginObj := &obsui.UIPlugin{ - ObjectMeta: metav1.ObjectMeta{ - Name: "dashboards", - }, - } - op, err := controllerutil.CreateOrPatch(ctx, r.Client, uiPluginObj, func() error { - uiPluginObj.Spec.Type = "Dashboards" - return nil - }) - if err != nil { - Log.Error(err, fmt.Sprintf("Failed to update Dashboard Plugin definition %s - operation: %s", uiPluginObj.GetName(), string(op))) - instance.Status.Conditions.MarkFalse(telemetryv1.DashboardPluginReadyCondition, - condition.Reason("Can't create Dashboard Plugin definition"), - condition.SeverityError, - telemetryv1.DashboardPluginFailedMessage, err) - } else { - instance.Status.Conditions.MarkTrue(telemetryv1.DashboardPluginReadyCondition, condition.ReadyMessage) +// persesResourceAvailable reports whether the given PersesDatasource or +// PersesDashboard resource is attached to a Perses instance, by checking its +// "Available" condition. It returns true when Available is True, otherwise +// false with the condition's reason and message (or a default when unset). +func persesResourceAvailable(conditions []metav1.Condition) (bool, string, string) { + available := meta.FindStatusCondition(conditions, "Available") + if available == nil { + return false, "PersesReconciling", "Waiting for the Perses operator to reconcile the object; no Perses instance may be available" } - if op != controllerutil.OperationResultNone { - Log.Info(fmt.Sprintf("Dashboard Plugin definition %s successfully changed - operation: %s", uiPluginObj.GetName(), string(op))) + if available.Status != metav1.ConditionTrue { + reason := available.Reason + if reason == "" { + reason = "PersesBackendNotReady" + } + return false, reason, available.Message } + return true, "", "" +} + +// markDashboardObjectReady marks a dashboard-related condition True once the +// telemetry-operator has created and owns the object, which is the full extent +// of this operator's responsibility: the Perses backend (instance/UIPlugin) is +// managed outside the telemetry-operator and is an optional prerequisite. The +// perses-operator's attachment state is folded into the condition message (and +// logged, but only when it changes, to avoid per-reconcile log spam) so a missing +// backend is visible via `oc describe`/status without gating the overall +// MetricStorage/Telemetry readiness on something this operator does not manage. +func (r *MetricStorageReconciler) markDashboardObjectReady( + ctx context.Context, + instance *telemetryv1.MetricStorage, + condType condition.Type, + available bool, + reason, message, describe string, +) { + msg := condition.ReadyMessage + if !available { + msg = fmt.Sprintf("%s created; Perses backend not attached yet (%s): %s", describe, reason, message) + if existing := instance.Status.Conditions.Get(condType); existing == nil || existing.Message != msg { + r.GetLogger(ctx).Info(fmt.Sprintf("%s is not attached to a Perses backend yet (%s): %s", describe, reason, message)) + } + } + instance.Status.Conditions.MarkTrue(condType, "%s", msg) +} + +func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper, eventHandler handler.EventHandler, serviceLabels map[string]string) (ctrl.Result, error) { + Log := r.GetLogger(ctx) // Deploy PrometheusRule for dashboards - err = utils.EnsureWatches( + err := utils.EnsureWatches( ctx, (*utils.ConditionalWatchingReconciler)(r), "prometheusrules.monitoring.rhobs", &monv1.PrometheusRule{}, eventHandler, helper, @@ -1425,12 +1454,11 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in Namespace: instance.Namespace, }, } - op, err = controllerutil.CreateOrPatch(ctx, r.Client, prometheusRule, func() error { + op, err := controllerutil.CreateOrPatch(ctx, r.Client, prometheusRule, func() error { desiredPrometheusRule := metricstorage.DashboardPrometheusRule(instance, serviceLabels) desiredPrometheusRule.Spec.DeepCopyInto(&prometheusRule.Spec) prometheusRule.Labels = desiredPrometheusRule.Labels - err = controllerutil.SetControllerReference(instance, prometheusRule, r.Scheme) - return err + return controllerutil.SetControllerReference(instance, prometheusRule, r.Scheme) }) if err != nil { return ctrl.Result{}, err @@ -1440,104 +1468,136 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in } instance.Status.Conditions.MarkTrue(telemetryv1.DashboardPrometheusRuleReadyCondition, condition.ReadyMessage) - // Deploy Configmap for Console UI Datasource - datasourceName := instance.Namespace + "-" + instance.Name + "-datasource" - datasourceCM := &corev1.ConfigMap{ + // Deploy PersesDatasource pointing the dashboards at the MetricStorage Prometheus + err = utils.EnsureWatches( + ctx, (*utils.ConditionalWatchingReconciler)(r), + "persesdatasources.perses.dev", + &persesv1alpha1.PersesDatasource{}, eventHandler, helper, + ) + if err != nil { + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDatasourceReadyCondition, + condition.Reason("Can't own PersesDatasource resource. Ensure the monitoring plugin in COO is enabled to use Perses dashboards."), + condition.SeverityError, + telemetryv1.DashboardDatasourceFailedMessage, err) + Log.Info("Can't own PersesDatasource resource. Ensure the monitoring plugin in COO is enabled to use Perses dashboards.") + return ctrl.Result{RequeueAfter: telemetryv1.PauseBetweenWatchAttempts}, nil + } + datasource := &persesv1alpha1.PersesDatasource{ ObjectMeta: metav1.ObjectMeta{ - Name: datasourceName, - Namespace: metricstorage.DashboardArtifactsNamespace, + Name: metricstorage.PrometheusDatasourceName, + Namespace: instance.Namespace, }, } - dataSourceSuccess := false - op, err = controllerutil.CreateOrPatch(ctx, r.Client, datasourceCM, func() error { - datasourceCM.Labels = map[string]string{ - "console.openshift.io/dashboard-datasource": "true", - } - datasourceCM.Data, err = metricstorage.DashboardDatasourceData(ctx, r.Client, instance, datasourceName, metricstorage.DashboardArtifactsNamespace) - return err + op, err = controllerutil.CreateOrPatch(ctx, r.Client, datasource, func() error { + desired := metricstorage.DashboardDatasource(instance) + datasource.Labels = desired.Labels + datasource.Spec = desired.Spec + return controllerutil.SetControllerReference(instance, datasource, r.Scheme) }) if err != nil { - Log.Error(err, "Failed to update Console UI Datasource ConfigMap %s - operation: %s", datasourceCM.Name, string(op)) + Log.Error(err, fmt.Sprintf("Failed to update PersesDatasource %s", datasource.Name)) instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDatasourceReadyCondition, - condition.Reason("Can't create Console UI Datasource ConfigMap"), + condition.Reason("Can't create PersesDatasource"), condition.SeverityError, telemetryv1.DashboardDatasourceFailedMessage, err) - } else { - dataSourceSuccess = true - instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDatasourceReadyCondition, condition.ReadyMessage) + return ctrl.Result{}, err } if op != controllerutil.OperationResultNone { - Log.Info(fmt.Sprintf("Console UI Datasource ConfigMap %s successfully changed - operation: %s", datasourceCM.Name, string(op))) + Log.Info(fmt.Sprintf("PersesDatasource %s successfully changed - operation: %s", datasource.Name, string(op))) } + // Creating and owning the PersesDatasource is this operator's responsibility, + // so the condition is Ready once that succeeds. Whether the perses-operator + // has attached it to a Perses instance is surfaced via the condition message + // but does not gate readiness (the Perses backend is managed elsewhere). + dsAvailable, dsReason, dsMessage := persesResourceAvailable(datasource.Status.Conditions) + r.markDashboardObjectReady(ctx, instance, telemetryv1.DashboardDatasourceReadyCondition, + dsAvailable, dsReason, dsMessage, fmt.Sprintf("PersesDatasource %s", datasource.Name)) - // Deploy ConfigMaps for dashboards - // NOTE: Dashboards installed without the custom datasource will default to the openshift-monitoring prometheus causing unexpected results - if dataSourceSuccess { - // Only enable DPDK sections of dataplane dashboard if dpdk service is enabled on at least one nodeset - dpdkConnectionInfo, err := getComputeNodesConnectionInfo(instance, helper, telemetry.DpdkServiceName) - hasDpdk := false - if err != nil { - Log.Info(fmt.Sprintf("Cannot get compute node connection info for dpdk service: %s", err)) - } else { - if len(dpdkConnectionInfo) > 0 { - hasDpdk = true - } - } - // Only enable SR-IOV sections of dataplane dashboard if neutron-sriov service is enabled on at least one nodeset - sriovConnectionInfo, err := getComputeNodesConnectionInfo(instance, helper, telemetry.SriovServiceName) - hasSriov := false - if err != nil { - Log.Info(fmt.Sprintf("Cannot get compute node connection info for sriov service: %s", err)) - } else { - if len(sriovConnectionInfo) > 0 { - hasSriov = true - } - } - dashboardCMs := map[string]*corev1.ConfigMap{ - "grafana-dashboard-openstack-cloud": dashboards.OpenstackCloud(datasourceName), - "grafana-dashboard-openstack-node": dashboards.OpenstackNode(datasourceName), - "grafana-dashboard-openstack-openstack-network": dashboards.OpenstackOpenstackNetwork(datasourceName, hasDpdk, hasSriov), - "grafana-dashboard-openstack-vm": dashboards.OpenstackVM(datasourceName), - "grafana-dashboard-openstack-rabbitmq": dashboards.OpenstackRabbitmq(datasourceName), - "grafana-dashboard-openstack-network-traffic": dashboards.OpenstackNetworkTraffic(datasourceName), - "grafana-dashboard-openstack-lightspeed": dashboards.OpenstackLightspeed(datasourceName), - } + // Deploy PersesDashboards + err = utils.EnsureWatches( + ctx, (*utils.ConditionalWatchingReconciler)(r), + "persesdashboards.perses.dev", + &persesv1alpha1.PersesDashboard{}, eventHandler, helper, + ) + if err != nil { + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, + condition.Reason("Can't own PersesDashboard resource. Ensure the monitoring plugin in COO is enabled to use Perses dashboards."), + condition.SeverityError, + telemetryv1.DashboardDefinitionFailedMessage, err) + Log.Info("Can't own PersesDashboard resource. Ensure the monitoring plugin in COO is enabled to use Perses dashboards.") + return ctrl.Result{RequeueAfter: telemetryv1.PauseBetweenWatchAttempts}, nil + } - // atleast one nodeset must have "telemetry-power-monitoring" service enabled for ipmi dashboard to be created - connectionInfo, err := getComputeNodesConnectionInfo(instance, helper, telemetryv1.TelemetryPowerMonitoring) - if err != nil { - Log.Info(fmt.Sprintf("Cannot get compute node connection info. Power monitoring dashboard not created. Error: %s", err)) - } else if len(connectionInfo) > 0 { - dashboardCMs["grafana-dashboard-openstack-ceilometer-ipmi"] = dashboards.OpenstackCeilometerIpmi(datasourceName) - } + disabledDashboards := make(map[string]bool, len(instance.Spec.DisabledDashboards)) + for _, name := range instance.Spec.DisabledDashboards { + disabledDashboards[name] = true + } - for dashboardName, desiredCM := range dashboardCMs { - dashboardCM := &corev1.ConfigMap{ + builders := dashboards.PersesDashboards() + + // Track whether every managed dashboard has been attached to a Perses + // instance by the perses-operator. Creating and owning the PersesDashboard + // objects is this operator's responsibility; the perses-operator's attachment + // state is surfaced via the condition message (reporting the first unattached + // dashboard) but does not gate readiness, since the Perses backend is managed + // elsewhere. + dashboardsAvailable := true + var unavailableReason, unavailableMessage, unavailableName string + + // Iterate over every dashboard the operator knows about. A dashboard is + // created when it has an implemented Perses builder and is not disabled by + // the administrator; otherwise any previously created object is removed. + for _, dashboardName := range dashboards.AllDashboardNames() { + builder, implemented := builders[dashboardName] + if !implemented || disabledDashboards[dashboardName] { + existing := &persesv1alpha1.PersesDashboard{ ObjectMeta: metav1.ObjectMeta{ Name: dashboardName, - Namespace: metricstorage.DashboardArtifactsNamespace, + Namespace: instance.Namespace, }, } - op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboardCM, func() error { - dashboardCM.Labels = desiredCM.Labels - dashboardCM.Data = desiredCM.Data - return nil - }) - if err != nil { - Log.Error(err, "Failed to update Dashboard ConfigMap %s - operation: %s", dashboardCM.Name, string(op)) - instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, - condition.Reason("Can't create Console UI Dashboard ConfigMap"), - condition.SeverityError, - telemetryv1.DashboardDefinitionFailedMessage, err) - } else { - instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDefinitionReadyCondition, condition.ReadyMessage) - } - if op != controllerutil.OperationResultNone { - Log.Info(fmt.Sprintf("Dashboard ConfigMap %s successfully changed - operation: %s", dashboardCM.Name, string(op))) + if res, err := utils.EnsureDeleted(ctx, helper, existing); err != nil { + return res, err } + continue + } + + dashboard := &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: dashboardName, + Namespace: instance.Namespace, + }, + } + op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboard, func() error { + desired := builder(metricstorage.PrometheusDatasourceName, instance.Namespace) + dashboard.Labels = desired.Labels + dashboard.Spec = desired.Spec + return controllerutil.SetControllerReference(instance, dashboard, r.Scheme) + }) + if err != nil { + Log.Error(err, fmt.Sprintf("Failed to update PersesDashboard %s", dashboard.Name)) + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, + condition.Reason("Can't create PersesDashboard"), + condition.SeverityError, + telemetryv1.DashboardDefinitionFailedMessage, err) + return ctrl.Result{}, err } + if op != controllerutil.OperationResultNone { + Log.Info(fmt.Sprintf("PersesDashboard %s successfully changed - operation: %s", dashboard.Name, string(op))) + } + if available, reason, message := persesResourceAvailable(dashboard.Status.Conditions); !available && dashboardsAvailable { + dashboardsAvailable = false + unavailableReason, unavailableMessage, unavailableName = reason, message, dashboard.Name + } + } + describe := "PersesDashboards" + if unavailableName != "" { + describe = fmt.Sprintf("PersesDashboard %s", unavailableName) } - return ctrl.Result{}, err + r.markDashboardObjectReady(ctx, instance, telemetryv1.DashboardDefinitionReadyCondition, + dashboardsAvailable, unavailableReason, unavailableMessage, describe) + + return ctrl.Result{}, nil } func getComputeNodesConnectionInfo( diff --git a/internal/controller/metricstorage_controller_test.go b/internal/controller/metricstorage_controller_test.go new file mode 100644 index 000000000..b29ee868b --- /dev/null +++ b/internal/controller/metricstorage_controller_test.go @@ -0,0 +1,76 @@ +package controller + +import ( + "testing" + + "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestPersesResourceAvailable checks that persesResourceAvailable reads the +// "Available" condition correctly: true when Available is True, false with the +// condition's reason/message otherwise (and a default when it is unset). +func TestPersesResourceAvailable(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + wantAvail bool + wantReason string + wantMessage string + }{ + { + name: "no conditions yet (freshly created / perses-operator not reconciled)", + conditions: nil, + wantAvail: false, + wantReason: "PersesReconciling", + wantMessage: "Waiting for the Perses operator to reconcile the object; no Perses instance may be available", + }, + { + name: "available true", + conditions: []metav1.Condition{ + {Type: "Available", Status: metav1.ConditionTrue, Reason: "Reconciled", Message: "ok"}, + {Type: "Degraded", Status: metav1.ConditionFalse}, + }, + wantAvail: true, + }, + { + name: "available false propagates the perses-operator reason and message", + conditions: []metav1.Condition{ + {Type: "Available", Status: metav1.ConditionFalse, Reason: "PersesMissing", Message: "no Perses instances found matching the label selector"}, + {Type: "Degraded", Status: metav1.ConditionTrue, Reason: "PersesMissing"}, + }, + wantAvail: false, + wantReason: "PersesMissing", + wantMessage: "no Perses instances found matching the label selector", + }, + { + name: "degraded true but available true is not misread as unavailable", + conditions: []metav1.Condition{ + {Type: "Degraded", Status: metav1.ConditionTrue, Reason: "SomethingTransient"}, + {Type: "Available", Status: metav1.ConditionTrue, Reason: "Reconciled"}, + }, + wantAvail: true, + }, + { + name: "available false with empty reason falls back to a non-empty reason", + conditions: []metav1.Condition{ + {Type: "Available", Status: metav1.ConditionFalse, Reason: "", Message: "backend error"}, + }, + wantAvail: false, + wantReason: "PersesBackendNotReady", + wantMessage: "backend error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + avail, reason, message := persesResourceAvailable(tt.conditions) + g.Expect(avail).To(gomega.Equal(tt.wantAvail)) + if !tt.wantAvail { + g.Expect(reason).To(gomega.Equal(tt.wantReason)) + g.Expect(message).To(gomega.Equal(tt.wantMessage)) + } + }) + } +} diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go new file mode 100644 index 000000000..9e1e164d5 --- /dev/null +++ b/internal/dashboards/perses.go @@ -0,0 +1,68 @@ +/* +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 dashboards contains the builders for the observability dashboards +// managed by the telemetry-operator. Dashboards are delivered as +// perses.dev PersesDashboard objects in the same namespace as the +// MetricStorage CR that owns them. +package dashboards + +import ( + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" +) + +// Canonical dashboard names. These are the identifiers an administrator uses in +// MetricStorage.spec.disabledDashboards to disable an individual dashboard, and +// the names of the PersesDashboard objects created in the openstack namespace. +const ( + OpenstackCloudName = "openstack-cloud" + OpenstackNodeName = "openstack-node" + OpenstackNetworkName = "openstack-openstack-network" + OpenstackVMName = "openstack-vm" + OpenstackRabbitmqName = "openstack-rabbitmq" + OpenstackNetworkTrafficName = "openstack-network-traffic" + OpenstackCeilometerIpmiName = "openstack-ceilometer-ipmi" + OpenstackLightspeedName = "openstack-lightspeed" +) + +// PersesDashboardBuilder builds a PersesDashboard object bound to the given +// datasource, in the given namespace. +type PersesDashboardBuilder func(datasourceName, namespace string) *persesv1alpha1.PersesDashboard + +// AllDashboardNames returns every dashboard name the operator knows about. +// It is the vocabulary accepted by MetricStorage.spec.disabledDashboards. +func AllDashboardNames() []string { + return []string{ + OpenstackCloudName, + OpenstackNodeName, + OpenstackNetworkName, + OpenstackVMName, + OpenstackRabbitmqName, + OpenstackNetworkTrafficName, + OpenstackCeilometerIpmiName, + OpenstackLightspeedName, + } +} + +// PersesDashboards returns the registry of PersesDashboard builders keyed by +// canonical dashboard name. Only dashboards whose Perses content has been +// implemented appear here; the remaining Grafana dashboards are converted and +// added separately. +func PersesDashboards() map[string]PersesDashboardBuilder { + return map[string]PersesDashboardBuilder{ + OpenstackCloudName: OpenstackCloudPerses, + } +} diff --git a/internal/dashboards/perses_openstack-cloud.go b/internal/dashboards/perses_openstack-cloud.go new file mode 100644 index 000000000..1f5c96136 --- /dev/null +++ b/internal/dashboards/perses_openstack-cloud.go @@ -0,0 +1,55 @@ +/* +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 dashboards + +import ( + "time" + + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + persesv1 "github.com/perses/perses/pkg/model/api/v1" + persescommon "github.com/perses/perses/pkg/model/api/v1/common" + persesdashboard "github.com/perses/perses/pkg/model/api/v1/dashboard" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// OpenstackCloudPerses builds the "OpenStack / Cloud" PersesDashboard. +// +// This is a minimal placeholder that establishes the object shape, ownership and +// datasource wiring. The full panel/layout content is converted from the legacy +// Grafana dashboard and delivered separately. +func OpenstackCloudPerses(_, namespace string) *persesv1alpha1.PersesDashboard { + return &persesv1alpha1.PersesDashboard{ + TypeMeta: metav1.TypeMeta{ + APIVersion: persesv1alpha1.GroupVersion.String(), + Kind: "PersesDashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: OpenstackCloudName, + Namespace: namespace, + }, + Spec: persesv1alpha1.Dashboard{ + DashboardSpec: persesv1.DashboardSpec{ + Display: &persescommon.Display{ + Name: "OpenStack / Cloud", + }, + Duration: persescommon.Duration(30 * time.Minute), + Panels: map[string]*persesv1.Panel{}, + Layouts: []persesdashboard.Layout{}, + }, + }, + } +} diff --git a/internal/metricstorage/dashboard_datasource.go b/internal/metricstorage/dashboard_datasource.go index c977bf57c..46b7d1980 100644 --- a/internal/metricstorage/dashboard_datasource.go +++ b/internal/metricstorage/dashboard_datasource.go @@ -17,47 +17,88 @@ limitations under the License. package metricstorage import ( - "context" + "fmt" - "github.com/openstack-k8s-operators/lib-common/modules/common/clusterdns" telemetryv1 "github.com/openstack-k8s-operators/telemetry-operator/api/v1beta1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + persesv1 "github.com/perses/perses/pkg/model/api/v1" + persescommon "github.com/perses/perses/pkg/model/api/v1/common" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// DashboardDatasourceData creates the data map for dashboard datasource configuration -func DashboardDatasourceData(ctx context.Context, c client.Client, instance *telemetryv1.MetricStorage, datasourceName string, namespace string) (map[string]string, error) { +const ( + // PrometheusDatasourceName is the name of the PersesDatasource the operator + // manages and that the managed PersesDashboards reference. + PrometheusDatasourceName = "prometheus" + // prometheusDatasourceDisplayName is the human readable name shown in the + // Perses UI for the managed Prometheus datasource. + prometheusDatasourceDisplayName = "Prometheus Datasource" + + // combinedCABundleSecretName is the secret holding the cluster CA bundle + // used to validate the Prometheus TLS certificate. + combinedCABundleSecretName = "combined-ca-bundle" //nolint:gosec // G101: secret name, not an actual credential + + // combinedCABundleKey is the key inside combinedCABundleSecretName that + // holds the CA bundle in PEM format. + combinedCABundleKey = "tls-ca-bundle.pem" +) + +// DashboardDatasource builds the PersesDatasource object that points the +// managed dashboards at the MetricStorage Prometheus instance. The datasource +// lives in the same namespace as the MetricStorage CR so it can be owned by it. +func DashboardDatasource(instance *telemetryv1.MetricStorage) *persesv1alpha1.PersesDatasource { scheme := "http" - certText := "" - dnsDomain := clusterdns.GetDNSClusterDomain() + + datasource := &persesv1alpha1.PersesDatasource{ + TypeMeta: metav1.TypeMeta{ + APIVersion: persesv1alpha1.GroupVersion.String(), + Kind: "PersesDatasource", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: PrometheusDatasourceName, + Namespace: instance.Namespace, + }, + } + if instance.Spec.PrometheusTLS.Enabled() { scheme = "https" - namespacedName := types.NamespacedName{ - Name: *instance.Spec.PrometheusTLS.SecretName, - Namespace: instance.Namespace, - } - caSecret := &corev1.Secret{} - err := c.Get(ctx, namespacedName, caSecret) - if err != nil { - return nil, err + datasource.Spec.Client = &persesv1alpha1.Client{ + TLS: &persesv1alpha1.TLS{ + Enable: true, + CaCert: &persesv1alpha1.Certificate{ + SecretSource: persesv1alpha1.SecretSource{ + Type: persesv1alpha1.SecretSourceTypeSecret, + Name: combinedCABundleSecretName, + Namespace: instance.Namespace, + }, + CertPath: combinedCABundleKey, + }, + }, } - certText = string(caSecret.Data["ca.crt"]) } - return map[string]string{ - // WARNING: The value lines below MUST be indented with spaces, not tabs (because they are YAML strings) - "dashboard-datasource-ca": certText, - "dashboard-datasource.yaml": ` -kind: "Datasource" -metadata: - name: "` + datasourceName + `" - project: "` + namespace + `" -spec: - plugin: - kind: "PrometheusDatasource" - spec: - direct_url: "` + scheme + `://metric-storage-prometheus.` + instance.Namespace + `.svc.` + dnsDomain + `:9090" -`}, nil + url := fmt.Sprintf("%s://metric-storage-prometheus.%s.svc:9090", scheme, instance.Namespace) + + datasource.Spec.Config = persesv1alpha1.Datasource{ + DatasourceSpec: persesv1.DatasourceSpec{ + Default: true, + Display: &persescommon.Display{ + Name: prometheusDatasourceDisplayName, + }, + Plugin: persescommon.Plugin{ + Kind: "PrometheusDatasource", + Spec: map[string]interface{}{ + "proxy": map[string]interface{}{ + "kind": "HTTPProxy", + "spec": map[string]interface{}{ + "url": url, + }, + }, + }, + }, + }, + } + + return datasource } diff --git a/internal/metricstorage/dashboard_objects.go b/internal/metricstorage/dashboard_objects.go index fca24ea55..9e1da6a3b 100644 --- a/internal/metricstorage/dashboard_objects.go +++ b/internal/metricstorage/dashboard_objects.go @@ -19,20 +19,47 @@ package metricstorage import ( "context" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" telemetryv1 "github.com/openstack-k8s-operators/telemetry-operator/api/v1beta1" + "github.com/openstack-k8s-operators/telemetry-operator/internal/dashboards" utils "github.com/openstack-k8s-operators/telemetry-operator/internal/utils" monv1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1" ) -// DashboardArtifactsNamespace is the namespace where dashboard artifacts are stored +// DashboardArtifactsNamespace is the namespace where the legacy ConfigMap-based +// dashboard artifacts were stored by earlier versions of the operator. const DashboardArtifactsNamespace = "openshift-config-managed" -// DeleteDashboardObjects removes dashboard-related objects for the MetricStorage instance +// legacyDashboardConfigMapNames are the ConfigMap-based dashboards created by +// earlier versions of the operator in DashboardArtifactsNamespace. They are +// removed on upgrade in favour of the PersesDashboard objects. This list is the +// full set of legacy artifacts to clean up and is intentionally a superset of +// dashboards.AllDashboardNames(): e.g. "grafana-dashboard-openstack-kepler" has +// no Perses equivalent (no planned PersesDashboard), but its legacy ConfigMap +// must still be removed on upgrade. +var legacyDashboardConfigMapNames = []string{ + "grafana-dashboard-openstack-cloud", + "grafana-dashboard-openstack-node", + "grafana-dashboard-openstack-openstack-network", + "grafana-dashboard-openstack-vm", + "grafana-dashboard-openstack-rabbitmq", + "grafana-dashboard-openstack-network-traffic", + "grafana-dashboard-openstack-ceilometer-ipmi", + "grafana-dashboard-openstack-lightspeed", + "grafana-dashboard-openstack-kepler", +} + +// DeleteDashboardObjects removes the dashboard-related objects owned by the +// MetricStorage instance. It is used both on the disable path +// (DashboardsEnabled=false) and on CR deletion. Perses object deletions +// tolerate the perses.dev CRDs not being installed. func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper) (ctrl.Result, error) { promRule := &monv1.PrometheusRule{ ObjectMeta: metav1.ObjectMeta{ @@ -44,38 +71,60 @@ func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricSto return res, err } - datasourceName := instance.Namespace + "-" + instance.Name + "-datasource" - datasourceCM := &corev1.ConfigMap{ + datasource := &persesv1alpha1.PersesDatasource{ ObjectMeta: metav1.ObjectMeta{ - Name: datasourceName, - Namespace: DashboardArtifactsNamespace, + Name: PrometheusDatasourceName, + Namespace: instance.Namespace, }, } - if res, err := utils.EnsureDeleted(ctx, helper, datasourceCM); err != nil { + if res, err := ensureDeletedTolerateNoCRD(ctx, helper, datasource); err != nil { return res, err } - var dashboards = []string{ - "grafana-dashboard-openstack-cloud", - "grafana-dashboard-openstack-node", - "grafana-dashboard-openstack-openstack-network", - "grafana-dashboard-openstack-vm", - "grafana-dashboard-openstack-rabbitmq", - "grafana-dashboard-openstack-network-traffic", - "grafana-dashboard-openstack-ceilometer-ipmi", - "grafana-dashboard-openstack-lightspeed", + for _, name := range dashboards.AllDashboardNames() { + dashboard := &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: instance.Namespace, + }, + } + if res, err := ensureDeletedTolerateNoCRD(ctx, helper, dashboard); err != nil { + return res, err + } } - for _, name := range dashboards { - dashboardCM := &corev1.ConfigMap{ + + return ctrl.Result{}, nil +} + +// CleanupLegacyDashboardConfigMaps removes the ConfigMap-based dashboard +// artifacts created by earlier versions of the operator in the shared +// openshift-config-managed namespace. It is idempotent and safe to call on +// every reconcile regardless of whether dashboards are enabled. +func CleanupLegacyDashboardConfigMaps(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper) (ctrl.Result, error) { + datasourceCMName := instance.Namespace + "-" + instance.Name + "-datasource" + names := append([]string{datasourceCMName}, legacyDashboardConfigMapNames...) + for _, name := range names { + configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: DashboardArtifactsNamespace, }, } - if res, err := utils.EnsureDeleted(ctx, helper, dashboardCM); err != nil { + if res, err := utils.EnsureDeleted(ctx, helper, configMap); err != nil { return res, err } } return ctrl.Result{}, nil } + +// ensureDeletedTolerateNoCRD deletes an object but treats a missing CRD (no REST +// mapping) as success, so the operator does not error on clusters where the +// perses.dev CRDs are not installed. +func ensureDeletedTolerateNoCRD(ctx context.Context, helper *helper.Helper, obj client.Object) (ctrl.Result, error) { + res, err := utils.EnsureDeleted(ctx, helper, obj) + if err != nil && meta.IsNoMatchError(err) { + return ctrl.Result{}, nil + } + return res, err +} diff --git a/test/kuttl/tests/default/01-assert.yaml b/test/kuttl/tests/default/01-assert.yaml index 83a61f676..72b55b132 100644 --- a/test/kuttl/tests/default/01-assert.yaml +++ b/test/kuttl/tests/default/01-assert.yaml @@ -195,8 +195,6 @@ status: status: "True" - type: DashboardDefinitionReady status: "True" - - type: DashboardPluginReady - status: "True" - type: DashboardPrometheusRuleReady status: "True" - type: MonitoringStackReady diff --git a/test/kuttl/tests/metricstorage/00-deps.yaml b/test/kuttl/tests/metricstorage/00-deps.yaml index 3fe63f68c..8c8992799 100644 --- a/test/kuttl/tests/metricstorage/00-deps.yaml +++ b/test/kuttl/tests/metricstorage/00-deps.yaml @@ -4,3 +4,7 @@ commands: - script: | oc apply -f deps/rhobs.yaml until oc api-resources | grep -q rhobs; do sleep 1; done + # The Cluster Observability Operator ships the perses.dev CRDs + # (PersesDatasource/PersesDashboard) used to deliver dashboards. + until oc api-resources | grep -q persesdatasources; do sleep 1; done + until oc api-resources | grep -q persesdashboards; do sleep 1; done diff --git a/test/kuttl/tests/metricstorage/01-assert.yaml b/test/kuttl/tests/metricstorage/01-assert.yaml index 3b69524ad..6abc11b94 100644 --- a/test/kuttl/tests/metricstorage/01-assert.yaml +++ b/test/kuttl/tests/metricstorage/01-assert.yaml @@ -175,21 +175,6 @@ spec: targetLabel: __address__ scrapeInterval: 30s --- -apiVersion: observability.openshift.io/v1alpha1 -kind: UIPlugin -metadata: - name: dashboards -spec: - type: Dashboards -status: - conditions: - - status: "True" - type: Reconciled - - status: "False" - type: Degraded - - status: "True" - type: Available ---- apiVersion: monitoring.rhobs/v1 kind: PrometheusRule metadata: @@ -199,47 +184,25 @@ spec: - name: osp-node-exporter-dashboard.rules - name: osp-ceilometer-dashboard.rules --- -apiVersion: v1 -kind: ConfigMap -metadata: - name: telemetry-kuttl-tests-telemetry-kuttl-datasource - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-cloud - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource metadata: - name: grafana-dashboard-openstack-node - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-openstack-network - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-vm - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-rabbitmq - namespace: openshift-config-managed + name: prometheus +spec: + config: + default: true + display: + name: Prometheus Datasource + plugin: + kind: PrometheusDatasource --- -apiVersion: v1 -kind: ConfigMap +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard metadata: - name: grafana-dashboard-openstack-network-traffic - namespace: openshift-config-managed + name: openstack-cloud +spec: + display: + name: OpenStack / Cloud --- apiVersion: v1 kind: Secret diff --git a/test/kuttl/tests/metricstorage/02-errors.yaml b/test/kuttl/tests/metricstorage/02-errors.yaml index 556666dd0..b3c51caf9 100644 --- a/test/kuttl/tests/metricstorage/02-errors.yaml +++ b/test/kuttl/tests/metricstorage/02-errors.yaml @@ -7,6 +7,16 @@ spec: - name: osp-node-exporter-dashboard.rules - name: osp-ceilometer-dashboard.rules --- +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource +metadata: + name: prometheus +--- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-cloud +--- apiVersion: v1 kind: ConfigMap metadata: diff --git a/test/kuttl/tests/metricstorage/07-assert.yaml b/test/kuttl/tests/metricstorage/07-assert.yaml index 166e20ed6..b9c60d7c5 100644 --- a/test/kuttl/tests/metricstorage/07-assert.yaml +++ b/test/kuttl/tests/metricstorage/07-assert.yaml @@ -1,7 +1,18 @@ -apiVersion: v1 -kind: ConfigMap +# Dashboards are re-enabled at step 06 with the power-monitoring EDPM resources +# deployed. Assert the managed Perses datasource and dashboards are present. The +# power-monitoring (ceilometer-ipmi) dashboard is not yet available as a Perses +# dashboard; its absence is asserted in 07-errors.yaml. +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource metadata: - labels: - console.openshift.io/dashboard: "true" - name: grafana-dashboard-openstack-ceilometer-ipmi - namespace: openshift-config-managed + name: prometheus +spec: + config: + default: true + plugin: + kind: PrometheusDatasource +--- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-cloud diff --git a/test/kuttl/tests/metricstorage/07-errors.yaml b/test/kuttl/tests/metricstorage/07-errors.yaml new file mode 100644 index 000000000..45e0a7bff --- /dev/null +++ b/test/kuttl/tests/metricstorage/07-errors.yaml @@ -0,0 +1,13 @@ +# The ceilometer-ipmi dashboard has not been converted to Perses yet, so it must +# not be created even when power monitoring is enabled. The legacy ConfigMap in +# openshift-config-managed must also stay removed after the upgrade cleanup. +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-ceilometer-ipmi + namespace: openshift-config-managed diff --git a/test/kuttl/tests/metricstorage/08-assert.yaml b/test/kuttl/tests/metricstorage/08-assert.yaml new file mode 100644 index 000000000..0a443ecf0 --- /dev/null +++ b/test/kuttl/tests/metricstorage/08-assert.yaml @@ -0,0 +1,11 @@ +# Dashboards remain enabled, so the datasource is still present, but the +# openstack-cloud dashboard is disabled by name (asserted absent in 08-errors). +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource +metadata: + name: prometheus +spec: + config: + default: true + plugin: + kind: PrometheusDatasource diff --git a/test/kuttl/tests/metricstorage/08-disable-by-name.yaml b/test/kuttl/tests/metricstorage/08-disable-by-name.yaml new file mode 100644 index 000000000..d5ed1b759 --- /dev/null +++ b/test/kuttl/tests/metricstorage/08-disable-by-name.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: telemetry.openstack.org/v1beta1 +kind: MetricStorage +metadata: + name: telemetry-kuttl +spec: + dashboardsEnabled: true + disabledDashboards: + - openstack-cloud + dataplaneNetwork: ctlplane + monitoringStack: + alertingEnabled: true + scrapeInterval: 30s + storage: + strategy: persistent + retention: 24h + persistent: + pvcStorageRequest: 10G + pvcStorageClass: local-storage + networkAttachments: [] diff --git a/test/kuttl/tests/metricstorage/08-errors.yaml b/test/kuttl/tests/metricstorage/08-errors.yaml new file mode 100644 index 000000000..e20c98679 --- /dev/null +++ b/test/kuttl/tests/metricstorage/08-errors.yaml @@ -0,0 +1,6 @@ +# openstack-cloud is listed in disabledDashboards, so its PersesDashboard must +# not exist even though dashboards are enabled. +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-cloud From fe3142a65f38b2aa98405eeaa97be362ee54364d Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Fri, 9 Oct 2026 17:13:38 +0200 Subject: [PATCH 02/16] Drop non-required dashboards migration checks - Make dashboards.PersesDashboards() the single source of truth for available T-O dashboards - Iterate the registry in a stable (sorted) order so the reported unattached dashboard, and the derived condition message and log, are deterministic regardless of Go map iteration order. - Move the persesResourceAvailable helper into api/v1beta1/conditions.go as the exported PersesResourceAvailable, and relocate its unit test. - Restructure the metricstorage kuttl step 07 to assert the dashboards that should exist instead of asserting the absence of a dashboard no code path creates Related-To: OSPRH-37817 Signed-off-by: Victoria Martinez de la Cruz Assisted-By: Claude Opus 4.8 --- ...elemetry.openstack.org_metricstorages.yaml | 4 +- .../telemetry.openstack.org_telemetries.yaml | 4 +- api/v1beta1/conditions.go | 21 +++++++++ .../v1beta1/conditions_test.go | 16 +++---- api/v1beta1/metricstorage_types.go | 4 +- ...elemetry.openstack.org_metricstorages.yaml | 4 +- .../telemetry.openstack.org_telemetries.yaml | 4 +- .../controller/metricstorage_controller.go | 46 ++++++++----------- internal/dashboards/perses.go | 30 ++---------- internal/metricstorage/dashboard_objects.go | 8 +--- test/kuttl/tests/metricstorage/07-assert.yaml | 5 +- test/kuttl/tests/metricstorage/07-errors.yaml | 11 ++--- 12 files changed, 68 insertions(+), 89 deletions(-) rename internal/controller/metricstorage_controller_test.go => api/v1beta1/conditions_test.go (85%) diff --git a/api/bases/telemetry.openstack.org_metricstorages.yaml b/api/bases/telemetry.openstack.org_metricstorages.yaml index ff1ab05bb..59cacc37e 100644 --- a/api/bases/telemetry.openstack.org_metricstorages.yaml +++ b/api/bases/telemetry.openstack.org_metricstorages.yaml @@ -1749,8 +1749,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator (for example "openstack-cloud", - "openstack-node", "openstack-vm"). + dashboards managed by the operator. Names that are not managed by the + operator are ignored. items: type: string type: array diff --git a/api/bases/telemetry.openstack.org_telemetries.yaml b/api/bases/telemetry.openstack.org_telemetries.yaml index c9fdcfc0e..e49559f91 100644 --- a/api/bases/telemetry.openstack.org_telemetries.yaml +++ b/api/bases/telemetry.openstack.org_telemetries.yaml @@ -3151,8 +3151,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator (for example "openstack-cloud", - "openstack-node", "openstack-vm"). + dashboards managed by the operator. Names that are not managed by the + operator are ignored. items: type: string type: array diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 01947cb49..26b081f33 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -17,8 +17,29 @@ package v1beta1 import ( condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// PersesResourceAvailable reports whether the given PersesDatasource or +// PersesDashboard resource is attached to a Perses instance, by checking its +// "Available" condition. It returns true when Available is True, otherwise +// false with the condition's reason and message (or a default when unset). +func PersesResourceAvailable(conditions []metav1.Condition) (bool, string, string) { + available := meta.FindStatusCondition(conditions, "Available") + if available == nil { + return false, "PersesReconciling", "Waiting for the Perses operator to reconcile the object; no Perses instance may be available" + } + if available.Status != metav1.ConditionTrue { + reason := available.Reason + if reason == "" { + reason = "PersesBackendNotReady" + } + return false, reason, available.Message + } + return true, "", "" +} + // Telemetry Condition Types used by API objects. const ( // CeilometerReadyCondition Status=True condition which indicates if the Ceilometer is configured and operational diff --git a/internal/controller/metricstorage_controller_test.go b/api/v1beta1/conditions_test.go similarity index 85% rename from internal/controller/metricstorage_controller_test.go rename to api/v1beta1/conditions_test.go index b29ee868b..8d90ae93f 100644 --- a/internal/controller/metricstorage_controller_test.go +++ b/api/v1beta1/conditions_test.go @@ -1,13 +1,13 @@ -package controller +package v1beta1 import ( "testing" - "github.com/onsi/gomega" + . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// TestPersesResourceAvailable checks that persesResourceAvailable reads the +// TestPersesResourceAvailable checks that PersesResourceAvailable reads the // "Available" condition correctly: true when Available is True, false with the // condition's reason/message otherwise (and a default when it is unset). func TestPersesResourceAvailable(t *testing.T) { @@ -64,12 +64,12 @@ func TestPersesResourceAvailable(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - g := gomega.NewWithT(t) - avail, reason, message := persesResourceAvailable(tt.conditions) - g.Expect(avail).To(gomega.Equal(tt.wantAvail)) + g := NewWithT(t) + avail, reason, message := PersesResourceAvailable(tt.conditions) + g.Expect(avail).To(Equal(tt.wantAvail)) if !tt.wantAvail { - g.Expect(reason).To(gomega.Equal(tt.wantReason)) - g.Expect(message).To(gomega.Equal(tt.wantMessage)) + g.Expect(reason).To(Equal(tt.wantReason)) + g.Expect(message).To(Equal(tt.wantMessage)) } }) } diff --git a/api/v1beta1/metricstorage_types.go b/api/v1beta1/metricstorage_types.go index 95af2dd38..4f0545d01 100644 --- a/api/v1beta1/metricstorage_types.go +++ b/api/v1beta1/metricstorage_types.go @@ -91,8 +91,8 @@ type MetricStorageSpec struct { // DisabledDashboards is a list of dashboard names that should not be // created even when DashboardsEnabled is true. Valid names match the - // dashboards managed by the operator (for example "openstack-cloud", - // "openstack-node", "openstack-vm"). + // dashboards managed by the operator. Names that are not managed by the + // operator are ignored. // +kubebuilder:validation:Optional // +listType=atomic DisabledDashboards []string `json:"disabledDashboards,omitempty"` diff --git a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml index ff1ab05bb..59cacc37e 100644 --- a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml +++ b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml @@ -1749,8 +1749,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator (for example "openstack-cloud", - "openstack-node", "openstack-vm"). + dashboards managed by the operator. Names that are not managed by the + operator are ignored. items: type: string type: array diff --git a/config/crd/bases/telemetry.openstack.org_telemetries.yaml b/config/crd/bases/telemetry.openstack.org_telemetries.yaml index c9fdcfc0e..e49559f91 100644 --- a/config/crd/bases/telemetry.openstack.org_telemetries.yaml +++ b/config/crd/bases/telemetry.openstack.org_telemetries.yaml @@ -3151,8 +3151,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator (for example "openstack-cloud", - "openstack-node", "openstack-vm"). + dashboards managed by the operator. Names that are not managed by the + operator are ignored. items: type: string type: array diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index 3fcdc16b8..a4e5462a8 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -23,6 +23,7 @@ import ( "net" "reflect" "regexp" + "sort" "strconv" "strings" "time" @@ -31,7 +32,6 @@ import ( discoveryv1 "k8s.io/api/discovery/v1" rbacv1 "k8s.io/api/rbac/v1" k8s_errors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" @@ -1387,25 +1387,6 @@ func (r *MetricStorageReconciler) createInstanceHAScrapeConfig( ) } -// persesResourceAvailable reports whether the given PersesDatasource or -// PersesDashboard resource is attached to a Perses instance, by checking its -// "Available" condition. It returns true when Available is True, otherwise -// false with the condition's reason and message (or a default when unset). -func persesResourceAvailable(conditions []metav1.Condition) (bool, string, string) { - available := meta.FindStatusCondition(conditions, "Available") - if available == nil { - return false, "PersesReconciling", "Waiting for the Perses operator to reconcile the object; no Perses instance may be available" - } - if available.Status != metav1.ConditionTrue { - reason := available.Reason - if reason == "" { - reason = "PersesBackendNotReady" - } - return false, reason, available.Message - } - return true, "", "" -} - // markDashboardObjectReady marks a dashboard-related condition True once the // telemetry-operator has created and owns the object, which is the full extent // of this operator's responsibility: the Perses backend (instance/UIPlugin) is @@ -1509,7 +1490,7 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in // so the condition is Ready once that succeeds. Whether the perses-operator // has attached it to a Perses instance is surfaced via the condition message // but does not gate readiness (the Perses backend is managed elsewhere). - dsAvailable, dsReason, dsMessage := persesResourceAvailable(datasource.Status.Conditions) + dsAvailable, dsReason, dsMessage := telemetryv1.PersesResourceAvailable(datasource.Status.Conditions) r.markDashboardObjectReady(ctx, instance, telemetryv1.DashboardDatasourceReadyCondition, dsAvailable, dsReason, dsMessage, fmt.Sprintf("PersesDatasource %s", datasource.Name)) @@ -1534,6 +1515,14 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in } builders := dashboards.PersesDashboards() + // Iterate the registry in a stable order so the reported unavailable + // dashboard (and the derived condition message/log) is deterministic + // regardless of Go's random map iteration order. + dashboardNames := make([]string, 0, len(builders)) + for name := range builders { + dashboardNames = append(dashboardNames, name) + } + sort.Strings(dashboardNames) // Track whether every managed dashboard has been attached to a Perses // instance by the perses-operator. Creating and owning the PersesDashboard @@ -1544,12 +1533,13 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in dashboardsAvailable := true var unavailableReason, unavailableMessage, unavailableName string - // Iterate over every dashboard the operator knows about. A dashboard is - // created when it has an implemented Perses builder and is not disabled by - // the administrator; otherwise any previously created object is removed. - for _, dashboardName := range dashboards.AllDashboardNames() { - builder, implemented := builders[dashboardName] - if !implemented || disabledDashboards[dashboardName] { + // Iterate over the implemented dashboards registry, the single source of + // truth for the dashboards the operator manages. A dashboard is created + // unless the administrator disabled it by name, in which case any previously + // created object is removed. + for _, dashboardName := range dashboardNames { + builder := builders[dashboardName] + if disabledDashboards[dashboardName] { existing := &persesv1alpha1.PersesDashboard{ ObjectMeta: metav1.ObjectMeta{ Name: dashboardName, @@ -1585,7 +1575,7 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in if op != controllerutil.OperationResultNone { Log.Info(fmt.Sprintf("PersesDashboard %s successfully changed - operation: %s", dashboard.Name, string(op))) } - if available, reason, message := persesResourceAvailable(dashboard.Status.Conditions); !available && dashboardsAvailable { + if available, reason, message := telemetryv1.PersesResourceAvailable(dashboard.Status.Conditions); !available && dashboardsAvailable { dashboardsAvailable = false unavailableReason, unavailableMessage, unavailableName = reason, message, dashboard.Name } diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go index 9e1e164d5..4c1797436 100644 --- a/internal/dashboards/perses.go +++ b/internal/dashboards/perses.go @@ -27,40 +27,18 @@ import ( // Canonical dashboard names. These are the identifiers an administrator uses in // MetricStorage.spec.disabledDashboards to disable an individual dashboard, and // the names of the PersesDashboard objects created in the openstack namespace. +// A name is defined here alongside its builder as each dashboard is converted to +// Perses. const ( - OpenstackCloudName = "openstack-cloud" - OpenstackNodeName = "openstack-node" - OpenstackNetworkName = "openstack-openstack-network" - OpenstackVMName = "openstack-vm" - OpenstackRabbitmqName = "openstack-rabbitmq" - OpenstackNetworkTrafficName = "openstack-network-traffic" - OpenstackCeilometerIpmiName = "openstack-ceilometer-ipmi" - OpenstackLightspeedName = "openstack-lightspeed" + OpenstackCloudName = "openstack-cloud" ) // PersesDashboardBuilder builds a PersesDashboard object bound to the given // datasource, in the given namespace. type PersesDashboardBuilder func(datasourceName, namespace string) *persesv1alpha1.PersesDashboard -// AllDashboardNames returns every dashboard name the operator knows about. -// It is the vocabulary accepted by MetricStorage.spec.disabledDashboards. -func AllDashboardNames() []string { - return []string{ - OpenstackCloudName, - OpenstackNodeName, - OpenstackNetworkName, - OpenstackVMName, - OpenstackRabbitmqName, - OpenstackNetworkTrafficName, - OpenstackCeilometerIpmiName, - OpenstackLightspeedName, - } -} - // PersesDashboards returns the registry of PersesDashboard builders keyed by -// canonical dashboard name. Only dashboards whose Perses content has been -// implemented appear here; the remaining Grafana dashboards are converted and -// added separately. +// canonical dashboard name. func PersesDashboards() map[string]PersesDashboardBuilder { return map[string]PersesDashboardBuilder{ OpenstackCloudName: OpenstackCloudPerses, diff --git a/internal/metricstorage/dashboard_objects.go b/internal/metricstorage/dashboard_objects.go index 9e1da6a3b..574f8ab9f 100644 --- a/internal/metricstorage/dashboard_objects.go +++ b/internal/metricstorage/dashboard_objects.go @@ -39,11 +39,7 @@ const DashboardArtifactsNamespace = "openshift-config-managed" // legacyDashboardConfigMapNames are the ConfigMap-based dashboards created by // earlier versions of the operator in DashboardArtifactsNamespace. They are -// removed on upgrade in favour of the PersesDashboard objects. This list is the -// full set of legacy artifacts to clean up and is intentionally a superset of -// dashboards.AllDashboardNames(): e.g. "grafana-dashboard-openstack-kepler" has -// no Perses equivalent (no planned PersesDashboard), but its legacy ConfigMap -// must still be removed on upgrade. +// removed on upgrade in favour of the PersesDashboard objects. var legacyDashboardConfigMapNames = []string{ "grafana-dashboard-openstack-cloud", "grafana-dashboard-openstack-node", @@ -81,7 +77,7 @@ func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricSto return res, err } - for _, name := range dashboards.AllDashboardNames() { + for name := range dashboards.PersesDashboards() { dashboard := &persesv1alpha1.PersesDashboard{ ObjectMeta: metav1.ObjectMeta{ Name: name, diff --git a/test/kuttl/tests/metricstorage/07-assert.yaml b/test/kuttl/tests/metricstorage/07-assert.yaml index b9c60d7c5..0c9e220d9 100644 --- a/test/kuttl/tests/metricstorage/07-assert.yaml +++ b/test/kuttl/tests/metricstorage/07-assert.yaml @@ -1,7 +1,6 @@ # Dashboards are re-enabled at step 06 with the power-monitoring EDPM resources -# deployed. Assert the managed Perses datasource and dashboards are present. The -# power-monitoring (ceilometer-ipmi) dashboard is not yet available as a Perses -# dashboard; its absence is asserted in 07-errors.yaml. +# deployed. Assert exactly the managed Perses objects that should exist: the +# datasource and the implemented openstack-cloud dashboard. apiVersion: perses.dev/v1alpha1 kind: PersesDatasource metadata: diff --git a/test/kuttl/tests/metricstorage/07-errors.yaml b/test/kuttl/tests/metricstorage/07-errors.yaml index 45e0a7bff..cc5471664 100644 --- a/test/kuttl/tests/metricstorage/07-errors.yaml +++ b/test/kuttl/tests/metricstorage/07-errors.yaml @@ -1,11 +1,6 @@ -# The ceilometer-ipmi dashboard has not been converted to Perses yet, so it must -# not be created even when power monitoring is enabled. The legacy ConfigMap in -# openshift-config-managed must also stay removed after the upgrade cleanup. -apiVersion: perses.dev/v1alpha1 -kind: PersesDashboard -metadata: - name: openstack-ceilometer-ipmi ---- +# Step 06 enables power monitoring, which under the legacy ConfigMap delivery +# created the ceilometer-ipmi dashboard. Assert that legacy ConfigMap stays +# removed after the upgrade cleanup. apiVersion: v1 kind: ConfigMap metadata: From 825122d60028c7dc659e6a2ffc03c09c33d53afc Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Sat, 10 Oct 2026 11:24:11 +0200 Subject: [PATCH 03/16] Fix Perses datasource TLS to Prometheus When MetricStorage.spec.prometheusTls is enabled, the Prometheus datasource is served over HTTPS, but Perses-proxied queries failed with "x509: certificate signed by unknown authority". The perses-operator creates a Perses Secret ("-secret") from the datasource's client.tls config, but does not wire it into the prox. The builder set client.tls (so the CA secret was created) yet never referenced it from the proxy, so Perses queried Prometheus without the CA. Set the datasource proxy's secret to that Perses Secret when Prometheus TLS is enabled, so Perses verifies the Prometheus serving certificate. Related-To: OSPRH-37817 Signed-off-by: Victoria Martinez de la Cruz Assisted-By: Claude Opus 4.8 --- .../metricstorage/dashboard_datasource.go | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/internal/metricstorage/dashboard_datasource.go b/internal/metricstorage/dashboard_datasource.go index 46b7d1980..b9ac183c0 100644 --- a/internal/metricstorage/dashboard_datasource.go +++ b/internal/metricstorage/dashboard_datasource.go @@ -42,6 +42,13 @@ const ( // combinedCABundleKey is the key inside combinedCABundleSecretName that // holds the CA bundle in PEM format. combinedCABundleKey = "tls-ca-bundle.pem" + + // persesDatasourceSecretName is the Perses Secret the perses-operator + // generates from the datasource's client.tls config. The perses-operator + // names it "-secret" (its SecretNameSuffix) but does NOT wire it + // into the datasource proxy automatically, so the proxy must reference it by + // name for Perses to use the CA when querying Prometheus over TLS. + persesDatasourceSecretName = PrometheusDatasourceName + "-secret" ) // DashboardDatasource builds the PersesDatasource object that points the @@ -80,6 +87,17 @@ func DashboardDatasource(instance *telemetryv1.MetricStorage) *persesv1alpha1.Pe url := fmt.Sprintf("%s://metric-storage-prometheus.%s.svc:9090", scheme, instance.Namespace) + proxySpec := map[string]interface{}{ + "url": url, + } + if instance.Spec.PrometheusTLS.Enabled() { + // Reference the Perses Secret the perses-operator creates from the + // client.tls config above, so the Perses proxy verifies the Prometheus + // serving certificate instead of failing with "certificate signed by + // unknown authority". + proxySpec["secret"] = persesDatasourceSecretName + } + datasource.Spec.Config = persesv1alpha1.Datasource{ DatasourceSpec: persesv1.DatasourceSpec{ Default: true, @@ -91,9 +109,7 @@ func DashboardDatasource(instance *telemetryv1.MetricStorage) *persesv1alpha1.Pe Spec: map[string]interface{}{ "proxy": map[string]interface{}{ "kind": "HTTPProxy", - "spec": map[string]interface{}{ - "url": url, - }, + "spec": proxySpec, }, }, }, From 0fd841950a5b967d06c33d4f65f5374f80cac23b Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Fri, 16 Oct 2026 16:18:27 +0200 Subject: [PATCH 04/16] Restore examples in DisabledDashboards doc The DisabledDashboards field doc previously listed example dashboard names (openstack-cloud, openstack-node, openstack-vm). A later commit replaced them with generic wording. Restore the examples, which are more helpful, per review feedback. Regenerate the CRD manifests accordingly. Related-To: OSPRH-37817 Signed-off-by: Victoria Martinez de la Cruz Assisted-By: Claude Opus 4.8 --- api/bases/telemetry.openstack.org_metricstorages.yaml | 4 ++-- api/bases/telemetry.openstack.org_telemetries.yaml | 4 ++-- api/v1beta1/metricstorage_types.go | 4 ++-- config/crd/bases/telemetry.openstack.org_metricstorages.yaml | 4 ++-- config/crd/bases/telemetry.openstack.org_telemetries.yaml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api/bases/telemetry.openstack.org_metricstorages.yaml b/api/bases/telemetry.openstack.org_metricstorages.yaml index 59cacc37e..ff1ab05bb 100644 --- a/api/bases/telemetry.openstack.org_metricstorages.yaml +++ b/api/bases/telemetry.openstack.org_metricstorages.yaml @@ -1749,8 +1749,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator. Names that are not managed by the - operator are ignored. + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). items: type: string type: array diff --git a/api/bases/telemetry.openstack.org_telemetries.yaml b/api/bases/telemetry.openstack.org_telemetries.yaml index e49559f91..c9fdcfc0e 100644 --- a/api/bases/telemetry.openstack.org_telemetries.yaml +++ b/api/bases/telemetry.openstack.org_telemetries.yaml @@ -3151,8 +3151,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator. Names that are not managed by the - operator are ignored. + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). items: type: string type: array diff --git a/api/v1beta1/metricstorage_types.go b/api/v1beta1/metricstorage_types.go index 4f0545d01..95af2dd38 100644 --- a/api/v1beta1/metricstorage_types.go +++ b/api/v1beta1/metricstorage_types.go @@ -91,8 +91,8 @@ type MetricStorageSpec struct { // DisabledDashboards is a list of dashboard names that should not be // created even when DashboardsEnabled is true. Valid names match the - // dashboards managed by the operator. Names that are not managed by the - // operator are ignored. + // dashboards managed by the operator (for example "openstack-cloud", + // "openstack-node", "openstack-vm"). // +kubebuilder:validation:Optional // +listType=atomic DisabledDashboards []string `json:"disabledDashboards,omitempty"` diff --git a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml index 59cacc37e..ff1ab05bb 100644 --- a/config/crd/bases/telemetry.openstack.org_metricstorages.yaml +++ b/config/crd/bases/telemetry.openstack.org_metricstorages.yaml @@ -1749,8 +1749,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator. Names that are not managed by the - operator are ignored. + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). items: type: string type: array diff --git a/config/crd/bases/telemetry.openstack.org_telemetries.yaml b/config/crd/bases/telemetry.openstack.org_telemetries.yaml index e49559f91..c9fdcfc0e 100644 --- a/config/crd/bases/telemetry.openstack.org_telemetries.yaml +++ b/config/crd/bases/telemetry.openstack.org_telemetries.yaml @@ -3151,8 +3151,8 @@ spec: description: |- DisabledDashboards is a list of dashboard names that should not be created even when DashboardsEnabled is true. Valid names match the - dashboards managed by the operator. Names that are not managed by the - operator are ignored. + dashboards managed by the operator (for example "openstack-cloud", + "openstack-node", "openstack-vm"). items: type: string type: array From 06237476144368e88484d81f91bdc22d42529995 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 15:05:49 +0100 Subject: [PATCH 05/16] Store Perses dashboards as v1alpha2 JSON Keep dashboard definitions as perses.dev/v1alpha2 PersesDashboard JSON, the format emitted by the Grafana-to-Perses conversion tooling and exported by the Perses UI, while still creating perses.dev/v1alpha1 objects, the API served by the supported perses-operator v0.2.0. - Add dashboardFromJSON, which adapts a v1alpha2 definition to v1alpha1 by moving spec.config into the v1alpha1 inline spec. Definitions with no spec.config section are rejected instead of yielding an empty dashboard. - Builders now return an error, which the controller surfaces through the CreateOrPatch mutate function. - Store the openstack-cloud placeholder dashboard as JSON. - Add unit tests for the registry builders and the adapter. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../controller/metricstorage_controller.go | 5 +- internal/dashboards/perses.go | 48 ++++++++++++- internal/dashboards/perses_openstack-cloud.go | 52 +++++++------- internal/dashboards/perses_test.go | 72 +++++++++++++++++++ 4 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 internal/dashboards/perses_test.go diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index a4e5462a8..3ecbb2bae 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -1559,7 +1559,10 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in }, } op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboard, func() error { - desired := builder(metricstorage.PrometheusDatasourceName, instance.Namespace) + desired, err := builder(metricstorage.PrometheusDatasourceName, instance.Namespace) + if err != nil { + return err + } dashboard.Labels = desired.Labels dashboard.Spec = desired.Spec return controllerutil.SetControllerReference(instance, dashboard, r.Scheme) diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go index 4c1797436..24047606d 100644 --- a/internal/dashboards/perses.go +++ b/internal/dashboards/perses.go @@ -21,7 +21,12 @@ limitations under the License. package dashboards import ( + "encoding/json" + "fmt" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + persesv1 "github.com/perses/perses/pkg/model/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Canonical dashboard names. These are the identifiers an administrator uses in @@ -34,8 +39,47 @@ const ( ) // PersesDashboardBuilder builds a PersesDashboard object bound to the given -// datasource, in the given namespace. -type PersesDashboardBuilder func(datasourceName, namespace string) *persesv1alpha1.PersesDashboard +// datasource, in the given namespace. Returns an error if the dashboard +// definition cannot be constructed (e.g., unmarshal failure). +type PersesDashboardBuilder func(datasourceName, namespace string) (*persesv1alpha1.PersesDashboard, error) + +// dashboardFromJSON builds a perses.dev/v1alpha1 PersesDashboard named name in +// the given namespace from a perses.dev/v1alpha2 PersesDashboard JSON +// definition. +// +// Dashboards are kept as JSON because that is what the Grafana-to-Perses +// conversion tooling emits and what the Perses UI exports, so edits stay +// ergonomic. The definitions use the v1alpha2 layout, where the dashboard +// content lives under spec.config, while the objects are created as v1alpha1 +// (inline spec), which is the API served by the supported perses-operator. +// Only spec.config is read; metadata in the JSON is ignored. +func dashboardFromJSON(name, raw, namespace string) (*persesv1alpha1.PersesDashboard, error) { + var doc struct { + Spec struct { + Config *persesv1.DashboardSpec `json:"config"` + } `json:"spec"` + } + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return nil, fmt.Errorf("failed to unmarshal %s dashboard: %w", name, err) + } + if doc.Spec.Config == nil { + return nil, fmt.Errorf("%s dashboard has no spec.config section", name) + } + return &persesv1alpha1.PersesDashboard{ + TypeMeta: metav1.TypeMeta{ + APIVersion: persesv1alpha1.GroupVersion.String(), + Kind: "PersesDashboard", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: persesv1alpha1.Dashboard{ + // v1alpha2 spec.config -> v1alpha1 inline spec + DashboardSpec: *doc.Spec.Config, + }, + }, nil +} // PersesDashboards returns the registry of PersesDashboard builders keyed by // canonical dashboard name. diff --git a/internal/dashboards/perses_openstack-cloud.go b/internal/dashboards/perses_openstack-cloud.go index 1f5c96136..697c27092 100644 --- a/internal/dashboards/perses_openstack-cloud.go +++ b/internal/dashboards/perses_openstack-cloud.go @@ -17,39 +17,35 @@ limitations under the License. package dashboards import ( - "time" - persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" - persesv1 "github.com/perses/perses/pkg/model/api/v1" - persescommon "github.com/perses/perses/pkg/model/api/v1/common" - persesdashboard "github.com/perses/perses/pkg/model/api/v1/dashboard" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// OpenstackCloudPerses builds the "OpenStack / Cloud" PersesDashboard. +// openstackCloudJSON is the "OpenStack / Cloud" PersesDashboard definition, in +// the perses.dev/v1alpha2 layout (content under spec.config). // // This is a minimal placeholder that establishes the object shape, ownership and // datasource wiring. The full panel/layout content is converted from the legacy // Grafana dashboard and delivered separately. -func OpenstackCloudPerses(_, namespace string) *persesv1alpha1.PersesDashboard { - return &persesv1alpha1.PersesDashboard{ - TypeMeta: metav1.TypeMeta{ - APIVersion: persesv1alpha1.GroupVersion.String(), - Kind: "PersesDashboard", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: OpenstackCloudName, - Namespace: namespace, - }, - Spec: persesv1alpha1.Dashboard{ - DashboardSpec: persesv1.DashboardSpec{ - Display: &persescommon.Display{ - Name: "OpenStack / Cloud", - }, - Duration: persescommon.Duration(30 * time.Minute), - Panels: map[string]*persesv1.Panel{}, - Layouts: []persesdashboard.Layout{}, - }, - }, - } +const openstackCloudJSON = `{ + "apiVersion": "perses.dev/v1alpha2", + "kind": "PersesDashboard", + "metadata": { + "name": "openstack-cloud" + }, + "spec": { + "config": { + "display": { + "name": "OpenStack / Cloud" + }, + "duration": "30m", + "panels": {}, + "layouts": [] + } + } +}` + +// OpenstackCloudPerses builds the "OpenStack / Cloud" PersesDashboard from its +// JSON definition, in the given namespace. +func OpenstackCloudPerses(_, namespace string) (*persesv1alpha1.PersesDashboard, error) { + return dashboardFromJSON(OpenstackCloudName, openstackCloudJSON, namespace) } diff --git a/internal/dashboards/perses_test.go b/internal/dashboards/perses_test.go new file mode 100644 index 000000000..24092f93e --- /dev/null +++ b/internal/dashboards/perses_test.go @@ -0,0 +1,72 @@ +/* +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 dashboards + +import ( + "testing" + + . "github.com/onsi/gomega" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" +) + +// TestPersesDashboardsBuild verifies that every registered dashboard builder +// parses its JSON definition and produces a well-formed perses.dev/v1alpha1 +// PersesDashboard, with the v1alpha2 spec.config content, in the requested +// namespace. +func TestPersesDashboardsBuild(t *testing.T) { + g := NewWithT(t) + + const namespace = "openstack" + for name, builder := range PersesDashboards() { + dashboard, err := builder("prometheus", namespace) + g.Expect(err).NotTo(HaveOccurred(), name) + + g.Expect(dashboard.Name).To(Equal(name)) + g.Expect(dashboard.Namespace).To(Equal(namespace)) + g.Expect(dashboard.Kind).To(Equal("PersesDashboard")) + g.Expect(dashboard.APIVersion).To(Equal(persesv1alpha1.GroupVersion.String())) + g.Expect(dashboard.Spec.Display).NotTo(BeNil(), name) + g.Expect(dashboard.Spec.Display.Name).NotTo(BeEmpty(), name) + } +} + +// TestOpenstackCloudPerses checks the openstack-cloud dashboard content is read +// from the v1alpha2 spec.config section into the v1alpha1 inline spec. +func TestOpenstackCloudPerses(t *testing.T) { + g := NewWithT(t) + + dashboard, err := OpenstackCloudPerses("prometheus", "openstack") + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(dashboard.Name).To(Equal(OpenstackCloudName)) + g.Expect(dashboard.Spec.Display).NotTo(BeNil()) + g.Expect(dashboard.Spec.Display.Name).To(Equal("OpenStack / Cloud")) + g.Expect(dashboard.Spec.Duration.String()).To(Equal("30m")) +} + +// TestDashboardFromJSONErrors checks malformed JSON and definitions without a +// spec.config section (e.g., the v1alpha1 inline layout, whose content would +// otherwise be silently dropped) are rejected. +func TestDashboardFromJSONErrors(t *testing.T) { + g := NewWithT(t) + + _, err := dashboardFromJSON("bad", `{"spec": `, "openstack") + g.Expect(err).To(HaveOccurred()) + + _, err = dashboardFromJSON("inline", `{"spec": {"display": {"name": "Inline"}}}`, "openstack") + g.Expect(err).To(MatchError(ContainSubstring("no spec.config"))) +} From 43c237f038b41e4f8602ab294b06648a7d1acbf4 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 15:20:40 +0100 Subject: [PATCH 06/16] Gate the ceilometer-ipmi dashboard on power mon Restore the behaviour of the legacy ConfigMap delivery, where the ceilometer-ipmi dashboard is only created when at least one dataplane nodeset runs the telemetry-power-monitoring service. - Add a placeholder openstack-ceilometer-ipmi PersesDashboard, stored as v1alpha2 JSON like openstack-cloud. Its content is delivered with the rest of the dashboard conversion. - Treat the dashboard as disabled when power monitoring is not deployed, so it is skipped, and removed if power monitoring is later dropped. - Extend the metricstorage kuttl test: the ipmi dashboard is absent before the power monitoring EDPM resources are deployed (01) and when dashboards are disabled (02), present once they are (07), and kept when another dashboard is disabled by name (08). Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../controller/metricstorage_controller.go | 11 ++++ internal/dashboards/perses.go | 6 ++- .../perses_openstack-ceilometer-ipmi.go | 54 +++++++++++++++++++ test/kuttl/tests/metricstorage/01-errors.yaml | 6 +++ test/kuttl/tests/metricstorage/02-errors.yaml | 5 ++ test/kuttl/tests/metricstorage/07-assert.yaml | 13 ++++- test/kuttl/tests/metricstorage/08-assert.yaml | 6 +++ 7 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 internal/dashboards/perses_openstack-ceilometer-ipmi.go create mode 100644 test/kuttl/tests/metricstorage/01-errors.yaml diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index 3ecbb2bae..32bba888f 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -1513,6 +1513,17 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in for _, name := range instance.Spec.DisabledDashboards { disabledDashboards[name] = true } + // At least one nodeset must have the telemetry-power-monitoring service + // enabled for the ceilometer-ipmi dashboard to be created. Otherwise it is + // handled like a disabled dashboard, so it is also removed when power + // monitoring is no longer deployed. + connectionInfo, err := getComputeNodesConnectionInfo(instance, helper, telemetryv1.TelemetryPowerMonitoring) + if err != nil { + Log.Info(fmt.Sprintf("Cannot get compute node connection info. Power monitoring dashboard not created. Error: %s", err)) + } + if err != nil || len(connectionInfo) == 0 { + disabledDashboards[dashboards.OpenstackCeilometerIpmiName] = true + } builders := dashboards.PersesDashboards() // Iterate the registry in a stable order so the reported unavailable diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go index 24047606d..a847356d8 100644 --- a/internal/dashboards/perses.go +++ b/internal/dashboards/perses.go @@ -35,7 +35,8 @@ import ( // A name is defined here alongside its builder as each dashboard is converted to // Perses. const ( - OpenstackCloudName = "openstack-cloud" + OpenstackCeilometerIpmiName = "openstack-ceilometer-ipmi" + OpenstackCloudName = "openstack-cloud" ) // PersesDashboardBuilder builds a PersesDashboard object bound to the given @@ -85,6 +86,7 @@ func dashboardFromJSON(name, raw, namespace string) (*persesv1alpha1.PersesDashb // canonical dashboard name. func PersesDashboards() map[string]PersesDashboardBuilder { return map[string]PersesDashboardBuilder{ - OpenstackCloudName: OpenstackCloudPerses, + OpenstackCeilometerIpmiName: OpenstackCeilometerIpmiPerses, + OpenstackCloudName: OpenstackCloudPerses, } } diff --git a/internal/dashboards/perses_openstack-ceilometer-ipmi.go b/internal/dashboards/perses_openstack-ceilometer-ipmi.go new file mode 100644 index 000000000..5176cfcd4 --- /dev/null +++ b/internal/dashboards/perses_openstack-ceilometer-ipmi.go @@ -0,0 +1,54 @@ +/* +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 dashboards + +import ( + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" +) + +// openstackCeilometerIpmiJSON is the "OpenStack / Compute Power Monitoring / +// Ceilometer IPMI" PersesDashboard definition, in the perses.dev/v1alpha2 +// layout (content under spec.config). It is only deployed when at least one +// dataplane nodeset runs the telemetry-power-monitoring service. +// +// This is a minimal placeholder that establishes the object shape, ownership and +// datasource wiring. The full panel/layout content is converted from the legacy +// Grafana dashboard and delivered separately. +const openstackCeilometerIpmiJSON = `{ + "apiVersion": "perses.dev/v1alpha2", + "kind": "PersesDashboard", + "metadata": { + "name": "openstack-ceilometer-ipmi" + }, + "spec": { + "config": { + "display": { + "name": "OpenStack / Compute Power Monitoring / Ceilometer IPMI" + }, + "duration": "30m", + "panels": {}, + "layouts": [] + } + } +}` + +// OpenstackCeilometerIpmiPerses builds the "OpenStack / Compute Power +// Monitoring / Ceilometer IPMI" PersesDashboard from its JSON definition, in the +// given namespace. +func OpenstackCeilometerIpmiPerses(_, namespace string) (*persesv1alpha1.PersesDashboard, error) { + return dashboardFromJSON(OpenstackCeilometerIpmiName, openstackCeilometerIpmiJSON, namespace) +} diff --git a/test/kuttl/tests/metricstorage/01-errors.yaml b/test/kuttl/tests/metricstorage/01-errors.yaml new file mode 100644 index 000000000..4289b95f3 --- /dev/null +++ b/test/kuttl/tests/metricstorage/01-errors.yaml @@ -0,0 +1,6 @@ +# No dataplane nodeset runs the telemetry-power-monitoring service yet, so the +# ceilometer-ipmi dashboard must not be created. +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi diff --git a/test/kuttl/tests/metricstorage/02-errors.yaml b/test/kuttl/tests/metricstorage/02-errors.yaml index b3c51caf9..9514e50ab 100644 --- a/test/kuttl/tests/metricstorage/02-errors.yaml +++ b/test/kuttl/tests/metricstorage/02-errors.yaml @@ -17,6 +17,11 @@ kind: PersesDashboard metadata: name: openstack-cloud --- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi +--- apiVersion: v1 kind: ConfigMap metadata: diff --git a/test/kuttl/tests/metricstorage/07-assert.yaml b/test/kuttl/tests/metricstorage/07-assert.yaml index 0c9e220d9..62ca8d2fc 100644 --- a/test/kuttl/tests/metricstorage/07-assert.yaml +++ b/test/kuttl/tests/metricstorage/07-assert.yaml @@ -1,6 +1,7 @@ # Dashboards are re-enabled at step 06 with the power-monitoring EDPM resources -# deployed. Assert exactly the managed Perses objects that should exist: the -# datasource and the implemented openstack-cloud dashboard. +# deployed. Assert the managed Perses objects: the datasource, the +# openstack-cloud dashboard and the ceilometer-ipmi dashboard, which is only +# created when a nodeset runs the telemetry-power-monitoring service. apiVersion: perses.dev/v1alpha1 kind: PersesDatasource metadata: @@ -15,3 +16,11 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDashboard metadata: name: openstack-cloud +--- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi +spec: + display: + name: OpenStack / Compute Power Monitoring / Ceilometer IPMI diff --git a/test/kuttl/tests/metricstorage/08-assert.yaml b/test/kuttl/tests/metricstorage/08-assert.yaml index 0a443ecf0..45aa54a0c 100644 --- a/test/kuttl/tests/metricstorage/08-assert.yaml +++ b/test/kuttl/tests/metricstorage/08-assert.yaml @@ -1,5 +1,6 @@ # Dashboards remain enabled, so the datasource is still present, but the # openstack-cloud dashboard is disabled by name (asserted absent in 08-errors). +# Dashboards not listed in disabledDashboards, like ceilometer-ipmi, are kept. apiVersion: perses.dev/v1alpha1 kind: PersesDatasource metadata: @@ -9,3 +10,8 @@ spec: default: true plugin: kind: PrometheusDatasource +--- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi From c746b2545175c7e1b49d5570eb9015acb078f03e Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 15:49:16 +0100 Subject: [PATCH 07/16] Fix golangci-lint issues in dashboards package - Return a wrapped static ErrDashboardNoSpecConfig error instead of a dynamic one when a dashboard JSON definition has no spec.config section (err113). - Use a named gomega import in the dashboards tests (revive dot-imports). Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- internal/dashboards/perses.go | 7 +++++- internal/dashboards/perses_test.go | 36 +++++++++++++++--------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go index a847356d8..93e0ce0bf 100644 --- a/internal/dashboards/perses.go +++ b/internal/dashboards/perses.go @@ -22,6 +22,7 @@ package dashboards import ( "encoding/json" + "errors" "fmt" persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" @@ -39,6 +40,10 @@ const ( OpenstackCloudName = "openstack-cloud" ) +// ErrDashboardNoSpecConfig is returned when a dashboard JSON definition has no +// spec.config section, i.e. it is not in the perses.dev/v1alpha2 layout. +var ErrDashboardNoSpecConfig = errors.New("dashboard has no spec.config section") + // PersesDashboardBuilder builds a PersesDashboard object bound to the given // datasource, in the given namespace. Returns an error if the dashboard // definition cannot be constructed (e.g., unmarshal failure). @@ -64,7 +69,7 @@ func dashboardFromJSON(name, raw, namespace string) (*persesv1alpha1.PersesDashb return nil, fmt.Errorf("failed to unmarshal %s dashboard: %w", name, err) } if doc.Spec.Config == nil { - return nil, fmt.Errorf("%s dashboard has no spec.config section", name) + return nil, fmt.Errorf("%s: %w", name, ErrDashboardNoSpecConfig) } return &persesv1alpha1.PersesDashboard{ TypeMeta: metav1.TypeMeta{ diff --git a/internal/dashboards/perses_test.go b/internal/dashboards/perses_test.go index 24092f93e..dc188daa5 100644 --- a/internal/dashboards/perses_test.go +++ b/internal/dashboards/perses_test.go @@ -19,7 +19,7 @@ package dashboards import ( "testing" - . "github.com/onsi/gomega" + "github.com/onsi/gomega" persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" ) @@ -28,45 +28,45 @@ import ( // PersesDashboard, with the v1alpha2 spec.config content, in the requested // namespace. func TestPersesDashboardsBuild(t *testing.T) { - g := NewWithT(t) + g := gomega.NewWithT(t) const namespace = "openstack" for name, builder := range PersesDashboards() { dashboard, err := builder("prometheus", namespace) - g.Expect(err).NotTo(HaveOccurred(), name) + g.Expect(err).NotTo(gomega.HaveOccurred(), name) - g.Expect(dashboard.Name).To(Equal(name)) - g.Expect(dashboard.Namespace).To(Equal(namespace)) - g.Expect(dashboard.Kind).To(Equal("PersesDashboard")) - g.Expect(dashboard.APIVersion).To(Equal(persesv1alpha1.GroupVersion.String())) - g.Expect(dashboard.Spec.Display).NotTo(BeNil(), name) - g.Expect(dashboard.Spec.Display.Name).NotTo(BeEmpty(), name) + g.Expect(dashboard.Name).To(gomega.Equal(name)) + g.Expect(dashboard.Namespace).To(gomega.Equal(namespace)) + g.Expect(dashboard.Kind).To(gomega.Equal("PersesDashboard")) + g.Expect(dashboard.APIVersion).To(gomega.Equal(persesv1alpha1.GroupVersion.String())) + g.Expect(dashboard.Spec.Display).NotTo(gomega.BeNil(), name) + g.Expect(dashboard.Spec.Display.Name).NotTo(gomega.BeEmpty(), name) } } // TestOpenstackCloudPerses checks the openstack-cloud dashboard content is read // from the v1alpha2 spec.config section into the v1alpha1 inline spec. func TestOpenstackCloudPerses(t *testing.T) { - g := NewWithT(t) + g := gomega.NewWithT(t) dashboard, err := OpenstackCloudPerses("prometheus", "openstack") - g.Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(gomega.HaveOccurred()) - g.Expect(dashboard.Name).To(Equal(OpenstackCloudName)) - g.Expect(dashboard.Spec.Display).NotTo(BeNil()) - g.Expect(dashboard.Spec.Display.Name).To(Equal("OpenStack / Cloud")) - g.Expect(dashboard.Spec.Duration.String()).To(Equal("30m")) + g.Expect(dashboard.Name).To(gomega.Equal(OpenstackCloudName)) + g.Expect(dashboard.Spec.Display).NotTo(gomega.BeNil()) + g.Expect(dashboard.Spec.Display.Name).To(gomega.Equal("OpenStack / Cloud")) + g.Expect(dashboard.Spec.Duration.String()).To(gomega.Equal("30m")) } // TestDashboardFromJSONErrors checks malformed JSON and definitions without a // spec.config section (e.g., the v1alpha1 inline layout, whose content would // otherwise be silently dropped) are rejected. func TestDashboardFromJSONErrors(t *testing.T) { - g := NewWithT(t) + g := gomega.NewWithT(t) _, err := dashboardFromJSON("bad", `{"spec": `, "openstack") - g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(gomega.HaveOccurred()) _, err = dashboardFromJSON("inline", `{"spec": {"display": {"name": "Inline"}}}`, "openstack") - g.Expect(err).To(MatchError(ContainSubstring("no spec.config"))) + g.Expect(err).To(gomega.MatchError(ErrDashboardNoSpecConfig)) } From 62ecc4d57a659728ee5939d0e696d847f27d9f05 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:07:58 +0100 Subject: [PATCH 08/16] Use the configured CA bundle for Perses datasource The PersesDatasource validated the Prometheus serving certificate against a hard-coded combined-ca-bundle secret, while the MonitoringStack uses spec.prometheusTls.caBundleSecretName. With a differently named CA bundle secret, the perses-operator cannot find the CA and dashboard queries to Prometheus fail. Use the same CA bundle secret and key as the MonitoringStack, falling back to the ca.crt in the Prometheus serving certificate secret when no CA bundle secret is configured. Add unit tests for the datasource. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../metricstorage/dashboard_datasource.go | 22 +++-- .../dashboard_datasource_test.go | 90 +++++++++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 internal/metricstorage/dashboard_datasource_test.go diff --git a/internal/metricstorage/dashboard_datasource.go b/internal/metricstorage/dashboard_datasource.go index b9ac183c0..708d95fa9 100644 --- a/internal/metricstorage/dashboard_datasource.go +++ b/internal/metricstorage/dashboard_datasource.go @@ -19,6 +19,7 @@ package metricstorage import ( "fmt" + "github.com/openstack-k8s-operators/lib-common/modules/common/tls" telemetryv1 "github.com/openstack-k8s-operators/telemetry-operator/api/v1beta1" persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" persesv1 "github.com/perses/perses/pkg/model/api/v1" @@ -35,13 +36,9 @@ const ( // Perses UI for the managed Prometheus datasource. prometheusDatasourceDisplayName = "Prometheus Datasource" - // combinedCABundleSecretName is the secret holding the cluster CA bundle - // used to validate the Prometheus TLS certificate. - combinedCABundleSecretName = "combined-ca-bundle" //nolint:gosec // G101: secret name, not an actual credential - - // combinedCABundleKey is the key inside combinedCABundleSecretName that - // holds the CA bundle in PEM format. - combinedCABundleKey = "tls-ca-bundle.pem" + // serviceCertCAKey is the key holding the issuing CA in the Prometheus + // serving certificate secret, used when no CA bundle secret is configured. + serviceCertCAKey = "ca.crt" // persesDatasourceSecretName is the Perses Secret the perses-operator // generates from the datasource's client.tls config. The perses-operator @@ -70,16 +67,23 @@ func DashboardDatasource(instance *telemetryv1.MetricStorage) *persesv1alpha1.Pe if instance.Spec.PrometheusTLS.Enabled() { scheme = "https" + // Validate the Prometheus serving certificate against the same CA + // bundle configured for the MonitoringStack, falling back to the CA in + // the serving certificate secret when no bundle is set. + caSecretName, caKey := instance.Spec.PrometheusTLS.CaBundleSecretName, tls.CABundleKey + if caSecretName == "" { + caSecretName, caKey = *instance.Spec.PrometheusTLS.SecretName, serviceCertCAKey + } datasource.Spec.Client = &persesv1alpha1.Client{ TLS: &persesv1alpha1.TLS{ Enable: true, CaCert: &persesv1alpha1.Certificate{ SecretSource: persesv1alpha1.SecretSource{ Type: persesv1alpha1.SecretSourceTypeSecret, - Name: combinedCABundleSecretName, + Name: caSecretName, Namespace: instance.Namespace, }, - CertPath: combinedCABundleKey, + CertPath: caKey, }, }, } diff --git a/internal/metricstorage/dashboard_datasource_test.go b/internal/metricstorage/dashboard_datasource_test.go new file mode 100644 index 000000000..18303b176 --- /dev/null +++ b/internal/metricstorage/dashboard_datasource_test.go @@ -0,0 +1,90 @@ +/* +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 metricstorage + +import ( + "testing" + + "github.com/onsi/gomega" + "github.com/openstack-k8s-operators/lib-common/modules/common/tls" + telemetryv1 "github.com/openstack-k8s-operators/telemetry-operator/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newMetricStorage(prometheusTLS tls.SimpleService) *telemetryv1.MetricStorage { + return &telemetryv1.MetricStorage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "metric-storage", + Namespace: "openstack", + }, + Spec: telemetryv1.MetricStorageSpec{ + PrometheusTLS: prometheusTLS, + }, + } +} + +// TestDashboardDatasourceNoTLS checks the datasource uses plain HTTP and no +// client TLS config when Prometheus TLS is disabled. +func TestDashboardDatasourceNoTLS(t *testing.T) { + g := gomega.NewWithT(t) + + datasource := DashboardDatasource(newMetricStorage(tls.SimpleService{})) + + g.Expect(datasource.Spec.Client).To(gomega.BeNil()) + proxy := datasource.Spec.Config.Plugin.Spec.(map[string]interface{})["proxy"].(map[string]interface{}) + proxySpec := proxy["spec"].(map[string]interface{}) + g.Expect(proxySpec["url"]).To(gomega.Equal("http://metric-storage-prometheus.openstack.svc:9090")) + g.Expect(proxySpec).NotTo(gomega.HaveKey("secret")) +} + +// TestDashboardDatasourceTLSCABundle checks the datasource validates the +// Prometheus certificate against the configured CA bundle secret, the same +// one used by the MonitoringStack. +func TestDashboardDatasourceTLSCABundle(t *testing.T) { + g := gomega.NewWithT(t) + + datasource := DashboardDatasource(newMetricStorage(tls.SimpleService{ + GenericService: tls.GenericService{SecretName: ptr.To("cert-metric-storage-prometheus-svc")}, + Ca: tls.Ca{CaBundleSecretName: "my-ca-bundle"}, + })) + + g.Expect(datasource.Spec.Client).NotTo(gomega.BeNil()) + caCert := datasource.Spec.Client.TLS.CaCert + g.Expect(caCert.Name).To(gomega.Equal("my-ca-bundle")) + g.Expect(caCert.Namespace).To(gomega.Equal("openstack")) + g.Expect(caCert.CertPath).To(gomega.Equal(tls.CABundleKey)) + proxy := datasource.Spec.Config.Plugin.Spec.(map[string]interface{})["proxy"].(map[string]interface{}) + proxySpec := proxy["spec"].(map[string]interface{}) + g.Expect(proxySpec["url"]).To(gomega.Equal("https://metric-storage-prometheus.openstack.svc:9090")) + g.Expect(proxySpec["secret"]).To(gomega.Equal(persesDatasourceSecretName)) +} + +// TestDashboardDatasourceTLSServiceCertCA checks the datasource falls back to +// the CA in the Prometheus serving certificate secret when no CA bundle secret +// is configured. +func TestDashboardDatasourceTLSServiceCertCA(t *testing.T) { + g := gomega.NewWithT(t) + + datasource := DashboardDatasource(newMetricStorage(tls.SimpleService{ + GenericService: tls.GenericService{SecretName: ptr.To("cert-metric-storage-prometheus-svc")}, + })) + + caCert := datasource.Spec.Client.TLS.CaCert + g.Expect(caCert.Name).To(gomega.Equal("cert-metric-storage-prometheus-svc")) + g.Expect(caCert.CertPath).To(gomega.Equal(serviceCertCAKey)) +} From 6908e5142e00ddfc6d790f67dac3b3ef00303996 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:13:52 +0100 Subject: [PATCH 09/16] Only manage Perses objects owned by MetricStorage The Perses objects use generic names (e.g. the "prometheus" datasource) in the MetricStorage namespace. The disable paths deleted them by name regardless of who created them, so an administrator's own PersesDatasource named "prometheus" was deleted on every reconcile while dashboards were disabled, and the create path silently took over and overwrote it. - Add EnsureControlledDeleted, which only deletes a Perses object controlled by the MetricStorage, and use it when dashboards are disabled, when a dashboard is disabled by name and on CR deletion. - Add EnsureAdoptable and call it from the PersesDatasource and PersesDashboard mutate functions, so an existing object not controlled by the MetricStorage is reported as an error instead of adopted. - Add unit tests for both helpers. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../controller/metricstorage_controller.go | 8 +- internal/metricstorage/dashboard_objects.go | 51 +++++++-- .../metricstorage/dashboard_objects_test.go | 108 ++++++++++++++++++ 3 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 internal/metricstorage/dashboard_objects_test.go diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index 32bba888f..0b56443a4 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -1470,6 +1470,9 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in }, } op, err = controllerutil.CreateOrPatch(ctx, r.Client, datasource, func() error { + if err := metricstorage.EnsureAdoptable(instance, datasource); err != nil { + return err + } desired := metricstorage.DashboardDatasource(instance) datasource.Labels = desired.Labels datasource.Spec = desired.Spec @@ -1557,7 +1560,7 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in Namespace: instance.Namespace, }, } - if res, err := utils.EnsureDeleted(ctx, helper, existing); err != nil { + if res, err := metricstorage.EnsureControlledDeleted(ctx, helper, instance, existing); err != nil { return res, err } continue @@ -1570,6 +1573,9 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in }, } op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboard, func() error { + if err := metricstorage.EnsureAdoptable(instance, dashboard); err != nil { + return err + } desired, err := builder(metricstorage.PrometheusDatasourceName, instance.Namespace) if err != nil { return err diff --git a/internal/metricstorage/dashboard_objects.go b/internal/metricstorage/dashboard_objects.go index 574f8ab9f..b33a5dc51 100644 --- a/internal/metricstorage/dashboard_objects.go +++ b/internal/metricstorage/dashboard_objects.go @@ -18,9 +18,12 @@ package metricstorage import ( "context" + "errors" + "fmt" persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -33,6 +36,11 @@ import ( monv1 "github.com/rhobs/obo-prometheus-operator/pkg/apis/monitoring/v1" ) +// ErrNotControlledByMetricStorage is returned when a Perses object the operator +// would manage already exists but is not controlled by the MetricStorage +// instance, e.g. because it was created by an administrator. +var ErrNotControlledByMetricStorage = errors.New("object exists and is not controlled by the MetricStorage") + // DashboardArtifactsNamespace is the namespace where the legacy ConfigMap-based // dashboard artifacts were stored by earlier versions of the operator. const DashboardArtifactsNamespace = "openshift-config-managed" @@ -54,8 +62,9 @@ var legacyDashboardConfigMapNames = []string{ // DeleteDashboardObjects removes the dashboard-related objects owned by the // MetricStorage instance. It is used both on the disable path -// (DashboardsEnabled=false) and on CR deletion. Perses object deletions -// tolerate the perses.dev CRDs not being installed. +// (DashboardsEnabled=false) and on CR deletion. Perses objects are only +// deleted when controlled by the instance, and their deletion tolerates the +// perses.dev CRDs not being installed. func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper) (ctrl.Result, error) { promRule := &monv1.PrometheusRule{ ObjectMeta: metav1.ObjectMeta{ @@ -73,7 +82,7 @@ func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricSto Namespace: instance.Namespace, }, } - if res, err := ensureDeletedTolerateNoCRD(ctx, helper, datasource); err != nil { + if res, err := EnsureControlledDeleted(ctx, helper, instance, datasource); err != nil { return res, err } @@ -84,7 +93,7 @@ func DeleteDashboardObjects(ctx context.Context, instance *telemetryv1.MetricSto Namespace: instance.Namespace, }, } - if res, err := ensureDeletedTolerateNoCRD(ctx, helper, dashboard); err != nil { + if res, err := EnsureControlledDeleted(ctx, helper, instance, dashboard); err != nil { return res, err } } @@ -114,13 +123,35 @@ func CleanupLegacyDashboardConfigMaps(ctx context.Context, instance *telemetryv1 return ctrl.Result{}, nil } -// ensureDeletedTolerateNoCRD deletes an object but treats a missing CRD (no REST -// mapping) as success, so the operator does not error on clusters where the +// EnsureControlledDeleted deletes a Perses object only if it is controlled by +// the MetricStorage instance, so objects with the same name created by others +// are left untouched. A missing object or a missing CRD (no REST mapping) is +// treated as success, so the operator does not error on clusters where the // perses.dev CRDs are not installed. -func ensureDeletedTolerateNoCRD(ctx context.Context, helper *helper.Helper, obj client.Object) (ctrl.Result, error) { - res, err := utils.EnsureDeleted(ctx, helper, obj) - if err != nil && meta.IsNoMatchError(err) { +func EnsureControlledDeleted(ctx context.Context, helper *helper.Helper, instance *telemetryv1.MetricStorage, obj client.Object) (ctrl.Result, error) { + if err := helper.GetClient().Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + if k8s_errors.IsNotFound(err) || meta.IsNoMatchError(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if !metav1.IsControlledBy(obj, instance) || !obj.GetDeletionTimestamp().IsZero() { return ctrl.Result{}, nil } - return res, err + if err := helper.GetClient().Delete(ctx, obj); err != nil && !k8s_errors.IsNotFound(err) { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// EnsureAdoptable returns ErrNotControlledByMetricStorage if obj already +// exists in the cluster and is not controlled by the MetricStorage instance. +// It is meant to be called from a CreateOrPatch mutate function, before +// setting the controller reference, so the operator does not take over and +// overwrite objects it did not create. +func EnsureAdoptable(instance *telemetryv1.MetricStorage, obj client.Object) error { + if creationTimestamp := obj.GetCreationTimestamp(); creationTimestamp.IsZero() || metav1.IsControlledBy(obj, instance) { + return nil + } + return fmt.Errorf("%w: %s/%s", ErrNotControlledByMetricStorage, obj.GetNamespace(), obj.GetName()) } diff --git a/internal/metricstorage/dashboard_objects_test.go b/internal/metricstorage/dashboard_objects_test.go new file mode 100644 index 000000000..4605d6a85 --- /dev/null +++ b/internal/metricstorage/dashboard_objects_test.go @@ -0,0 +1,108 @@ +/* +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 metricstorage + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/onsi/gomega" + "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + telemetryv1 "github.com/openstack-k8s-operators/telemetry-operator/api/v1beta1" + persesv1alpha1 "github.com/perses/perses-operator/api/v1alpha1" + 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/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func newOwnedTestEnv(t *testing.T, objs ...client.Object) (*telemetryv1.MetricStorage, client.Client, *helper.Helper) { + t.Helper() + g := gomega.NewWithT(t) + + scheme := runtime.NewScheme() + g.Expect(telemetryv1.AddToScheme(scheme)).To(gomega.Succeed()) + g.Expect(persesv1alpha1.AddToScheme(scheme)).To(gomega.Succeed()) + + instance := &telemetryv1.MetricStorage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "metric-storage", + Namespace: "openstack", + UID: types.UID("metric-storage-uid"), + }, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + h, err := helper.NewHelper(instance, c, nil, scheme, logr.Discard()) + g.Expect(err).NotTo(gomega.HaveOccurred()) + return instance, c, h +} + +func newDashboard(name string) *persesv1alpha1.PersesDashboard { + return &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "openstack", + }, + } +} + +// TestEnsureControlledDeleted checks only Perses objects controlled by the +// MetricStorage are deleted; same-named objects created by others are kept and +// a missing object is not an error. +func TestEnsureControlledDeleted(t *testing.T) { + g := gomega.NewWithT(t) + + owned := newDashboard("owned") + unowned := newDashboard("unowned") + instance, c, h := newOwnedTestEnv(t, unowned) + g.Expect(controllerutil.SetControllerReference(instance, owned, c.Scheme())).To(gomega.Succeed()) + g.Expect(c.Create(context.TODO(), owned)).To(gomega.Succeed()) + + _, err := EnsureControlledDeleted(context.TODO(), h, instance, newDashboard("owned")) + g.Expect(err).NotTo(gomega.HaveOccurred()) + err = c.Get(context.TODO(), client.ObjectKeyFromObject(owned), &persesv1alpha1.PersesDashboard{}) + g.Expect(k8s_errors.IsNotFound(err)).To(gomega.BeTrue()) + + _, err = EnsureControlledDeleted(context.TODO(), h, instance, newDashboard("unowned")) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(c.Get(context.TODO(), client.ObjectKeyFromObject(unowned), &persesv1alpha1.PersesDashboard{})).To(gomega.Succeed()) + + _, err = EnsureControlledDeleted(context.TODO(), h, instance, newDashboard("missing")) + g.Expect(err).NotTo(gomega.HaveOccurred()) +} + +// TestEnsureAdoptable checks new objects and objects controlled by the +// MetricStorage can be reconciled, while existing objects it does not control +// are rejected instead of being taken over. +func TestEnsureAdoptable(t *testing.T) { + g := gomega.NewWithT(t) + + instance, c, _ := newOwnedTestEnv(t) + + g.Expect(EnsureAdoptable(instance, newDashboard("new"))).To(gomega.Succeed()) + + existing := newDashboard("existing") + existing.CreationTimestamp = metav1.Now() + g.Expect(EnsureAdoptable(instance, existing)).To(gomega.MatchError(ErrNotControlledByMetricStorage)) + + g.Expect(controllerutil.SetControllerReference(instance, existing, c.Scheme())).To(gomega.Succeed()) + g.Expect(EnsureAdoptable(instance, existing)).To(gomega.Succeed()) +} From b884a1b9e966064ad5f8b9ae3fcd9d9140430e7f Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:13:52 +0100 Subject: [PATCH 10/16] Clean up legacy dashboard ConfigMaps on delete The legacy ConfigMap-based dashboards live in openshift-config-managed with no owner references, and were only removed from reconcileNormal, after several steps that can return early. If the MetricStorage was deleted after an upgrade before reconcileNormal got that far, the ConfigMaps were orphaned. Remove them from reconcileDelete as well. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- internal/controller/metricstorage_controller.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index 0b56443a4..68de68748 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -230,6 +230,12 @@ func (r *MetricStorageReconciler) reconcileDelete( if res, err := metricstorage.DeleteDashboardObjects(ctx, instance, helper); err != nil { return res, err } + // The legacy ConfigMap-based dashboards live in another namespace and have + // no owner references, so remove them here too in case the CR is deleted + // before reconcileNormal ever cleaned them up after an upgrade. + if res, err := metricstorage.CleanupLegacyDashboardConfigMaps(ctx, instance, helper); err != nil { + return res, err + } crbName := fmt.Sprintf("%s-%s", metricstorage.OpenStackLightspeedAccessCRName, instance.Namespace) crb := &rbacv1.ClusterRoleBinding{} From bd1c7e0065ce8a14f61944b19fd683daec222569 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:13:52 +0100 Subject: [PATCH 11/16] Test legacy dashboard ConfigMap cleanup in kuttl The kuttl assertions that the legacy ConfigMap-based dashboards are absent passed vacuously, since nothing created them and the operator no longer does. Pre-create all the legacy ConfigMaps in step 00, before the MetricStorage is deployed, to simulate an upgrade, and assert in 01-errors that the operator removes them. Drop the redundant ConfigMap assertions from 02-errors and 07-errors. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../metricstorage/00-legacy-dashboards.yaml | 83 +++++++++++++++++++ test/kuttl/tests/metricstorage/01-errors.yaml | 62 ++++++++++++++ test/kuttl/tests/metricstorage/02-errors.yaml | 42 ---------- test/kuttl/tests/metricstorage/07-errors.yaml | 8 -- 4 files changed, 145 insertions(+), 50 deletions(-) create mode 100644 test/kuttl/tests/metricstorage/00-legacy-dashboards.yaml delete mode 100644 test/kuttl/tests/metricstorage/07-errors.yaml diff --git a/test/kuttl/tests/metricstorage/00-legacy-dashboards.yaml b/test/kuttl/tests/metricstorage/00-legacy-dashboards.yaml new file mode 100644 index 000000000..efb330ca9 --- /dev/null +++ b/test/kuttl/tests/metricstorage/00-legacy-dashboards.yaml @@ -0,0 +1,83 @@ +# Pre-create the ConfigMap-based dashboard artifacts that earlier versions of +# the operator delivered in openshift-config-managed, before the MetricStorage +# is deployed in step 01, to simulate an upgrade. 01-errors asserts the +# operator removes them. +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard-datasource: "true" + name: telemetry-kuttl-tests-telemetry-kuttl-datasource + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-cloud + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-node + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-openstack-network + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-vm + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-rabbitmq + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-network-traffic + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-ceilometer-ipmi + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-lightspeed + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + console.openshift.io/dashboard: "true" + name: grafana-dashboard-openstack-kepler + namespace: openshift-config-managed diff --git a/test/kuttl/tests/metricstorage/01-errors.yaml b/test/kuttl/tests/metricstorage/01-errors.yaml index 4289b95f3..72d25b394 100644 --- a/test/kuttl/tests/metricstorage/01-errors.yaml +++ b/test/kuttl/tests/metricstorage/01-errors.yaml @@ -4,3 +4,65 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDashboard metadata: name: openstack-ceilometer-ipmi +--- +# The legacy ConfigMap-based dashboard artifacts pre-created in step 00 are +# removed by the operator once the MetricStorage is reconciled. +apiVersion: v1 +kind: ConfigMap +metadata: + name: telemetry-kuttl-tests-telemetry-kuttl-datasource + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-cloud + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-node + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-openstack-network + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-vm + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-rabbitmq + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-network-traffic + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-ceilometer-ipmi + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-lightspeed + namespace: openshift-config-managed +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-openstack-kepler + namespace: openshift-config-managed diff --git a/test/kuttl/tests/metricstorage/02-errors.yaml b/test/kuttl/tests/metricstorage/02-errors.yaml index 9514e50ab..b9d8b5f97 100644 --- a/test/kuttl/tests/metricstorage/02-errors.yaml +++ b/test/kuttl/tests/metricstorage/02-errors.yaml @@ -21,45 +21,3 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDashboard metadata: name: openstack-ceilometer-ipmi ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: telemetry-kuttl-tests-telemetry-kuttl-datasource - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-cloud - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-node - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-openstack-network - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-vm - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-rabbitmq - namespace: openshift-config-managed ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-network-traffic - namespace: openshift-config-managed diff --git a/test/kuttl/tests/metricstorage/07-errors.yaml b/test/kuttl/tests/metricstorage/07-errors.yaml deleted file mode 100644 index cc5471664..000000000 --- a/test/kuttl/tests/metricstorage/07-errors.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Step 06 enables power monitoring, which under the legacy ConfigMap delivery -# created the ceilometer-ipmi dashboard. Assert that legacy ConfigMap stays -# removed after the upgrade cleanup. -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-openstack-ceilometer-ipmi - namespace: openshift-config-managed From d47fd4d4c59b91ee7d96f9faf4e9a8ed2511c904 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:20:39 +0100 Subject: [PATCH 12/16] Don't remove ipmi dashboard on node lookup errors Any error while inspecting the dataplane nodesets for the telemetry-power-monitoring service, e.g. a transient list failure or one host without a resolvable address, marked the ceilometer-ipmi dashboard as disabled, deleting it until the next successful reconcile. The legacy delivery only skipped creating it. Skip reconciling the dashboard when the nodesets cannot be inspected, leaving any existing object untouched, and only remove it when no nodeset runs power monitoring. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- internal/controller/metricstorage_controller.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index 68de68748..b0efa17ae 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -1525,12 +1525,15 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in // At least one nodeset must have the telemetry-power-monitoring service // enabled for the ceilometer-ipmi dashboard to be created. Otherwise it is // handled like a disabled dashboard, so it is also removed when power - // monitoring is no longer deployed. + // monitoring is no longer deployed. If the nodesets cannot be inspected, + // the dashboard is left as is rather than removed, so a transient error + // does not make it flap. + skippedDashboards := map[string]bool{} connectionInfo, err := getComputeNodesConnectionInfo(instance, helper, telemetryv1.TelemetryPowerMonitoring) if err != nil { - Log.Info(fmt.Sprintf("Cannot get compute node connection info. Power monitoring dashboard not created. Error: %s", err)) - } - if err != nil || len(connectionInfo) == 0 { + Log.Info(fmt.Sprintf("Cannot get compute node connection info. Power monitoring dashboard not reconciled. Error: %s", err)) + skippedDashboards[dashboards.OpenstackCeilometerIpmiName] = true + } else if len(connectionInfo) == 0 { disabledDashboards[dashboards.OpenstackCeilometerIpmiName] = true } @@ -1556,7 +1559,8 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in // Iterate over the implemented dashboards registry, the single source of // truth for the dashboards the operator manages. A dashboard is created // unless the administrator disabled it by name, in which case any previously - // created object is removed. + // created object is removed, or it is skipped, in which case any existing + // object is left untouched. for _, dashboardName := range dashboardNames { builder := builders[dashboardName] if disabledDashboards[dashboardName] { @@ -1571,6 +1575,9 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in } continue } + if skippedDashboards[dashboardName] { + continue + } dashboard := &persesv1alpha1.PersesDashboard{ ObjectMeta: metav1.ObjectMeta{ From 2fa4a81869340cf8b51f2b54bbc9120ddfee45cb Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:32:27 +0100 Subject: [PATCH 13/16] Don't block reconcile on Perses name conflicts When a PersesDatasource or PersesDashboard with the same name already exists and is not controlled by the MetricStorage, the error was returned from createDashboardObjects, so reconcileNormal stopped before the TLS input validation, network attachments and Prometheus readiness, and kept retrying with backoff. Report the conflict on the dashboard conditions and keep reconciling instead. On a datasource conflict the dashboards, which would reference the foreign datasource, are skipped; on a dashboard conflict only that dashboard is skipped. Also fix markDashboardObjectReady logging the Perses attachment state on every reconcile: it compared against the current conditions, which Init resets at the start of each reconcile. Compare against the conditions saved before Init instead. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- .../controller/metricstorage_controller.go | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/internal/controller/metricstorage_controller.go b/internal/controller/metricstorage_controller.go index b0efa17ae..8a8e2e784 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net" "reflect" @@ -216,7 +217,7 @@ func (r *MetricStorageReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // Handle non-deleted clusters - return r.reconcileNormal(ctx, instance, helper) + return r.reconcileNormal(ctx, instance, helper, savedConditions) } func (r *MetricStorageReconciler) reconcileDelete( @@ -330,6 +331,7 @@ func (r *MetricStorageReconciler) reconcileNormal( ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper, + savedConditions condition.Conditions, ) (ctrl.Result, error) { Log := r.GetLogger(ctx) Log.Info(fmt.Sprintf("Reconciling Service '%s'", instance.Name)) @@ -528,7 +530,7 @@ func (r *MetricStorageReconciler) reconcileNormal( instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDatasourceReadyCondition, telemetryv1.DashboardsNotEnabledMessage) instance.Status.Conditions.MarkTrue(telemetryv1.DashboardDefinitionReadyCondition, telemetryv1.DashboardsNotEnabledMessage) } else { - if res, err := r.createDashboardObjects(ctx, instance, helper, eventHandler, serviceLabels); err != nil { + if res, err := r.createDashboardObjects(ctx, instance, helper, eventHandler, serviceLabels, savedConditions); err != nil { return res, err } } @@ -1404,6 +1406,7 @@ func (r *MetricStorageReconciler) createInstanceHAScrapeConfig( func (r *MetricStorageReconciler) markDashboardObjectReady( ctx context.Context, instance *telemetryv1.MetricStorage, + savedConditions condition.Conditions, condType condition.Type, available bool, reason, message, describe string, @@ -1411,14 +1414,16 @@ func (r *MetricStorageReconciler) markDashboardObjectReady( msg := condition.ReadyMessage if !available { msg = fmt.Sprintf("%s created; Perses backend not attached yet (%s): %s", describe, reason, message) - if existing := instance.Status.Conditions.Get(condType); existing == nil || existing.Message != msg { + // Compare against the conditions saved before they were reset by Init + // at the start of the reconcile, which hold the previous message. + if existing := savedConditions.Get(condType); existing == nil || existing.Message != msg { r.GetLogger(ctx).Info(fmt.Sprintf("%s is not attached to a Perses backend yet (%s): %s", describe, reason, message)) } } instance.Status.Conditions.MarkTrue(condType, "%s", msg) } -func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper, eventHandler handler.EventHandler, serviceLabels map[string]string) (ctrl.Result, error) { +func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, instance *telemetryv1.MetricStorage, helper *helper.Helper, eventHandler handler.EventHandler, serviceLabels map[string]string, savedConditions condition.Conditions) (ctrl.Result, error) { Log := r.GetLogger(ctx) // Deploy PrometheusRule for dashboards @@ -1484,6 +1489,21 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in datasource.Spec = desired.Spec return controllerutil.SetControllerReference(instance, datasource, r.Scheme) }) + if errors.Is(err, metricstorage.ErrNotControlledByMetricStorage) { + // A PersesDatasource with the same name was created by someone else. + // Leave it and the dashboards, which would reference it, alone and + // report it on the conditions instead of failing the whole reconcile. + Log.Info(fmt.Sprintf("Not managing PersesDatasource %s: %s", datasource.Name, err)) + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDatasourceReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + telemetryv1.DashboardDatasourceFailedMessage, err) + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + telemetryv1.DashboardDefinitionFailedMessage, err) + return ctrl.Result{}, nil + } if err != nil { Log.Error(err, fmt.Sprintf("Failed to update PersesDatasource %s", datasource.Name)) instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDatasourceReadyCondition, @@ -1500,7 +1520,7 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in // has attached it to a Perses instance is surfaced via the condition message // but does not gate readiness (the Perses backend is managed elsewhere). dsAvailable, dsReason, dsMessage := telemetryv1.PersesResourceAvailable(datasource.Status.Conditions) - r.markDashboardObjectReady(ctx, instance, telemetryv1.DashboardDatasourceReadyCondition, + r.markDashboardObjectReady(ctx, instance, savedConditions, telemetryv1.DashboardDatasourceReadyCondition, dsAvailable, dsReason, dsMessage, fmt.Sprintf("PersesDatasource %s", datasource.Name)) // Deploy PersesDashboards @@ -1555,6 +1575,9 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in // elsewhere. dashboardsAvailable := true var unavailableReason, unavailableMessage, unavailableName string + // First dashboard that could not be managed because a same-named object + // not controlled by the MetricStorage exists. + var notControlledErr error // Iterate over the implemented dashboards registry, the single source of // truth for the dashboards the operator manages. A dashboard is created @@ -1597,6 +1620,16 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in dashboard.Spec = desired.Spec return controllerutil.SetControllerReference(instance, dashboard, r.Scheme) }) + if errors.Is(err, metricstorage.ErrNotControlledByMetricStorage) { + // A PersesDashboard with the same name was created by someone + // else. Leave it alone, keep reconciling the other dashboards and + // report it on the condition. + Log.Info(fmt.Sprintf("Not managing PersesDashboard %s: %s", dashboard.Name, err)) + if notControlledErr == nil { + notControlledErr = err + } + continue + } if err != nil { Log.Error(err, fmt.Sprintf("Failed to update PersesDashboard %s", dashboard.Name)) instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, @@ -1613,11 +1646,18 @@ func (r *MetricStorageReconciler) createDashboardObjects(ctx context.Context, in unavailableReason, unavailableMessage, unavailableName = reason, message, dashboard.Name } } + if notControlledErr != nil { + instance.Status.Conditions.MarkFalse(telemetryv1.DashboardDefinitionReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + telemetryv1.DashboardDefinitionFailedMessage, notControlledErr) + return ctrl.Result{}, nil + } describe := "PersesDashboards" if unavailableName != "" { describe = fmt.Sprintf("PersesDashboard %s", unavailableName) } - r.markDashboardObjectReady(ctx, instance, telemetryv1.DashboardDefinitionReadyCondition, + r.markDashboardObjectReady(ctx, instance, savedConditions, telemetryv1.DashboardDefinitionReadyCondition, dashboardsAvailable, unavailableReason, unavailableMessage, describe) return ctrl.Result{}, nil From 6dc87d1fdca1c1025e05f59d1efd549a78ab2803 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:49:45 +0100 Subject: [PATCH 14/16] Target the instance Prometheus Service in datasource The PersesDatasource URL hardcoded the metric-storage-prometheus Service, while the Prometheus Service is named after the MetricStorage instance (-prometheus). With a MetricStorage not named metric-storage, e.g. in the kuttl tests, the dashboards queried a Service that does not exist. Build the URL from the instance name, which also matches the subject name requested for the Prometheus serving certificate. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- internal/metricstorage/dashboard_datasource.go | 2 +- internal/metricstorage/dashboard_datasource_test.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/metricstorage/dashboard_datasource.go b/internal/metricstorage/dashboard_datasource.go index 708d95fa9..61cbcb050 100644 --- a/internal/metricstorage/dashboard_datasource.go +++ b/internal/metricstorage/dashboard_datasource.go @@ -89,7 +89,7 @@ func DashboardDatasource(instance *telemetryv1.MetricStorage) *persesv1alpha1.Pe } } - url := fmt.Sprintf("%s://metric-storage-prometheus.%s.svc:9090", scheme, instance.Namespace) + url := fmt.Sprintf("%s://%s-prometheus.%s.svc:9090", scheme, instance.Name, instance.Namespace) proxySpec := map[string]interface{}{ "url": url, diff --git a/internal/metricstorage/dashboard_datasource_test.go b/internal/metricstorage/dashboard_datasource_test.go index 18303b176..687517bd8 100644 --- a/internal/metricstorage/dashboard_datasource_test.go +++ b/internal/metricstorage/dashboard_datasource_test.go @@ -39,16 +39,19 @@ func newMetricStorage(prometheusTLS tls.SimpleService) *telemetryv1.MetricStorag } // TestDashboardDatasourceNoTLS checks the datasource uses plain HTTP and no -// client TLS config when Prometheus TLS is disabled. +// client TLS config when Prometheus TLS is disabled, and targets the +// Prometheus Service of the MetricStorage instance. func TestDashboardDatasourceNoTLS(t *testing.T) { g := gomega.NewWithT(t) - datasource := DashboardDatasource(newMetricStorage(tls.SimpleService{})) + instance := newMetricStorage(tls.SimpleService{}) + instance.Name = "telemetry-kuttl" + datasource := DashboardDatasource(instance) g.Expect(datasource.Spec.Client).To(gomega.BeNil()) proxy := datasource.Spec.Config.Plugin.Spec.(map[string]interface{})["proxy"].(map[string]interface{}) proxySpec := proxy["spec"].(map[string]interface{}) - g.Expect(proxySpec["url"]).To(gomega.Equal("http://metric-storage-prometheus.openstack.svc:9090")) + g.Expect(proxySpec["url"]).To(gomega.Equal("http://telemetry-kuttl-prometheus.openstack.svc:9090")) g.Expect(proxySpec).NotTo(gomega.HaveKey("secret")) } From 2afea401794018a782d72c19c310d5ff494cd891 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 16:57:40 +0100 Subject: [PATCH 15/16] Explain how to resolve Perses name conflicts When an existing Perses object with a managed name is not controlled by the MetricStorage, the reported error now tells the administrator to delete or rename it so the operator can manage it. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- internal/metricstorage/dashboard_objects.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/metricstorage/dashboard_objects.go b/internal/metricstorage/dashboard_objects.go index b33a5dc51..b933dabb4 100644 --- a/internal/metricstorage/dashboard_objects.go +++ b/internal/metricstorage/dashboard_objects.go @@ -153,5 +153,6 @@ func EnsureAdoptable(instance *telemetryv1.MetricStorage, obj client.Object) err if creationTimestamp := obj.GetCreationTimestamp(); creationTimestamp.IsZero() || metav1.IsControlledBy(obj, instance) { return nil } - return fmt.Errorf("%w: %s/%s", ErrNotControlledByMetricStorage, obj.GetNamespace(), obj.GetName()) + return fmt.Errorf("%w: %s/%s; delete or rename it so the operator can manage it", + ErrNotControlledByMetricStorage, obj.GetNamespace(), obj.GetName()) } From f5684b25f36ca02ff4a3f7ab6e8501c18c81a465 Mon Sep 17 00:00:00 2001 From: Victoria Martinez de la Cruz Date: Mon, 26 Oct 2026 18:11:06 +0100 Subject: [PATCH 16/16] Assert Perses objects are owned in kuttl The metricstorage kuttl test only asserted the Perses objects exist, so it would still pass if the operator stopped setting the controller reference that their garbage collection and the ownership-aware deletion rely on. Assert the PersesDatasource and the openstack-cloud and ceilometer-ipmi PersesDashboards are controlled by the MetricStorage. Related-To: OSPRH-37817 Assisted-By: Claude Opus 5.5 Signed-off-by: Victoria Martinez de la Cruz --- test/kuttl/tests/metricstorage/01-assert.yaml | 8 ++++++++ test/kuttl/tests/metricstorage/07-assert.yaml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/test/kuttl/tests/metricstorage/01-assert.yaml b/test/kuttl/tests/metricstorage/01-assert.yaml index 6abc11b94..fe8ac1f72 100644 --- a/test/kuttl/tests/metricstorage/01-assert.yaml +++ b/test/kuttl/tests/metricstorage/01-assert.yaml @@ -188,6 +188,10 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDatasource metadata: name: prometheus + ownerReferences: + - kind: MetricStorage + name: telemetry-kuttl + controller: true spec: config: default: true @@ -200,6 +204,10 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDashboard metadata: name: openstack-cloud + ownerReferences: + - kind: MetricStorage + name: telemetry-kuttl + controller: true spec: display: name: OpenStack / Cloud diff --git a/test/kuttl/tests/metricstorage/07-assert.yaml b/test/kuttl/tests/metricstorage/07-assert.yaml index 62ca8d2fc..ce9e5aaa8 100644 --- a/test/kuttl/tests/metricstorage/07-assert.yaml +++ b/test/kuttl/tests/metricstorage/07-assert.yaml @@ -21,6 +21,10 @@ apiVersion: perses.dev/v1alpha1 kind: PersesDashboard metadata: name: openstack-ceilometer-ipmi + ownerReferences: + - kind: MetricStorage + name: telemetry-kuttl + controller: true spec: display: name: OpenStack / Compute Power Monitoring / Ceilometer IPMI