Skip to content

Commit f2ce5a1

Browse files
committed
add_kubernetes_metadata: implement PdataProcessor interface
Adds RunPdata to the kubernetes processor and introduces MetadataIndexPdata, which resolves the index key directly from a pcommon.Map. Matchers implementing the new pdataMatcher interface operate natively on the map.
1 parent 53993f3 commit f2ce5a1

4 files changed

Lines changed: 241 additions & 33 deletions

File tree

libbeat/processors/add_kubernetes_metadata/kubernetes.go

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,16 @@ import (
3232

3333
k8sclient "k8s.io/client-go/kubernetes"
3434

35+
"go.opentelemetry.io/collector/pdata/pcommon"
36+
3537
"github.com/elastic/elastic-agent-autodiscover/kubernetes"
3638
"github.com/elastic/elastic-agent-autodiscover/kubernetes/metadata"
3739
"github.com/elastic/elastic-agent-libs/config"
3840
"github.com/elastic/elastic-agent-libs/logp"
3941
"github.com/elastic/elastic-agent-libs/mapstr"
4042

4143
"github.com/elastic/beats/v7/libbeat/beat"
44+
"github.com/elastic/beats/v7/libbeat/otel/otelmap"
4245
"github.com/elastic/beats/v7/libbeat/processors"
4346
"github.com/elastic/beats/v7/libbeat/processors/shared"
4447
)
@@ -79,6 +82,8 @@ func init() {
7982
Indexing.AddMatcher(FieldFormatMatcherName, NewFieldFormatMatcher)
8083
}
8184

85+
var _ processors.PdataProcessor = (*kubernetesAnnotator)(nil)
86+
8287
func isKubernetesAvailable(client k8sclient.Interface, logger *logp.Logger) (bool, error) {
8388
server, err := client.Discovery().ServerVersion()
8489
if err != nil {
@@ -398,34 +403,69 @@ func (k *kubernetesAnnotator) Run(event *beat.Event) (*beat.Event, error) {
398403
return event, nil
399404
}
400405

401-
// One full clone for the kubernetes field; one cheap sub-map clone for the OCI
402-
// container field. This replaces the original three full clones.
403-
kubeMeta := metadata.Clone()
406+
kubeMeta, ociContainer := prepareKubeMetadata(metadata)
407+
if ociContainer != nil {
408+
event.Fields.DeepUpdate(mapstr.M{"container": ociContainer})
409+
}
410+
event.Fields.DeepUpdate(kubeMeta)
411+
412+
return event, nil
413+
}
414+
415+
// RunPdata enriches the given pcommon.Map directly with Kubernetes metadata
416+
func (k *kubernetesAnnotator) RunPdata(body pcommon.Map) (bool, error) {
417+
if _, ok := body.Get("kubernetes"); ok {
418+
return false, nil
419+
}
420+
421+
// A nil state means init has not published yet (still running or kubernetes unavailable); the
422+
// load pairs with the Store in init for a race-free read.
423+
state := k.state.Load()
424+
if state == nil {
425+
return false, nil
426+
}
404427

405-
// Build the OCI container field by cloning only the container sub-map —
406-
// much cheaper than cloning the full metadata. Transform it in place:
407-
// drop container.name and rewrite container.image -> container.image.name.
428+
index := state.matchers.MetadataIndexPdata(body)
429+
if index == "" {
430+
k.log.Debug("No container match string, not adding kubernetes data")
431+
return false, nil
432+
}
433+
434+
metadata := k.cache.get(index)
435+
if metadata == nil {
436+
return false, nil
437+
}
438+
439+
kubeMeta, ociContainer := prepareKubeMetadata(metadata)
440+
if ociContainer != nil {
441+
if err := otelmap.MergeMapstrIntoPdata(mapstr.M{"container": ociContainer}, body, true); err != nil {
442+
return false, err
443+
}
444+
}
445+
return false, otelmap.MergeMapstrIntoPdata(kubeMeta, body, true)
446+
}
447+
448+
// prepareKubeMetadata clones the cached metadata, builds the OCI container
449+
// sub-map from kubernetes.container (dropping name, rewriting image), and
450+
// strips the kubernetes-only container fields. container.name is kept in
451+
// kubeMeta to match original behaviour.
452+
// ociContainer is nil when the kubernetes.container sub-map is absent.
453+
func prepareKubeMetadata(metadata mapstr.M) (kubeMeta mapstr.M, ociContainer mapstr.M) {
454+
kubeMeta = metadata.Clone()
408455
if containerVal, err := kubeMeta.GetValue("kubernetes.container"); err == nil {
409456
if cm, ok := containerVal.(mapstr.M); ok {
410-
ociContainer := cm.Clone()
457+
ociContainer = cm.Clone()
411458
_ = ociContainer.Delete("name")
412459
if img, imgErr := ociContainer.GetValue("image"); imgErr == nil {
413460
_ = ociContainer.Delete("image")
414461
ociContainer["image"] = mapstr.M{"name": img}
415462
}
416-
event.Fields.DeepUpdate(mapstr.M{"container": ociContainer})
417463
}
418464
}
419-
420-
// Remove container fields that belong only in the OCI section before writing
421-
// kubernetes metadata to the event. container.name is intentionally kept here
422-
// to match original behaviour.
423465
_ = kubeMeta.Delete("kubernetes.container.id")
424466
_ = kubeMeta.Delete("kubernetes.container.runtime")
425467
_ = kubeMeta.Delete("kubernetes.container.image")
426-
event.Fields.DeepUpdate(kubeMeta)
427-
428-
return event, nil
468+
return
429469
}
430470

431471
func (k *kubernetesAnnotator) Close() error {

libbeat/processors/add_kubernetes_metadata/kubernetes_test.go

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,41 @@ import (
2727

2828
"github.com/stretchr/testify/assert"
2929
"github.com/stretchr/testify/require"
30+
"go.opentelemetry.io/collector/pdata/pcommon"
3031
"go.uber.org/goleak"
3132
"k8s.io/apimachinery/pkg/runtime"
3233
k8sclient "k8s.io/client-go/kubernetes"
3334
k8sfake "k8s.io/client-go/kubernetes/fake"
3435
k8stesting "k8s.io/client-go/testing"
3536

3637
"github.com/elastic/beats/v7/libbeat/beat"
38+
"github.com/elastic/beats/v7/libbeat/otel/otelmap"
39+
"github.com/elastic/beats/v7/libbeat/processors"
3740
"github.com/elastic/elastic-agent-libs/config"
3841
"github.com/elastic/elastic-agent-libs/logp/logptest"
3942
"github.com/elastic/elastic-agent-libs/mapstr"
4043
)
4144

45+
// assertRunPdataEquivalent verifies that RunPdata, given the same input fields
46+
// used to produce result via Run, enriches a pcommon.Map with identical output.
47+
func assertRunPdataEquivalent(t *testing.T, p beat.Processor, input, result mapstr.M) {
48+
t.Helper()
49+
50+
pp, ok := p.(processors.PdataProcessor)
51+
require.True(t, ok, "processor must implement PdataProcessor")
52+
53+
body := pcommon.NewMap()
54+
require.NoError(t, otelmap.FromMapstr(body, input))
55+
drop, err := pp.RunPdata(body)
56+
require.NoError(t, err)
57+
require.False(t, drop)
58+
59+
legacyNorm := pcommon.NewMap()
60+
require.NoError(t, otelmap.FromMapstr(legacyNorm, result))
61+
assert.Equal(t, otelmap.ToMapstr(legacyNorm), otelmap.ToMapstr(body),
62+
"Run and RunPdata must produce identical output")
63+
}
64+
4265
// Test Annotator is skipped if kubernetes metadata already exist
4366
func TestAnnotatorSkipped(t *testing.T) {
4467
cfg := config.MustNewConfigFrom(map[string]interface{}{
@@ -67,23 +90,7 @@ func TestAnnotatorSkipped(t *testing.T) {
6790
},
6891
})
6992

70-
event, err := processor.Run(&beat.Event{
71-
Fields: mapstr.M{
72-
"kubernetes": mapstr.M{
73-
"pod": mapstr.M{
74-
"name": "foo",
75-
"id": "pod_id",
76-
"metrics": mapstr.M{
77-
"a": 1,
78-
"b": 2,
79-
},
80-
},
81-
},
82-
},
83-
})
84-
assert.NoError(t, err)
85-
86-
assert.Equal(t, mapstr.M{
93+
input := mapstr.M{
8794
"kubernetes": mapstr.M{
8895
"pod": mapstr.M{
8996
"name": "foo",
@@ -94,7 +101,14 @@ func TestAnnotatorSkipped(t *testing.T) {
94101
},
95102
},
96103
},
97-
}, event.Fields)
104+
}
105+
event, err := processor.Run(&beat.Event{Fields: input.Clone()})
106+
assert.NoError(t, err)
107+
108+
assert.Equal(t, input, event.Fields)
109+
110+
// RunPdata path: assert Run == RunPdata.
111+
assertRunPdataEquivalent(t, &processor, input, event.Fields)
98112
}
99113

100114
// Test metadata are not included in the event
@@ -123,6 +137,9 @@ func TestAnnotatorWithNoKubernetesAvailable(t *testing.T) {
123137
assert.NoError(t, err)
124138

125139
assert.Equal(t, intialEventMap, event.Fields)
140+
141+
// RunPdata path: assert Run == RunPdata.
142+
assertRunPdataEquivalent(t, &processor, intialEventMap, event.Fields)
126143
}
127144

128145
// TestNewProcessorConfigDefaultIndexers validates the behaviour of default indexers and
@@ -317,6 +334,9 @@ func TestAnnotatorRunFullContainerMetadata(t *testing.T) {
317334
assert.NotContains(t, k8sContainer, "id", "kubernetes.container must NOT have id")
318335
assert.NotContains(t, k8sContainer, "runtime", "kubernetes.container must NOT have runtime")
319336
assert.NotContains(t, k8sContainer, "image", "kubernetes.container must NOT have image")
337+
338+
// RunPdata path: assert Run == RunPdata.
339+
assertRunPdataEquivalent(t, processor, mapstr.M{"container": mapstr.M{"id": "abc123"}}, event.Fields)
320340
}
321341

322342
// TestAnnotatorRunContainerWithoutImage verifies that when there is no image in
@@ -345,6 +365,9 @@ func TestAnnotatorRunContainerWithoutImage(t *testing.T) {
345365
assert.Equal(t, "abc456", container["id"])
346366
assert.Equal(t, "docker", container["runtime"])
347367
assert.NotContains(t, container, "image", "container must NOT have image key when no image in metadata")
368+
369+
// RunPdata path: assert Run == RunPdata.
370+
assertRunPdataEquivalent(t, processor, baseEvent("abc456").Fields, event.Fields)
348371
}
349372

350373
// TestAnnotatorRunContainerWithoutName verifies that missing container.name
@@ -375,6 +398,9 @@ func TestAnnotatorRunContainerWithoutName(t *testing.T) {
375398
require.IsType(t, mapstr.M{}, imageRaw)
376399
imageMap, _ := imageRaw.(mapstr.M)
377400
assert.Equal(t, "busybox:latest", imageMap["name"])
401+
402+
// RunPdata path: assert Run == RunPdata.
403+
assertRunPdataEquivalent(t, processor, baseEvent("abc789").Fields, event.Fields)
378404
}
379405

380406
// TestAnnotatorRunNoContainerSubMap verifies that when the metadata has no
@@ -426,6 +452,9 @@ func TestAnnotatorRunNoContainerSubMap(t *testing.T) {
426452
require.IsType(t, mapstr.M{}, podRaw)
427453
pod, _ := podRaw.(mapstr.M)
428454
assert.Equal(t, "mypod", pod["name"])
455+
456+
// RunPdata path: assert Run == RunPdata.
457+
assertRunPdataEquivalent(t, processor, mapstr.M{"pod": mapstr.M{"name": "mypod"}}, event.Fields)
429458
}
430459

431460
// TestAnnotatorRunExtraContainerFieldsPreserved verifies that unknown extra
@@ -454,6 +483,9 @@ func TestAnnotatorRunExtraContainerFieldsPreserved(t *testing.T) {
454483
container, _ := containerRaw.(mapstr.M)
455484

456485
assert.Equal(t, "extra", container["custom_field"], "extra container fields must be preserved in OCI container")
486+
487+
// RunPdata path: assert Run == RunPdata.
488+
assertRunPdataEquivalent(t, processor, baseEvent("xtra001").Fields, event.Fields)
457489
}
458490

459491
// TestAnnotatorRunCacheNotMutated verifies that running the processor multiple
@@ -536,6 +568,24 @@ func TestAnnotatorRunEventIndependence(t *testing.T) {
536568
assert.NotContains(t, container2, "injected", "mutating first result must not affect second result")
537569
}
538570

571+
// TestAnnotatorRunCacheMiss verifies that when the matcher returns an index key
572+
// but the cache has no entry for it, both Run and RunPdata leave the event
573+
// unchanged and return no error.
574+
func TestAnnotatorRunCacheMiss(t *testing.T) {
575+
// Seed the cache with a different key so the matcher can fire but miss.
576+
processor := newAnnotatorForTest(t, "known-key", mapstr.M{
577+
"kubernetes": mapstr.M{"pod": mapstr.M{"name": "mypod"}},
578+
})
579+
580+
input := mapstr.M{"container": mapstr.M{"id": "unknown-key"}}
581+
event, err := processor.Run(&beat.Event{Fields: input.Clone()})
582+
require.NoError(t, err)
583+
assert.Equal(t, input, event.Fields, "Run must leave the event unchanged on a cache miss")
584+
585+
// RunPdata path: assert Run == RunPdata.
586+
assertRunPdataEquivalent(t, processor, input, event.Fields)
587+
}
588+
539589
func BenchmarkKubernetesAnnotatorRun(b *testing.B) {
540590
cfg := config.MustNewConfigFrom(map[string]interface{}{
541591
"lookup_fields": []string{"container.id"},

libbeat/processors/add_kubernetes_metadata/matchers.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ import (
2222
"regexp"
2323
"slices"
2424

25+
"go.opentelemetry.io/collector/pdata/pcommon"
26+
2527
"github.com/elastic/beats/v7/libbeat/beat"
2628
"github.com/elastic/beats/v7/libbeat/common/fmtstr"
29+
"github.com/elastic/beats/v7/libbeat/otel/otelmap"
2730
"github.com/elastic/beats/v7/libbeat/outputs/codec"
2831
"github.com/elastic/beats/v7/libbeat/outputs/codec/format"
2932
"github.com/elastic/elastic-agent-libs/config"
@@ -89,6 +92,35 @@ func (m *Matchers) MetadataIndex(event mapstr.M) string {
8992
return ""
9093
}
9194

95+
// pdataMatcher is an optional interface a Matcher can implement to avoid a
96+
// full pcommon.Map→mapstr.M conversion when looking up the metadata index.
97+
type pdataMatcher interface {
98+
MetadataIndexPdata(body pcommon.Map) string
99+
}
100+
101+
// MetadataIndexPdata is the pdata-native counterpart of MetadataIndex. For
102+
// each matcher that implements pdataMatcher the lookup is done directly on the
103+
// pcommon.Map; the mapstr.M conversion is performed lazily and only once for
104+
// matchers that do not implement the interface.
105+
func (m *Matchers) MetadataIndexPdata(body pcommon.Map) string {
106+
var fallback mapstr.M
107+
for _, matcher := range m.matchers {
108+
if pm, ok := matcher.(pdataMatcher); ok {
109+
if index := pm.MetadataIndexPdata(body); index != "" {
110+
return index
111+
}
112+
} else {
113+
if fallback == nil {
114+
fallback = otelmap.ToMapstr(body)
115+
}
116+
if index := matcher.MetadataIndex(fallback); index != "" {
117+
return index
118+
}
119+
}
120+
}
121+
return ""
122+
}
123+
92124
func (m *Matchers) Empty() bool {
93125
return len(m.matchers) == 0
94126
}
@@ -157,6 +189,28 @@ func (f *FieldMatcher) MetadataIndex(event mapstr.M) string {
157189
return ""
158190
}
159191

192+
func (f *FieldMatcher) MetadataIndexPdata(body pcommon.Map) string {
193+
for _, field := range f.MatchFields {
194+
v, ok := otelmap.GetAtPath(field, body)
195+
if !ok || v.Type() != pcommon.ValueTypeStr {
196+
continue
197+
}
198+
fieldValue := v.Str()
199+
if f.Regexp == nil {
200+
return fieldValue
201+
}
202+
matches := f.Regexp.FindStringSubmatch(fieldValue)
203+
if matches == nil {
204+
continue
205+
}
206+
key := matches[f.Regexp.SubexpIndex(regexKeyGroupName)]
207+
if key != "" {
208+
return key
209+
}
210+
}
211+
return ""
212+
}
213+
160214
type FieldFormatMatcher struct {
161215
Codec codec.Codec
162216
}

0 commit comments

Comments
 (0)