Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions internal/controller/consoleplugin/consoleplugin_objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion internal/controller/consoleplugin/consoleplugin_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package consoleplugin

import (
"context"
"encoding/json"
"reflect"

osv1 "github.com/openshift/api/console/v1"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Copy link
Member

@memodi memodi Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when there are transient issues (API request failing etc.) retrieving the rules, it would overwrite existing rules with empty recording rules?

}
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is simply validating JSON is sufficient? do we want to validate specific fields?

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
}
66 changes: 49 additions & 17 deletions internal/controller/consoleplugin/consoleplugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -240,15 +240,15 @@ 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

// set status url and enable default tls
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

Expand All @@ -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

Expand All @@ -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)
}

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)
}

Expand All @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion internal/controller/flowcollector_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -76,14 +77,29 @@ 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}}
}),
)
r.lokiWatcherStarted = true
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
Expand Down