diff --git a/docs/sources/reference/stdlib/targets.md b/docs/sources/reference/stdlib/targets.md new file mode 100644 index 0000000000..2f09a891c9 --- /dev/null +++ b/docs/sources/reference/stdlib/targets.md @@ -0,0 +1,35 @@ +--- +canonical: https://grafana.com/docs/alloy/latest/reference/stdlib/targets/ +description: Learn about targets functions +menuTitle: targets +title: targets +--- + +# targets + +The `targets` namespace contains functions related to `list(map(string))` arguments. +They are often used by [prometheus][prom-comp] and [discovery][disc-comp] components. +Refer to [Compatible components][] for a full list of components which export and consume targets. + +[prom-comp]: ../components/prometheus/ +[disc-comp]: ../components/discovery/ +[Compatible components]: ../compatibility/#targets + +## targets.merge + +The `targets.inner_join` function allows you to join two arrays containing maps if certain keys have matching values in both maps. +It takes three inputs: + +* The first two inputs are a of type `list(map(string))`. The keys of the map are strings. + The value for each key could have any Alloy type such as a string, integer, map, or a capsule. +* The third input is an array containing strings. The strings are the keys whose value has to match for maps to be joined. + +### Examples + +```alloy +targets.inner_join(discovery.kubernetes.k8s_pods.targets, prometheus.exporter.postgres, ["instance"]) +``` + +```alloy +targets.inner_join(prometheus.exporter.redis.default.targets, [{"instance"="1.1.1.1", "testLabelKey" = "testLabelVal"}], ["instance"]) +``` \ No newline at end of file diff --git a/syntax/internal/stdlib/stdlib.go b/syntax/internal/stdlib/stdlib.go index 995e538d4b..0bb23db966 100644 --- a/syntax/internal/stdlib/stdlib.go +++ b/syntax/internal/stdlib/stdlib.go @@ -52,6 +52,7 @@ var Identifiers = map[string]interface{}{ "sys": sys, "convert": convert, "array": array, + "targets": targets, "encoding": encoding, "string": str, "file": file, @@ -89,6 +90,10 @@ var array = map[string]interface{}{ "concat": concat, } +var targets = map[string]interface{}{ + "merge": targetsMerge, +} + var convert = map[string]interface{}{ "nonsensitive": nonSensitive, } @@ -146,6 +151,139 @@ var concat = value.RawFunction(func(funcValue value.Value, args ...value.Value) return value.Array(raw...), nil }) +// This function assumes that the types of the value.Value objects are correct. +func shouldJoin(left, right value.Value, conditions value.Value) (bool, error) { + for i := 0; i < conditions.Len(); i++ { + c := conditions.Index(i) + condition := c.Text() + + leftVal, ok := left.Key(condition) + if !ok { + return false, fmt.Errorf("concatMaps: key %s not found in left map", condition) + } + + rightVal, ok := right.Key(condition) + if !ok { + return false, fmt.Errorf("concatMaps: key %s not found in right map", condition) + } + + if !leftVal.Equal(rightVal) { + return false, nil + } + } + return true, nil +} + +// Merge two maps. +// If a key exists in both maps, the value from the right map will be used. +func concatMaps(left, right value.Value) (value.Value, error) { + res := make(map[string]value.Value) + + for _, key := range left.Keys() { + val, ok := left.Key(key) + if !ok { + return value.Null, fmt.Errorf("concatMaps: key %s not found in left map", key) + } + res[key] = val + } + + for _, key := range right.Keys() { + val, ok := right.Key(key) + if !ok { + return value.Null, fmt.Errorf("concatMaps: key %s not found in right map", key) + } + res[key] = val + } + + return value.Object(res), nil +} + +// Inputs: +// args[0]: []map[string]string: lhs array +// args[1]: []map[string]string: rhs array +// args[2]: []string: merge conditions +var targetsMerge = value.RawFunction(func(funcValue value.Value, args ...value.Value) (value.Value, error) { + if len(args) != 3 { + return value.Value{}, fmt.Errorf("inner_join: expected 3 arguments, got %d", len(args)) + } + + // Validate args[0] and args[1] + for i := range []int{0, 1} { + if args[i].Type() != value.TypeArray { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[i], + Index: i, + Inner: value.TypeError{ + Value: args[i], + Expected: value.TypeArray, + }, + } + } + for j := 0; j < args[i].Len(); j++ { + if args[i].Index(j).Type() != value.TypeObject { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[i].Index(j), + Index: j, + Inner: value.TypeError{ + Value: args[i].Index(j), + Expected: value.TypeObject, + }, + } + } + } + } + + // Validate args[2] + if args[2].Type() != value.TypeArray { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[2], + Index: 2, + Inner: value.TypeError{ + Value: args[2], + Expected: value.TypeArray, + }, + } + } + if args[2].Len() == 0 { + return value.Null, value.ArgError{ + Function: funcValue, + Argument: args[2], + Index: 2, + Inner: fmt.Errorf("inner_join: merge conditions must not be empty"), + } + } + + // We cannot preallocate the size of the result array, because we don't know + // how well the merge is going to go. If none of the merge conditions are met, + // the result array will be empty. + res := []value.Value{} + + for i := 0; i < args[0].Len(); i++ { + for j := 0; j < args[1].Len(); j++ { + left := args[0].Index(i) + right := args[1].Index(j) + + join, err := shouldJoin(left, right, args[2]) + if err != nil { + return value.Null, err + } + + if join { + val, err := concatMaps(left, right) + if err != nil { + return value.Null, err + } + res = append(res, val) + } + } + } + + return value.Array(res...), nil +}) + func jsonDecode(in string) (interface{}, error) { var res interface{} err := json.Unmarshal([]byte(in), &res) diff --git a/syntax/internal/value/value.go b/syntax/internal/value/value.go index 3c8554b88c..0925012094 100644 --- a/syntax/internal/value/value.go +++ b/syntax/internal/value/value.go @@ -221,9 +221,11 @@ func (v Value) Text() string { } } -// Len returns the length of v. Panics if v is not an array or object. +// Len returns the length of v. Panics if v is not an array, an object, or nil. func (v Value) Len() int { switch v.ty { + case TypeNull: + return 0 case TypeArray: return v.rv.Len() case TypeObject: @@ -396,7 +398,7 @@ func (v Value) Key(key string) (index Value, ok bool) { // // An ArgError will be returned if one of the arguments is invalid. An Error // will be returned if the function call returns an error or if the number of -// arguments doesn't match. +// arguments doesn't match func (v Value) Call(args ...Value) (Value, error) { if v.ty != TypeFunction { panic("syntax/value: Call called on non-function type") @@ -553,3 +555,15 @@ func convertGoNumber(nval Number, target reflect.Type) reflect.Value { panic("unsupported number conversion") } + +func (v Value) Equal(rhs Value) bool { + if v.Type() != rhs.Type() { + return false + } + + if !v.rv.Equal(rhs.rv) { + return false + } + + return true +} diff --git a/syntax/internal/value/value_test.go b/syntax/internal/value/value_test.go index fbebcabdd7..c957be052d 100644 --- a/syntax/internal/value/value_test.go +++ b/syntax/internal/value/value_test.go @@ -128,6 +128,23 @@ func (*pointerMarshaler) MarshalText() ([]byte, error) { } func TestValue_Call(t *testing.T) { + // t.Run("slices with maps", func(t *testing.T) { + // add := func(a, b []map[string]string) []map[string]string { + // res := make([]map[string]string, 0, len(a)+len(b)) + // res = append(res, a...) + // res = append(res, b...) + // return res + // } + // addVal := value.Encode(add) + + // res, err := addVal.Call( + // value.Array(map[string]string{"a": "b"}), + // value.Int(43), + // ) + // require.NoError(t, err) + // require.Equal(t, int64(15+43), res.Int()) + // }) + t.Run("simple", func(t *testing.T) { add := func(a, b int) int { return a + b } addVal := value.Encode(add) diff --git a/syntax/vm/vm_stdlib_test.go b/syntax/vm/vm_stdlib_test.go index e8009ae157..74a0ec771c 100644 --- a/syntax/vm/vm_stdlib_test.go +++ b/syntax/vm/vm_stdlib_test.go @@ -39,6 +39,57 @@ func TestVM_Stdlib(t *testing.T) { {"encoding.from_yaml nil field", "encoding.from_yaml(`foo: null`)", map[string]interface{}{"foo": nil}}, {"encoding.from_yaml nil array element", `encoding.from_yaml("[0, null]")`, []interface{}{0, nil}}, {"encoding.from_base64", `encoding.from_base64("Zm9vYmFyMTIzIT8kKiYoKSctPUB+")`, string(`foobar123!?$*&()'-=@~`)}, + + // Map tests + { + // Basic case. No conflicting key/val pairs. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}}, + }, + { + // The first array has 2 maps, each with the same key/val pairs. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}, {"a" = "a1", "b" = "b1"}], [{"a" = "a1", "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": "a1", "b": "b1", "c": "c1"}, {"a": "a1", "b": "b1", "c": "c1"}}, + }, + { + // Non-unique merge criteria. + "targets.merge", + `targets.merge([{"pod" = "a", "lbl" = "q"}, {"pod" = "b", "lbl" = "q"}], [{"pod" = "c", "lbl" = "q"}, {"pod" = "d", "lbl" = "q"}], ["lbl"])`, + []map[string]interface{}{{"lbl": "q", "pod": "c"}, {"lbl": "q", "pod": "d"}, {"lbl": "q", "pod": "c"}, {"lbl": "q", "pod": "d"}}, + }, + { + // Basic case. Integer and string values. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 1, "c" = "c1"}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": 2.2, "c": "c1"}}, + }, + { + // The second map will override a value from the first. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 1, "b" = "3.3"}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": "3.3"}}, + }, + { + // Not enough matches for a join. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = 2, "b" = "3.3"}], ["a"])`, + []map[string]interface{}{}, + }, + { + // Not enough matches for a join. + // The "a" value has differing types. + "targets.merge", + `targets.merge([{"a" = 1, "b" = 2.2}], [{"a" = "1", "b" = "3.3"}], ["a"])`, + []map[string]interface{}{}, + }, + { + // Basic case. Some values are arrays and maps. + "targets.merge", + `targets.merge([{"a" = 1, "b" = [1,2,3]}], [{"a" = 1, "c" = {"d" = {"e" = 10}}}], ["a"])`, + []map[string]interface{}{{"a": 1, "b": []interface{}{1, 2, 3}, "c": map[string]interface{}{"d": map[string]interface{}{"e": 10}}}}, + }, } for _, tc := range tt { @@ -55,6 +106,41 @@ func TestVM_Stdlib(t *testing.T) { } } +func TestVM_Stdlib_Errors(t *testing.T) { + tt := []struct { + name string + input string + expectedErr string + }{ + // Map tests + { + // Error: invalid RHS type - string. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], "a", ["a"])`, + `"a" should be array, got string`, + }, + { + // Error: invalid RHS type - an array with strings. + "targets.merge", + `targets.merge([{"a" = "a1", "b" = "b1"}], ["a"], ["a"])`, + `"a" should be object, got string`, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + expr, err := parser.ParseExpression(tc.input) + require.NoError(t, err) + + eval := vm.New(expr) + + rv := reflect.New(reflect.TypeOf([]map[string]interface{}{})) + err = eval.Evaluate(nil, rv.Interface()) + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} + func TestStdlibCoalesce(t *testing.T) { t.Setenv("TEST_VAR2", "Hello!")