Skip to content

Commit

Permalink
Merge pull request #281 from projectsyn/add-cluster-tenant-info
Browse files Browse the repository at this point in the history
Add Cluster and Tenant info metrics
  • Loading branch information
bastjan authored Jun 6, 2024
2 parents 886e111 + e5b6758 commit a41f284
Show file tree
Hide file tree
Showing 4 changed files with 352 additions and 0 deletions.
136 changes: 136 additions & 0 deletions metrics/cluster_info_collector.go
Original file line number Diff line number Diff line change
@@ -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 "_<n>" 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
}
89 changes: 89 additions & 0 deletions metrics/cluster_info_collector_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
57 changes: 57 additions & 0 deletions metrics/tenant_info_collector.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
70 changes: 70 additions & 0 deletions metrics/tenant_info_collector_test.go
Original file line number Diff line number Diff line change
@@ -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())
}

0 comments on commit a41f284

Please sign in to comment.