diff --git a/metrics/cluster_info_collector.go b/metrics/cluster_info_collector.go new file mode 100644 index 00000000..289b2647 --- /dev/null +++ b/metrics/cluster_info_collector.go @@ -0,0 +1,136 @@ +package metrics + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" +) + +//+kubebuilder:rbac:groups=syn.tools,resources=clusters,verbs=get;list;watch +//+kubebuilder:rbac:groups=syn.tools,resources=clusters/status,verbs=get + +var clusterInfoDesc = prometheus.NewDesc( + "syn_lieutenant_cluster_info", + "Cluster information metric.", + []string{"cluster", "tenant", "display_name"}, + nil, +) + +var clusterFactsDesc = prometheus.NewDesc( + "syn_lieutenant_cluster_facts", + "Lieutenant cluster facts.", + []string{"cluster", "tenant", "display_name"}, + nil, +) + +// commodore build info has dynamic labels +func newClusterFactsDesc(lbls ...string) *prometheus.Desc { + return prometheus.NewDesc( + "syn_lieutenant_cluster_facts", + "Lieutenant cluster facts. Keys are normalized to be valid Prometheus labels.", + lbls, + nil, + ) +} + +// ClusterInfoCollector is a Prometheus collector that collects cluster info metrics. +type ClusterInfoCollector struct { + Client client.Client + + Namespace string +} + +var _ prometheus.Collector = &ClusterInfoCollector{} + +// Describe implements prometheus.Collector. +// Sends the descriptors of the metrics to the channel. +func (*ClusterInfoCollector) Describe(chan<- *prometheus.Desc) {} + +// Collect implements prometheus.Collector. +// Iterates over all clusters and sends cluster information for each cluster. +func (m *ClusterInfoCollector) Collect(ch chan<- prometheus.Metric) { + ctx := context.Background() + + cls := synv1alpha1.ClusterList{} + if err := m.Client.List(ctx, &cls, client.InNamespace(m.Namespace)); err != nil { + err := fmt.Errorf("failed to list clusters: %w", err) + ch <- prometheus.NewInvalidMetric(clusterInfoDesc, err) + } + + for _, cl := range cls.Items { + ch <- prometheus.MustNewConstMetric( + clusterInfoDesc, + prometheus.GaugeValue, + 1, + cl.Name, cl.Spec.TenantRef.Name, cl.Spec.DisplayName, + ) + + if err := clusterFacts(cl, ch); err != nil { + log.Log.Info("failed to collect cluster facts", "error", err) + } + } +} + +// clusterFacts collects the facts of a cluster and sends them as Prometheus metrics. +// The keys of the facts are normalized to be valid Prometheus labels. +// If the first character of a key is an underscore or an invalid character it is replaced with "fact_". +// If a key is empty it is replaced with "_empty". +// If a key is in the protected list after normalizing it is prefixed with "orig_". +// If a key is a duplicate after normalizing it is suffixed with "_" where n is the number of duplicates. +func clusterFacts(cl synv1alpha1.Cluster, ch chan<- prometheus.Metric) error { + rks, vs := pairs(cl.Spec.Facts) + ks := make([]string, len(rks)) + for i, k := range rks { + ks[i] = normalizeLabelKey(k, []string{"cluster", "tenant"}, "fact_") + } + seen := make(map[string]int) + for i, k := range ks { + if _, ok := seen[k]; ok { + ks[i] = fmt.Sprintf("%s_%d", k, seen[k]) + } + seen[k]++ + } + + m, err := prometheus.NewConstMetric( + newClusterFactsDesc(append([]string{"cluster", "tenant"}, ks...)...), + prometheus.GaugeValue, + 1, + append([]string{cl.Name, cl.Spec.TenantRef.Name}, vs...)..., + ) + if err != nil { + return fmt.Errorf("failed to create metric for cluster %q: %w", cl.Name, err) + } + ch <- m + return nil +} + +// https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels +var validKeyCharacters = regexp.MustCompile(`(?:^[^a-zA-Z_]|[^a-zA-Z0-9_])`) + +// normalizeLabelKey normalizes a key to be a valid Prometheus metric name. +// It replaces invalid characters with underscores and prefixes the key with the given prefix if it starts with an underscore character. +// If the key is empty it returns "_empty". +// If the key is in the protected list after normalizing it prefixes the key with "orig_". +func normalizeLabelKey(key string, protected []string, prefixForUnderscore string) string { + if key == "" { + return "_empty" + } + + key = validKeyCharacters.ReplaceAllLiteralString(key, "_") + if strings.HasPrefix(key, "_") { + key = prefixForUnderscore + key + } + if slices.Contains(protected, key) { + key = "orig_" + key + } + + return key +} diff --git a/metrics/cluster_info_collector_test.go b/metrics/cluster_info_collector_test.go new file mode 100644 index 00000000..ea85e296 --- /dev/null +++ b/metrics/cluster_info_collector_test.go @@ -0,0 +1,89 @@ +package metrics_test + +import ( + "errors" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" + "github.com/projectsyn/lieutenant-operator/metrics" +) + +func Test_ClusterInfoCollector(t *testing.T) { + namespace := "testns" + expectedMetricNames := []string{ + "syn_lieutenant_cluster_info", + "syn_lieutenant_cluster_facts", + } + + c := prepareClient(t, + &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "c-empty", + }, + }, + &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "c2", + }, + Spec: synv1alpha1.ClusterSpec{ + DisplayName: "Cluster 2", + TenantRef: corev1.LocalObjectReference{ + Name: "t2", + }, + Facts: map[string]string{ + "key": "value", + "_key": "value", + "0key-duplicate-after-normalize": "value", + "1key-duplicate-after-normalize": "value", + "2key-duplicate-after-normalize": "value", + "key-with847_💩_â-invalid-chars": "value", + "cluster": "value", + "tenant": "value", + }, + }, + }, + ) + + subject := &metrics.ClusterInfoCollector{ + Client: c, + + Namespace: namespace, + } + + metrics := `# HELP syn_lieutenant_cluster_facts Lieutenant cluster facts. Keys are normalized to be valid Prometheus labels. +# TYPE syn_lieutenant_cluster_facts gauge +syn_lieutenant_cluster_facts{cluster="c-empty",tenant=""} 1 +syn_lieutenant_cluster_facts{cluster="c2",fact__key="value",fact__key_duplicate_after_normalize="value",fact__key_duplicate_after_normalize_1="value",fact__key_duplicate_after_normalize_2="value",key="value",key_with847_____invalid_chars="value",orig_cluster="value",orig_tenant="value",tenant="t2"} 1 +# HELP syn_lieutenant_cluster_info Cluster information metric. +# TYPE syn_lieutenant_cluster_info gauge +syn_lieutenant_cluster_info{cluster="c-empty",display_name="",tenant=""} 1 +syn_lieutenant_cluster_info{cluster="c2",display_name="Cluster 2",tenant="t2"} 1 +` + require.NoError(t, + testutil.CollectAndCompare(subject, strings.NewReader(metrics), expectedMetricNames...), + ) +} + +func Test_ClusterInfoCollector_ListFail(t *testing.T) { + namespace := "testns" + + listErr := errors.New("whoopsie daisy") + + c := prepareFailingClient(t, listErr) + + subject := &metrics.ClusterInfoCollector{ + Client: c, + + Namespace: namespace, + } + + require.ErrorContains(t, testutil.CollectAndCompare(subject, strings.NewReader(``)), listErr.Error()) +} diff --git a/metrics/tenant_info_collector.go b/metrics/tenant_info_collector.go new file mode 100644 index 00000000..7c9699cb --- /dev/null +++ b/metrics/tenant_info_collector.go @@ -0,0 +1,57 @@ +package metrics + +import ( + "context" + "fmt" + + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/client" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" +) + +//+kubebuilder:rbac:groups=syn.tools,resources=tenants,verbs=get;list;watch +//+kubebuilder:rbac:groups=syn.tools,resources=tenants/status,verbs=get + +var tenantInfoDesc = prometheus.NewDesc( + "syn_lieutenant_tenant_info", + "Tenant information metric.", + []string{"tenant", "display_name"}, + nil, +) + +// TenantInfoCollector is a Prometheus collector that collects tenant info metrics. +type TenantInfoCollector struct { + Client client.Client + + Namespace string +} + +var _ prometheus.Collector = &TenantInfoCollector{} + +// Describe implements prometheus.Collector. +// Sends the descriptors of the metrics to the channel. +func (*TenantInfoCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- tenantInfoDesc +} + +// Collect implements prometheus.Collector. +// Iterates over all tenants and sends tenant information for each tenant. +func (m *TenantInfoCollector) Collect(ch chan<- prometheus.Metric) { + ctx := context.Background() + + cls := synv1alpha1.TenantList{} + if err := m.Client.List(ctx, &cls, client.InNamespace(m.Namespace)); err != nil { + err := fmt.Errorf("failed to list tenants: %w", err) + ch <- prometheus.NewInvalidMetric(tenantInfoDesc, err) + } + + for _, cl := range cls.Items { + ch <- prometheus.MustNewConstMetric( + tenantInfoDesc, + prometheus.GaugeValue, + 1, + cl.Name, cl.Spec.DisplayName, + ) + } +} diff --git a/metrics/tenant_info_collector_test.go b/metrics/tenant_info_collector_test.go new file mode 100644 index 00000000..933c3717 --- /dev/null +++ b/metrics/tenant_info_collector_test.go @@ -0,0 +1,70 @@ +package metrics_test + +import ( + "errors" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" + "github.com/projectsyn/lieutenant-operator/metrics" +) + +func Test_TenantInfoCollector(t *testing.T) { + namespace := "testns" + expectedMetricNames := []string{ + "syn_lieutenant_tenant_info", + } + + c := prepareClient(t, + &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "t-empty", + }, + }, + &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: "t2", + }, + Spec: synv1alpha1.TenantSpec{ + DisplayName: "Tenant 2", + }, + }, + ) + + subject := &metrics.TenantInfoCollector{ + Client: c, + + Namespace: namespace, + } + + metrics := `# HELP syn_lieutenant_tenant_info Tenant information metric. +# TYPE syn_lieutenant_tenant_info gauge +syn_lieutenant_tenant_info{display_name="",tenant="t-empty"} 1 +syn_lieutenant_tenant_info{display_name="Tenant 2",tenant="t2"} 1 +` + require.NoError(t, + testutil.CollectAndCompare(subject, strings.NewReader(metrics), expectedMetricNames...), + ) +} + +func Test_TenantInfoCollector_ListFail(t *testing.T) { + namespace := "testns" + + listErr := errors.New("whoopsie daisy") + + c := prepareFailingClient(t, listErr) + + subject := &metrics.TenantInfoCollector{ + Client: c, + + Namespace: namespace, + } + + require.ErrorContains(t, testutil.CollectAndCompare(subject, strings.NewReader(``)), listErr.Error()) +}