-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #281 from projectsyn/add-cluster-tenant-info
Add Cluster and Tenant info metrics
- Loading branch information
Showing
4 changed files
with
352 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} |