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..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 @@ -68,8 +89,6 @@ const ( DashboardPrometheusRuleReadyCondition condition.Type = "DashboardPrometheusRuleReady" - DashboardPluginReadyCondition condition.Type = "DashboardPluginReady" - DashboardDatasourceReadyCondition condition.Type = "DashboardDatasourceReady" DashboardDefinitionReadyCondition condition.Type = "DashboardDefinitionReady" @@ -314,9 +333,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/conditions_test.go b/api/v1beta1/conditions_test.go new file mode 100644 index 000000000..8d90ae93f --- /dev/null +++ b/api/v1beta1/conditions_test.go @@ -0,0 +1,76 @@ +package v1beta1 + +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 := NewWithT(t) + avail, reason, message := PersesResourceAvailable(tt.conditions) + g.Expect(avail).To(Equal(tt.wantAvail)) + if !tt.wantAvail { + 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 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..8a8e2e784 100644 --- a/internal/controller/metricstorage_controller.go +++ b/internal/controller/metricstorage_controller.go @@ -19,10 +19,12 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net" "reflect" "regexp" + "sort" "strconv" "strings" "time" @@ -63,10 +65,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 +118,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 +196,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), @@ -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( @@ -230,6 +231,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{} @@ -324,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)) @@ -507,6 +515,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,9 +529,8 @@ 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 { + if res, err := r.createDashboardObjects(ctx, instance, helper, eventHandler, serviceLabels, savedConditions); err != nil { return res, err } } @@ -1381,32 +1395,39 @@ 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) { +// 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, + savedConditions condition.Conditions, + 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) + // 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, savedConditions condition.Conditions) (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) - } - if op != controllerutil.OperationResultNone { - Log.Info(fmt.Sprintf("Dashboard Plugin definition %s successfully changed - operation: %s", uiPluginObj.GetName(), string(op))) - } // Deploy PrometheusRule for dashboards - err = utils.EnsureWatches( + err := utils.EnsureWatches( ctx, (*utils.ConditionalWatchingReconciler)(r), "prometheusrules.monitoring.rhobs", &monv1.PrometheusRule{}, eventHandler, helper, @@ -1425,12 +1446,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 +1460,207 @@ 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", + op, err = controllerutil.CreateOrPatch(ctx, r.Client, datasource, func() error { + if err := metricstorage.EnsureAdoptable(instance, datasource); err != nil { + return err } - datasourceCM.Data, err = metricstorage.DashboardDatasourceData(ctx, r.Client, instance, datasourceName, metricstorage.DashboardArtifactsNamespace) - return err + desired := metricstorage.DashboardDatasource(instance) + datasource.Labels = desired.Labels + 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, "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 := telemetryv1.PersesResourceAvailable(datasource.Status.Conditions) + r.markDashboardObjectReady(ctx, instance, savedConditions, 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 + // 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 + } + + disabledDashboards := make(map[string]bool, len(instance.Spec.DisabledDashboards)) + 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. 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 reconciled. Error: %s", err)) + skippedDashboards[dashboards.OpenstackCeilometerIpmiName] = true + } else if len(connectionInfo) == 0 { + disabledDashboards[dashboards.OpenstackCeilometerIpmiName] = true + } + + 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 + // 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 + // 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 + // unless the administrator disabled it by name, in which case any previously + // 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] { + existing := &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: dashboardName, + Namespace: instance.Namespace, + }, } - } - // 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 + if res, err := metricstorage.EnsureControlledDeleted(ctx, helper, instance, existing); err != nil { + return res, err } + continue } - 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), + if skippedDashboards[dashboardName] { + continue } - // 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) + dashboard := &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: dashboardName, + Namespace: instance.Namespace, + }, } - - for dashboardName, desiredCM := range dashboardCMs { - dashboardCM := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: dashboardName, - Namespace: metricstorage.DashboardArtifactsNamespace, - }, + op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboard, func() error { + if err := metricstorage.EnsureAdoptable(instance, dashboard); err != nil { + return err } - op, err = controllerutil.CreateOrPatch(ctx, r.Client, dashboardCM, func() error { - dashboardCM.Labels = desiredCM.Labels - dashboardCM.Data = desiredCM.Data - return nil - }) + desired, err := builder(metricstorage.PrometheusDatasourceName, instance.Namespace) 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) + return err } - if op != controllerutil.OperationResultNone { - Log.Info(fmt.Sprintf("Dashboard ConfigMap %s successfully changed - operation: %s", dashboardCM.Name, string(op))) + dashboard.Labels = desired.Labels + 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, + 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 := telemetryv1.PersesResourceAvailable(dashboard.Status.Conditions); !available && dashboardsAvailable { + dashboardsAvailable = false + unavailableReason, unavailableMessage, unavailableName = reason, message, dashboard.Name } } - return ctrl.Result{}, err + 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, savedConditions, telemetryv1.DashboardDefinitionReadyCondition, + dashboardsAvailable, unavailableReason, unavailableMessage, describe) + + return ctrl.Result{}, nil } func getComputeNodesConnectionInfo( diff --git a/internal/dashboards/perses.go b/internal/dashboards/perses.go new file mode 100644 index 000000000..93e0ce0bf --- /dev/null +++ b/internal/dashboards/perses.go @@ -0,0 +1,97 @@ +/* +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 ( + "encoding/json" + "errors" + "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 +// 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 ( + OpenstackCeilometerIpmiName = "openstack-ceilometer-ipmi" + 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). +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: %w", name, ErrDashboardNoSpecConfig) + } + 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. +func PersesDashboards() map[string]PersesDashboardBuilder { + return map[string]PersesDashboardBuilder{ + 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/internal/dashboards/perses_openstack-cloud.go b/internal/dashboards/perses_openstack-cloud.go new file mode 100644 index 000000000..697c27092 --- /dev/null +++ b/internal/dashboards/perses_openstack-cloud.go @@ -0,0 +1,51 @@ +/* +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" +) + +// 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. +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..dc188daa5 --- /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 := gomega.NewWithT(t) + + const namespace = "openstack" + for name, builder := range PersesDashboards() { + dashboard, err := builder("prometheus", namespace) + g.Expect(err).NotTo(gomega.HaveOccurred(), 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 := gomega.NewWithT(t) + + dashboard, err := OpenstackCloudPerses("prometheus", "openstack") + g.Expect(err).NotTo(gomega.HaveOccurred()) + + 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 := gomega.NewWithT(t) + + _, err := dashboardFromJSON("bad", `{"spec": `, "openstack") + g.Expect(err).To(gomega.HaveOccurred()) + + _, err = dashboardFromJSON("inline", `{"spec": {"display": {"name": "Inline"}}}`, "openstack") + g.Expect(err).To(gomega.MatchError(ErrDashboardNoSpecConfig)) +} diff --git a/internal/metricstorage/dashboard_datasource.go b/internal/metricstorage/dashboard_datasource.go index c977bf57c..61cbcb050 100644 --- a/internal/metricstorage/dashboard_datasource.go +++ b/internal/metricstorage/dashboard_datasource.go @@ -17,47 +17,108 @@ limitations under the License. package metricstorage import ( - "context" + "fmt" - "github.com/openstack-k8s-operators/lib-common/modules/common/clusterdns" + "github.com/openstack-k8s-operators/lib-common/modules/common/tls" 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" + + // 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 + // 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 +// 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, + // 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 } - 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: caSecretName, + Namespace: instance.Namespace, + }, + CertPath: caKey, + }, + }, } - 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://%s-prometheus.%s.svc:9090", scheme, instance.Name, 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, + Display: &persescommon.Display{ + Name: prometheusDatasourceDisplayName, + }, + Plugin: persescommon.Plugin{ + Kind: "PrometheusDatasource", + Spec: map[string]interface{}{ + "proxy": map[string]interface{}{ + "kind": "HTTPProxy", + "spec": proxySpec, + }, + }, + }, + }, + } + + return datasource } diff --git a/internal/metricstorage/dashboard_datasource_test.go b/internal/metricstorage/dashboard_datasource_test.go new file mode 100644 index 000000000..687517bd8 --- /dev/null +++ b/internal/metricstorage/dashboard_datasource_test.go @@ -0,0 +1,93 @@ +/* +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, and targets the +// Prometheus Service of the MetricStorage instance. +func TestDashboardDatasourceNoTLS(t *testing.T) { + g := gomega.NewWithT(t) + + 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://telemetry-kuttl-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)) +} diff --git a/internal/metricstorage/dashboard_objects.go b/internal/metricstorage/dashboard_objects.go index fca24ea55..b933dabb4 100644 --- a/internal/metricstorage/dashboard_objects.go +++ b/internal/metricstorage/dashboard_objects.go @@ -18,21 +18,53 @@ 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" + "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 +// 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" -// 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. +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 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{ @@ -44,38 +76,83 @@ 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 := EnsureControlledDeleted(ctx, helper, instance, 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.PersesDashboards() { + dashboard := &persesv1alpha1.PersesDashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: instance.Namespace, + }, + } + if res, err := EnsureControlledDeleted(ctx, helper, instance, 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 } + +// 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 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 + } + 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; delete or rename it so the operator can manage it", + 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()) +} 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/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-assert.yaml b/test/kuttl/tests/metricstorage/01-assert.yaml index 3b69524ad..fe8ac1f72 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,33 @@ 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 +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource 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 + name: prometheus + ownerReferences: + - kind: MetricStorage + name: telemetry-kuttl + controller: true +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 + ownerReferences: + - kind: MetricStorage + name: telemetry-kuttl + controller: true +spec: + display: + name: OpenStack / Cloud --- apiVersion: v1 kind: Secret diff --git a/test/kuttl/tests/metricstorage/01-errors.yaml b/test/kuttl/tests/metricstorage/01-errors.yaml new file mode 100644 index 000000000..72d25b394 --- /dev/null +++ b/test/kuttl/tests/metricstorage/01-errors.yaml @@ -0,0 +1,68 @@ +# 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 +--- +# 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 556666dd0..b9d8b5f97 100644 --- a/test/kuttl/tests/metricstorage/02-errors.yaml +++ b/test/kuttl/tests/metricstorage/02-errors.yaml @@ -7,44 +7,17 @@ spec: - name: osp-node-exporter-dashboard.rules - name: osp-ceilometer-dashboard.rules --- -apiVersion: v1 -kind: ConfigMap +apiVersion: perses.dev/v1alpha1 +kind: PersesDatasource metadata: - name: telemetry-kuttl-tests-telemetry-kuttl-datasource - namespace: openshift-config-managed + name: prometheus --- -apiVersion: v1 -kind: ConfigMap +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard metadata: - name: grafana-dashboard-openstack-cloud - namespace: openshift-config-managed + name: openstack-cloud --- -apiVersion: v1 -kind: ConfigMap +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard 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 + name: openstack-ceilometer-ipmi diff --git a/test/kuttl/tests/metricstorage/07-assert.yaml b/test/kuttl/tests/metricstorage/07-assert.yaml index 166e20ed6..ce9e5aaa8 100644 --- a/test/kuttl/tests/metricstorage/07-assert.yaml +++ b/test/kuttl/tests/metricstorage/07-assert.yaml @@ -1,7 +1,30 @@ -apiVersion: v1 -kind: ConfigMap +# Dashboards are re-enabled at step 06 with the power-monitoring EDPM resources +# 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: - 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 +--- +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 diff --git a/test/kuttl/tests/metricstorage/08-assert.yaml b/test/kuttl/tests/metricstorage/08-assert.yaml new file mode 100644 index 000000000..45aa54a0c --- /dev/null +++ b/test/kuttl/tests/metricstorage/08-assert.yaml @@ -0,0 +1,17 @@ +# 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: + name: prometheus +spec: + config: + default: true + plugin: + kind: PrometheusDatasource +--- +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: openstack-ceilometer-ipmi 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