Skip to content

Commit 0f6e70c

Browse files
committed
fix: Invalid handling of id in foreach when using discovery components
1 parent 3090c4a commit 0f6e70c

File tree

3 files changed

+193
-7
lines changed

3 files changed

+193
-7
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package controller_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
8+
"github.com/prometheus/client_golang/prometheus"
9+
"github.com/stretchr/testify/require"
10+
"go.opentelemetry.io/otel/trace/noop"
11+
12+
"github.com/grafana/alloy/internal/component"
13+
"github.com/grafana/alloy/internal/component/discovery"
14+
"github.com/grafana/alloy/internal/featuregate"
15+
"github.com/grafana/alloy/internal/runtime/internal/controller"
16+
"github.com/grafana/alloy/internal/runtime/logging"
17+
"github.com/grafana/alloy/syntax/ast"
18+
"github.com/grafana/alloy/syntax/parser"
19+
"github.com/grafana/alloy/syntax/vm"
20+
)
21+
22+
func TestForeachCollectionTargetsUsesId(t *testing.T) {
23+
config := `foreach "default" {
24+
collection = targets
25+
var = "each"
26+
id = "netbox_id"
27+
template {
28+
}
29+
}`
30+
moduleController := &moduleControllerStub{}
31+
foreachConfigNode := controller.NewForeachConfigNode(getBlockFromConfig(t, config), getComponentGlobals(t, moduleController), nil)
32+
vars := map[string]interface{}{
33+
"targets": []discovery.Target{
34+
discovery.NewTargetFromMap(map[string]string{
35+
"__address__": "192.0.2.10",
36+
"netbox_id": "8201",
37+
"instance": "192.0.2.10",
38+
}),
39+
discovery.NewTargetFromMap(map[string]string{
40+
"__address__": "198.51.100.24",
41+
"netbox_id": "8202",
42+
"instance": "198.51.100.24",
43+
}),
44+
},
45+
}
46+
require.NoError(t, foreachConfigNode.Evaluate(vm.NewScope(vars)))
47+
require.ElementsMatch(t, []string{"foreach_8201_1", "foreach_8202_1"}, moduleController.customComponents)
48+
}
49+
50+
func getBlockFromConfig(t *testing.T, config string) *ast.BlockStmt {
51+
file, err := parser.ParseFile("", []byte(config))
52+
require.NoError(t, err)
53+
return file.Body[0].(*ast.BlockStmt)
54+
}
55+
56+
func getComponentGlobals(t *testing.T, moduleController controller.ModuleController) controller.ComponentGlobals {
57+
l, _ := logging.New(os.Stderr, logging.DefaultOptions)
58+
return controller.ComponentGlobals{
59+
Logger: l,
60+
TraceProvider: noop.NewTracerProvider(),
61+
DataPath: t.TempDir(),
62+
MinStability: featuregate.StabilityGenerallyAvailable,
63+
OnBlockNodeUpdate: func(cn controller.BlockNode) { /* no-op */ },
64+
Registerer: prometheus.NewRegistry(),
65+
NewModuleController: func(opts controller.ModuleControllerOpts) controller.ModuleController {
66+
return moduleController
67+
},
68+
}
69+
}
70+
71+
type moduleControllerStub struct {
72+
customComponents []string
73+
}
74+
75+
func (m *moduleControllerStub) NewModule(id string, export component.ExportFunc) (component.Module, error) {
76+
return nil, nil
77+
}
78+
79+
func (m *moduleControllerStub) ModuleIDs() []string {
80+
return nil
81+
}
82+
83+
func (m *moduleControllerStub) NewCustomComponent(id string, export component.ExportFunc) (controller.CustomComponent, error) {
84+
m.customComponents = append(m.customComponents, id)
85+
return &customComponentStub{}, nil
86+
}
87+
88+
type customComponentStub struct{}
89+
90+
func (c *customComponentStub) LoadBody(body ast.Body, args map[string]any, customComponentRegistry *controller.CustomComponentRegistry) error {
91+
return nil
92+
}
93+
94+
func (c *customComponentStub) Run(ctx context.Context) error {
95+
return nil
96+
}

internal/runtime/internal/controller/node_config_foreach.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/grafana/alloy/internal/nodeconf/foreach"
2121
"github.com/grafana/alloy/internal/runner"
2222
"github.com/grafana/alloy/internal/runtime/logging/level"
23+
"github.com/grafana/alloy/syntax"
2324
"github.com/grafana/alloy/syntax/ast"
2425
"github.com/grafana/alloy/syntax/vm"
2526
)
@@ -200,13 +201,9 @@ func (fn *ForeachConfigNode) evaluate(scope *vm.Scope) error {
200201

201202
// Extract Id from collection if exists
202203
if args.Id != "" {
203-
if m, ok := args.Collection[i].(map[string]any); ok {
204-
if val, exists := m[args.Id]; exists {
205-
// Use the field's value for fingerprinting
206-
id = val
207-
} else {
208-
level.Warn(fn.logger).Log("msg", "specified id not found in collection item", "id", args.Id)
209-
}
204+
if val, ok := collectionItemID(args.Collection[i], args.Id, fn.logger); ok {
205+
// Use the field's value for fingerprinting
206+
id = val
210207
}
211208
}
212209

@@ -444,6 +441,57 @@ func objectFingerprint(id any, hashId bool) string {
444441
}
445442
}
446443

444+
func collectionItemID(item any, key string, logger log.Logger) (any, bool) {
445+
switch value := item.(type) {
446+
case map[string]any:
447+
// Inline object literals in Alloy decode into map[string]any.
448+
val, ok := value[key]
449+
if !ok {
450+
logMissingCollectionID(logger, key)
451+
return nil, false
452+
}
453+
return val, true
454+
case map[string]string:
455+
// Object values sourced from Go (e.g., vars) often use map[string]string.
456+
val, ok := value[key]
457+
if !ok {
458+
logMissingCollectionID(logger, key)
459+
return nil, false
460+
}
461+
return val, true
462+
case map[string]syntax.Value:
463+
// Capsules converted to objects use map[string]syntax.Value (VM canonical form).
464+
val, ok := value[key]
465+
if !ok {
466+
logMissingCollectionID(logger, key)
467+
return nil, false
468+
}
469+
return val.Interface(), true
470+
case syntax.ConvertibleIntoCapsule:
471+
return collectionItemIDFromCapsule(value, key, logger)
472+
default:
473+
return nil, false
474+
}
475+
}
476+
477+
func collectionItemIDFromCapsule(value syntax.ConvertibleIntoCapsule, key string, logger log.Logger) (any, bool) {
478+
var obj map[string]syntax.Value
479+
if err := value.ConvertInto(&obj); err == nil {
480+
val, ok := obj[key]
481+
if ok {
482+
return val.Interface(), true
483+
}
484+
logMissingCollectionID(logger, key)
485+
return nil, false
486+
}
487+
488+
return nil, false
489+
}
490+
491+
func logMissingCollectionID(logger log.Logger, key string) {
492+
level.Warn(logger).Log("msg", "specified id not found in collection item", "id", key)
493+
}
494+
447495
func replaceNonAlphaNumeric(s string) string {
448496
var builder strings.Builder
449497
for _, r := range s {

internal/runtime/internal/controller/node_config_foreach_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/grafana/alloy/internal/component"
1717
"github.com/grafana/alloy/internal/featuregate"
1818
"github.com/grafana/alloy/internal/runtime/logging"
19+
"github.com/grafana/alloy/syntax"
1920
"github.com/grafana/alloy/syntax/ast"
2021
"github.com/grafana/alloy/syntax/parser"
2122
"github.com/grafana/alloy/syntax/vm"
@@ -338,6 +339,47 @@ func TestStringIDHashWithKeySameValue(t *testing.T) {
338339
require.ElementsMatch(t, customComponentIds, []string{"foreach_1951d330e1267d082c816bfb3f40cce6eb9a8da9f6a6b9da09ace3c6514361cd_1", "foreach_1951d330e1267d082c816bfb3f40cce6eb9a8da9f6a6b9da09ace3c6514361cd_2"})
339340
}
340341

342+
func TestForeachCollectionMapAnyUsesId(t *testing.T) {
343+
config := `foreach "default" {
344+
collection = [obj1, obj2]
345+
var = "each"
346+
id = "netbox_id"
347+
template {
348+
}
349+
}`
350+
foreachConfigNode := NewForeachConfigNode(getBlockFromConfig(t, config), getComponentGlobals(t), nil)
351+
vars := map[string]interface{}{
352+
"obj1": map[string]any{
353+
"netbox_id": "9101",
354+
},
355+
"obj2": map[string]any{
356+
"netbox_id": "9102",
357+
},
358+
}
359+
require.NoError(t, foreachConfigNode.Evaluate(vm.NewScope(vars)))
360+
customComponentIds := foreachConfigNode.moduleController.(*ModuleControllerMock).CustomComponents
361+
require.ElementsMatch(t, customComponentIds, []string{"foreach_9101_1", "foreach_9102_1"})
362+
}
363+
364+
func TestForeachCollectionSyntaxValueUsesId(t *testing.T) {
365+
config := `foreach "default" {
366+
collection = [obj1]
367+
var = "each"
368+
id = "netbox_id"
369+
template {
370+
}
371+
}`
372+
foreachConfigNode := NewForeachConfigNode(getBlockFromConfig(t, config), getComponentGlobals(t), nil)
373+
vars := map[string]interface{}{
374+
"obj1": map[string]syntax.Value{
375+
"netbox_id": syntax.ValueFromString("9103"),
376+
},
377+
}
378+
require.NoError(t, foreachConfigNode.Evaluate(vm.NewScope(vars)))
379+
customComponentIds := foreachConfigNode.moduleController.(*ModuleControllerMock).CustomComponents
380+
require.ElementsMatch(t, customComponentIds, []string{"foreach_9103_1"})
381+
}
382+
341383
func TestCollectionNonArrayValue(t *testing.T) {
342384
config := `foreach "default" {
343385
collection = "aaa"

0 commit comments

Comments
 (0)