diff --git a/internal/controller/consoleplugin/consoleplugin_objects.go b/internal/controller/consoleplugin/consoleplugin_objects.go index 1440baed3..9bedeac9d 100644 --- a/internal/controller/consoleplugin/consoleplugin_objects.go +++ b/internal/controller/consoleplugin/consoleplugin_objects.go @@ -36,6 +36,9 @@ import ( const proxyAlias = "backend" const configMapName = "console-plugin-config" + +// Annotation on PrometheusRule metadata for recording-rule metadata (key = metric name, value = same as alert annotations). +const recordingAnnotationsAnnotation = "netobserv.io/network-health" const configFile = "config.yaml" const configVolume = "config-volume" const configPath = "/opt/app-root/" @@ -538,8 +541,9 @@ func getLokiStatus(lokiStack *lokiv1.LokiStack) string { } // returns a configmap with a digest of its configuration contents, which will be used to -// detect any configuration change -func (b *builder) configMap(ctx context.Context, lokiStack *lokiv1.LokiStack) (*corev1.ConfigMap, string, error) { +// detect any configuration change. externalRecordingAnnotations is optional (e.g. nil in tests); +// when non-empty, those annotations are merged into the frontend config (from PrometheusRules). +func (b *builder) configMap(ctx context.Context, externalRecordingAnnotations map[string]map[string]string, lokiStack *lokiv1.LokiStack) (*corev1.ConfigMap, string, error) { config := cfg.PluginConfig{ Server: cfg.ServerConfig{ Port: int(*b.advanced.Port), @@ -575,6 +579,12 @@ func (b *builder) configMap(ctx context.Context, lokiStack *lokiv1.LokiStack) (* if err != nil { return nil, "", err } + for k, v := range externalRecordingAnnotations { + if config.Frontend.RecordingAnnotations == nil { + config.Frontend.RecordingAnnotations = make(map[string]map[string]string) + } + config.Frontend.RecordingAnnotations[k] = v + } var configStr string bs, err := yaml.Marshal(config) diff --git a/internal/controller/consoleplugin/consoleplugin_reconciler.go b/internal/controller/consoleplugin/consoleplugin_reconciler.go index 5dc169bde..9e8c4b368 100644 --- a/internal/controller/consoleplugin/consoleplugin_reconciler.go +++ b/internal/controller/consoleplugin/consoleplugin_reconciler.go @@ -2,6 +2,7 @@ package consoleplugin import ( "context" + "encoding/json" "reflect" osv1 "github.com/openshift/api/console/v1" @@ -11,6 +12,7 @@ import ( ascv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" lokiv1 "github.com/grafana/loki/operator/apis/loki/v1" @@ -202,7 +204,8 @@ func (r *CPReconciler) reconcileConfigMap(ctx context.Context, builder *builder, } } - newCM, configDigest, err := builder.configMap(ctx, lokiStack) + externalRecordingAnnotations := getExternalRecordingAnnotations(ctx, r.Client) + newCM, configDigest, err := builder.configMap(ctx, externalRecordingAnnotations, lokiStack) if err != nil { return "", err } @@ -272,3 +275,43 @@ func pluginNeedsUpdate(plg *osv1.ConsolePlugin, desired *pluginSpec) bool { advancedConfig := helper.GetAdvancedPluginConfig(desired.Advanced) return plg.Spec.Backend.Service.Port != *advancedConfig.Port } + +// getExternalRecordingAnnotations reads PrometheusRules with label netobserv=true and netobserv.io/network-health annotation. +// Returns metric name -> annotations. Recording rules without the annotation are not included. +func getExternalRecordingAnnotations(ctx context.Context, cl client.Client) map[string]map[string]string { + out := make(map[string]map[string]string) + list := &monitoringv1.PrometheusRuleList{} + if err := cl.List(ctx, list, client.MatchingLabels{"netobserv": "true"}); err != nil { + log.FromContext(ctx).Error(err, "Failed to list PrometheusRules for recording annotations") + return out + } + for i := range list.Items { + pr := &list.Items[i] + // Process recording rules from spec + for _, group := range pr.Spec.Groups { + for _, rule := range group.Rules { + // Only process recording rules (not alerts) with netobserv label + if rule.Record != "" { + if labelVal, ok := rule.Labels["netobserv"]; ok && labelVal == "true" { + // Check if there's annotation metadata for this rule + raw, hasAnnot := pr.Annotations[recordingAnnotationsAnnotation] + if hasAnnot && raw != "" { + var perRule map[string]map[string]string + if err := json.Unmarshal([]byte(raw), &perRule); err != nil { + log.FromContext(ctx).Info("Invalid netobserv.io/network-health annotation on PrometheusRule", + "namespace", pr.Namespace, "name", pr.Name, "error", err) + // Continue processing other rules even if annotation is malformed + continue + } + if annots, found := perRule[rule.Record]; found && len(annots) > 0 { + out[rule.Record] = annots + } + } + // Rules without annotation are not included - annotation is required + } + } + } + } + } + return out +} diff --git a/internal/controller/consoleplugin/consoleplugin_test.go b/internal/controller/consoleplugin/consoleplugin_test.go index 2c655183c..95b3ef47c 100644 --- a/internal/controller/consoleplugin/consoleplugin_test.go +++ b/internal/controller/consoleplugin/consoleplugin_test.go @@ -111,7 +111,7 @@ func getAutoScalerSpecs() (ascv2.HorizontalPodAutoscaler, flowslatest.FlowCollec func getBuilder(spec *flowslatest.FlowCollectorSpec, lk *helper.LokiConfig) builder { info := reconcilers.Common{Namespace: testNamespace, Loki: lk, ClusterInfo: &cluster.Info{}} b := newBuilder(info.NewInstance(map[reconcilers.ImageRef]string{reconcilers.MainImage: testImage}, status.Instance{}), spec, constants.PluginName) - _, _, _ = b.configMap(context.Background(), nil) // build configmap to update builder's volumes + _, _, _ = b.configMap(context.Background(), nil, nil) // build configmap to update builder's volumes return b } @@ -224,8 +224,8 @@ func TestConfigMapUpdateCheck(t *testing.T) { } spec := flowslatest.FlowCollectorSpec{ConsolePlugin: plugin} builder := getBuilder(&spec, &loki) - old, _, _ := builder.configMap(context.Background(), nil) - nEw, _, _ := builder.configMap(context.Background(), nil) + old, _, _ := builder.configMap(context.Background(), nil, nil) + nEw, _, _ := builder.configMap(context.Background(), nil, nil) assert.Equal(old.Data, nEw.Data) // update loki @@ -240,7 +240,7 @@ func TestConfigMapUpdateCheck(t *testing.T) { }}, } builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) old = nEw @@ -248,7 +248,7 @@ func TestConfigMapUpdateCheck(t *testing.T) { loki.LokiManualParams.StatusURL = "http://loki.status:3100/" loki.LokiManualParams.StatusTLS.Enable = true builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) old = nEw @@ -259,7 +259,7 @@ func TestConfigMapUpdateCheck(t *testing.T) { CertFile: "status-ca.crt", } builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) old = nEw @@ -271,7 +271,7 @@ func TestConfigMapUpdateCheck(t *testing.T) { CertKey: "tls.key", } builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) } @@ -287,8 +287,8 @@ func TestConfigMapUpdateWithLokistackMode(t *testing.T) { loki := helper.NewLokiConfig(&lokiSpec, "any") spec := flowslatest.FlowCollectorSpec{ConsolePlugin: plugin, Loki: lokiSpec} builder := getBuilder(&spec, &loki) - old, _, _ := builder.configMap(context.Background(), nil) - nEw, _, _ := builder.configMap(context.Background(), nil) + old, _, _ := builder.configMap(context.Background(), nil, nil) + nEw, _, _ := builder.configMap(context.Background(), nil, nil) assert.Equal(old.Data, nEw.Data) // update lokistack name @@ -297,7 +297,7 @@ func TestConfigMapUpdateWithLokistackMode(t *testing.T) { spec = flowslatest.FlowCollectorSpec{ConsolePlugin: plugin, Loki: lokiSpec} builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) old = nEw @@ -307,7 +307,7 @@ func TestConfigMapUpdateWithLokistackMode(t *testing.T) { spec = flowslatest.FlowCollectorSpec{ConsolePlugin: plugin, Loki: lokiSpec} builder = getBuilder(&spec, &loki) - nEw, _, _ = builder.configMap(context.Background(), nil) + nEw, _, _ = builder.configMap(context.Background(), nil, nil) assert.NotEqual(old.Data, nEw.Data) } @@ -332,7 +332,7 @@ func TestConfigMapContent(t *testing.T) { Processor: flowslatest.FlowCollectorFLP{SubnetLabels: flowslatest.SubnetLabels{OpenShiftAutoDetect: ptr.To(false)}}, } builder := getBuilder(&spec, &loki) - cm, _, err := builder.configMap(context.Background(), nil) + cm, _, err := builder.configMap(context.Background(), nil, nil) assert.NotNil(cm) assert.Nil(err) @@ -354,6 +354,38 @@ func TestConfigMapContent(t *testing.T) { assert.Equal(config.Frontend.Sampling, 1) } +func TestConfigMapExternalRecordingAnnotations(t *testing.T) { + assert := assert.New(t) + lokiSpec := flowslatest.FlowCollectorLoki{ + Mode: flowslatest.LokiModeLokiStack, + LokiStack: flowslatest.LokiStackRef{Name: "lokistack", Namespace: "ls-namespace"}, + } + loki := helper.NewLokiConfig(&lokiSpec, "any") + spec := flowslatest.FlowCollectorSpec{ + ConsolePlugin: getPluginConfig(), + Loki: lokiSpec, + Processor: flowslatest.FlowCollectorFLP{SubnetLabels: flowslatest.SubnetLabels{OpenShiftAutoDetect: ptr.To(false)}}, + } + builder := getBuilder(&spec, &loki) + + external := map[string]map[string]string{ + "my_custom_metric": { + "summary": "Custom metric", + "netobserv_io_network_health": `{"recordingThresholds":{"info":"10"}}`, + }, + } + cm, _, err := builder.configMap(context.Background(), external, nil) + assert.NotNil(cm) + assert.NoError(err) + + var pluginConfig config.PluginConfig + err = yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &pluginConfig) + assert.NoError(err) + assert.Contains(pluginConfig.Frontend.RecordingAnnotations, "my_custom_metric") + assert.Equal("Custom metric", pluginConfig.Frontend.RecordingAnnotations["my_custom_metric"]["summary"]) + assert.Equal(`{"recordingThresholds":{"info":"10"}}`, pluginConfig.Frontend.RecordingAnnotations["my_custom_metric"]["netobserv_io_network_health"]) +} + func TestServiceUpdateCheck(t *testing.T) { assert := assert.New(t) old := getServiceSpecs() @@ -510,7 +542,7 @@ func TestLokiStackStatusEmbedding(t *testing.T) { }, }, } - cm, _, err := builder.configMap(context.Background(), lokiStackReady) + cm, _, err := builder.configMap(context.Background(), nil, lokiStackReady) assert.Nil(err) assert.NotNil(cm) @@ -536,7 +568,7 @@ func TestLokiStackStatusEmbedding(t *testing.T) { }, }, } - cm, _, err = builder.configMap(context.Background(), lokiStackPending) + cm, _, err = builder.configMap(context.Background(), nil, lokiStackPending) assert.Nil(err) assert.NotNil(cm) @@ -561,7 +593,7 @@ func TestLokiStackStatusEmbedding(t *testing.T) { }, }, } - cm, _, err = builder.configMap(context.Background(), lokiStackNotReady) + cm, _, err = builder.configMap(context.Background(), nil, lokiStackNotReady) assert.Nil(err) assert.NotNil(cm) @@ -571,7 +603,7 @@ func TestLokiStackStatusEmbedding(t *testing.T) { assert.Empty(cfg.Loki.StatusURL) // Test 4: No LokiStack provided (nil) - cm, _, err = builder.configMap(context.Background(), nil) + cm, _, err = builder.configMap(context.Background(), nil, nil) assert.Nil(err) assert.NotNil(cm) @@ -694,7 +726,7 @@ func TestLokiStackNotFoundBehavior(t *testing.T) { // Test behavior when LokiStack is not found (nil is passed) // This simulates the reconciler behavior when Get() returns NotFound - cm, digest, err := builder.configMap(context.Background(), nil) + cm, digest, err := builder.configMap(context.Background(), nil, nil) // ConfigMap should still be created successfully assert.Nil(err) diff --git a/internal/controller/flowcollector_controller.go b/internal/controller/flowcollector_controller.go index ff4595b76..e4d3998f8 100644 --- a/internal/controller/flowcollector_controller.go +++ b/internal/controller/flowcollector_controller.go @@ -7,6 +7,7 @@ import ( lokiv1 "github.com/grafana/loki/operator/apis/loki/v1" osv1 "github.com/openshift/api/console/v1" securityv1 "github.com/openshift/api/security/v1" + monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" appsv1 "k8s.io/api/apps/v1" ascv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" @@ -76,7 +77,6 @@ func Start(ctx context.Context, mgr *manager.Manager) (manager.PostCreateHook, e builder.Watches( &lokiv1.LokiStack{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, _ client.Object) []ctrl.Request { - // When a LokiStack changes, trigger reconcile of the FlowCollector return []ctrl.Request{{NamespacedName: constants.FlowCollectorName}} }), ) @@ -84,6 +84,22 @@ func Start(ctx context.Context, mgr *manager.Manager) (manager.PostCreateHook, e log.Info("LokiStack CRD detected") } + // When a PrometheusRule changes, trigger reconcile so console-plugin config is updated (recording-rule annotations) + if mgr.ClusterInfo.HasPromRule() { + builder.Watches( + &monitoringv1.PrometheusRule{}, + handler.EnqueueRequestsFromMapFunc(func(_ context.Context, o client.Object) []reconcile.Request { + // Only trigger reconcile for PrometheusRules with netobserv=true label + labels := o.GetLabels() + if labels != nil && labels["netobserv"] == "true" { + return []reconcile.Request{{NamespacedName: constants.FlowCollectorName}} + } + return []reconcile.Request{} + }), + ) + log.Info("PrometheusRule CRD detected, watching for netobserv=true rules") + } + ctrl, err := builder.Build(&r) if err != nil { return nil, err