Implement the `contains` action (#9)

* implement the `contains` action

* Add contains action

* Polish code and add a unit test

* Add header licenses

Co-authored-by: kezhenxu94 <kezhenxu94@apache.org>
diff --git a/internal/components/verifier/verifier.go b/internal/components/verifier/verifier.go
index 84150c1..2f4675a 100644
--- a/internal/components/verifier/verifier.go
+++ b/internal/components/verifier/verifier.go
@@ -94,6 +94,7 @@
 	}
 
 	if !cmp.Equal(expected, actual) {
+		// TODO: use a custom Reporter (suggested by the comment of cmp.Diff)
 		diff := cmp.Diff(expected, actual)
 		fmt.Println(diff)
 		return &MismatchError{}
diff --git a/internal/components/verifier/verifier_test.go b/internal/components/verifier/verifier_test.go
new file mode 100644
index 0000000..f7d073a
--- /dev/null
+++ b/internal/components/verifier/verifier_test.go
@@ -0,0 +1,150 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package verifier
+
+import "testing"
+
+func TestVerify(t *testing.T) {
+	type args struct {
+		actualData       string
+		expectedTemplate string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+		err     error
+	}{
+		{
+			name: "should contain two elements",
+			args: args{
+				actualData: `
+metrics:
+  - name: business-zone::projectA
+    id: YnVzaW5lc3Mtem9uZTo6cHJvamVjdEE=.1
+    value: 1
+  - name: system::load balancer1
+    id: c3lzdGVtOjpsb2FkIGJhbGFuY2VyMQ==.1
+    value: 0
+  - name: system::load balancer2
+    id: WW91cl9BcHBsaWNhdGlvbk5hbWU=.1
+    value: 2
+`,
+				expectedTemplate: `
+metrics:
+{{- contains .metrics }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 0 }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 1 }}
+{{- end }}
+`,
+			},
+			wantErr: false,
+		},
+		{
+			name: "fail to contain two elements",
+			args: args{
+				actualData: `
+metrics:
+  - name: business-zone::projectA
+    id: YnVzaW5lc3Mtem9uZTo6cHJvamVjdEE=.1
+    value: 1
+  - name: system::load balancer1
+    id: c3lzdGVtOjpsb2FkIGJhbGFuY2VyMQ==.1
+    value: 0
+  - name: system::load balancer2
+    id: WW91cl9BcHBsaWNhdGlvbk5hbWU=.1
+    value: 1
+`,
+				expectedTemplate: `
+metrics:
+{{- contains .metrics }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 0 }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 1 }}
+{{- end }}
+`,
+			},
+			wantErr: true,
+		},
+		{
+			name: "should contain one element",
+			args: args{
+				actualData: `
+metrics:
+  - name: business-zone::projectA
+    id: YnVzaW5lc3Mtem9uZTo6cHJvamVjdEE=.1
+    value: 1
+  - name: system::load balancer1
+    id: c3lzdGVtOjpsb2FkIGJhbGFuY2VyMQ==.1
+    value: 0
+  - name: system::load balancer2
+    id: WW91cl9BcHBsaWNhdGlvbk5hbWU=.1
+    value: 2
+`,
+				expectedTemplate: `
+metrics:
+{{- contains .metrics }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 1 }}
+{{- end }}
+`,
+			},
+			wantErr: false,
+		},
+		{
+			name: "fail to contain one element",
+			args: args{
+				actualData: `
+metrics:
+  - name: business-zone::projectA
+    id: YnVzaW5lc3Mtem9uZTo6cHJvamVjdEE=.1
+    value: 1
+  - name: system::load balancer1
+    id: c3lzdGVtOjpsb2FkIGJhbGFuY2VyMQ==.1
+    value: 0
+  - name: system::load balancer2
+    id: WW91cl9BcHBsaWNhdGlvbk5hbWU=.1
+    value: 2
+`,
+				expectedTemplate: `
+metrics:
+{{- contains .metrics }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 3 }}
+{{- end }}
+`,
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := verify(tt.args.actualData, tt.args.expectedTemplate); (err != nil) != tt.wantErr {
+				t.Errorf("verify() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
diff --git a/test/verify/2.actual.yaml b/test/verify/2.actual.yaml
new file mode 100644
index 0000000..d0872b5
--- /dev/null
+++ b/test/verify/2.actual.yaml
@@ -0,0 +1,27 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+metrics:
+  - name: business-zone::projectA
+    id: YnVzaW5lc3Mtem9uZTo6cHJvamVjdEE=.1
+    value: 1
+  - name: system::load balancer1
+    id: c3lzdGVtOjpsb2FkIGJhbGFuY2VyMQ==.1
+    value: 0
+  - name: system::load balancer2
+    id: WW91cl9BcHBsaWNhdGlvbk5hbWU=.1
+    value: 2
diff --git a/test/verify/2.expected.yaml b/test/verify/2.expected.yaml
new file mode 100644
index 0000000..7a69757
--- /dev/null
+++ b/test/verify/2.expected.yaml
@@ -0,0 +1,26 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+metrics:
+{{- contains .metrics }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 0 }}
+  - name: {{ notEmpty .name }}
+    id: {{ notEmpty .id }}
+    value: {{ gt .value 1 }}
+{{- end }}
diff --git a/third-party/go/template/exec.go b/third-party/go/template/exec.go
index b4af1ad..0d88f70 100644
--- a/third-party/go/template/exec.go
+++ b/third-party/go/template/exec.go
@@ -7,13 +7,16 @@
 import (
 	"bytes"
 	"fmt"
-	"github.com/apache/skywalking-infra-e2e/third-party/go/internal/fmtsort"
-	"github.com/apache/skywalking-infra-e2e/third-party/go/template/parse"
-	"gopkg.in/yaml.v3"
 	"io"
 	"reflect"
 	"runtime"
 	"strings"
+
+	"github.com/apache/skywalking-infra-e2e/internal/logger"
+	"github.com/apache/skywalking-infra-e2e/third-party/go/internal/fmtsort"
+	"github.com/apache/skywalking-infra-e2e/third-party/go/template/parse"
+
+	"gopkg.in/yaml.v2"
 )
 
 // maxExecDepth specifies the maximum stack depth of templates within
@@ -274,8 +277,8 @@
 		}
 	case *parse.WithNode:
 		s.walkIfOrWith(parse.NodeWith, dot, node.Pipe, node.List, node.ElseList)
-	case *parse.AtLeastOnceNode:
-		s.walkAtLeastOnce(dot, node)
+	case *parse.ContainsNode:
+		s.walkContains(dot, node)
 	default:
 		s.errorf("unknown node: %s", node)
 	}
@@ -398,13 +401,13 @@
 	}
 }
 
-func (s *state) walkAtLeastOnce(dot reflect.Value, r *parse.AtLeastOnceNode) {
+func (s *state) walkContains(dot reflect.Value, r *parse.ContainsNode) {
 	s.at(r)
 	defer s.pop(s.mark())
 	val, _ := indirect(s.evalPipeline(dot, r.Pipe))
 	// mark top of stack before any variables in the body are pushed.
 	mark := s.mark()
-	oneIteration := func(index, elem reflect.Value) interface{} {
+	oneIteration := func(index, elem reflect.Value) []interface{} {
 		var b bytes.Buffer
 		ob := s.wr
 		s.wr = &b
@@ -422,8 +425,11 @@
 
 		s.wr = ob
 
-		var re interface{}
-		yaml.Unmarshal(b.Bytes(), &re)
+		// the contents inside `contains` must be an array
+		var re []interface{}
+		if err := yaml.Unmarshal(b.Bytes(), &re); err != nil {
+			logger.Log.Errorf("failed to unmarshal index: %v", index)
+		}
 		return re
 	}
 	switch val.Kind() {
@@ -431,23 +437,31 @@
 		if val.Len() == 0 {
 			break
 		}
-		match := false
-		sss := make([]interface{}, val.Len())
+		expectedSize := 0
+		// matched stores the matched pair of indices <expected index>: <actual index>
+		matched := make(map[int]int)
+		output := make([]interface{}, val.Len())
 		for i := 0; i < val.Len(); i++ {
-			actual := oneIteration(reflect.ValueOf(i), val.Index(i))
-			sss[i] = actual
-			value, _ := printableValue(val.Index(i))
-			if fmt.Sprint(value) == fmt.Sprint(actual) {
-				match = true
-				break
+			expectedArr := oneIteration(reflect.ValueOf(i), val.Index(i))
+			// expectedSize is the number of elements that the actual array should contain.
+			expectedSize = len(expectedArr)
+			actual, _ := printableValue(val.Index(i))
+			for j, expected := range expectedArr {
+				if fmt.Sprint(actual) == fmt.Sprint(expected) {
+					matched[j] = i
+					output[i] = actual
+				} else {
+					output[i] = expected
+				}
 			}
 		}
+
 		var marshal []byte
-		if match {
+		if len(matched) == expectedSize {
 			value, _ := printableValue(val)
 			marshal, _ = yaml.Marshal(value)
 		} else {
-			marshal, _ = yaml.Marshal(sss)
+			marshal, _ = yaml.Marshal(output)
 		}
 		s.wr.Write(append([]byte("\n"), marshal...))
 		return
@@ -479,10 +493,7 @@
 	case reflect.Invalid:
 		break // An invalid value is likely a nil map, etc. and acts like an empty map.
 	default:
-		s.errorf("range can't iterate over %v", val)
-	}
-	if r.ElseList != nil {
-		s.walk(dot, r.ElseList)
+		s.errorf("contains can't iterate over %v", val)
 	}
 }
 
diff --git a/third-party/go/template/parse/lex.go b/third-party/go/template/parse/lex.go
index 8ebb789..19eed9a 100644
--- a/third-party/go/template/parse/lex.go
+++ b/third-party/go/template/parse/lex.go
@@ -59,32 +59,32 @@
 	itemText       // plain text
 	itemVariable   // variable starting with '$', such as '$' or  '$1' or '$hello'
 	// Keywords appear after all the rest.
-	itemKeyword     // used only to delimit the keywords
-	itemBlock       // block keyword
-	itemDot         // the cursor, spelled '.'
-	itemDefine      // define keyword
-	itemElse        // else keyword
-	itemEnd         // end keyword
-	itemIf          // if keyword
-	itemNil         // the untyped nil constant, easiest to treat as a keyword
-	itemRange       // range keyword
-	itemTemplate    // template keyword
-	itemWith        // with keyword
-	itemAtLeastOnce // atLeastOnce keyword
+	itemKeyword  // used only to delimit the keywords
+	itemBlock    // block keyword
+	itemDot      // the cursor, spelled '.'
+	itemDefine   // define keyword
+	itemElse     // else keyword
+	itemEnd      // end keyword
+	itemIf       // if keyword
+	itemNil      // the untyped nil constant, easiest to treat as a keyword
+	itemRange    // range keyword
+	itemTemplate // template keyword
+	itemWith     // with keyword
+	itemContains // contains keyword
 )
 
 var key = map[string]itemType{
-	".":           itemDot,
-	"block":       itemBlock,
-	"define":      itemDefine,
-	"else":        itemElse,
-	"end":         itemEnd,
-	"if":          itemIf,
-	"range":       itemRange,
-	"nil":         itemNil,
-	"template":    itemTemplate,
-	"with":        itemWith,
-	"atLeastOnce": itemAtLeastOnce,
+	".":        itemDot,
+	"block":    itemBlock,
+	"define":   itemDefine,
+	"else":     itemElse,
+	"end":      itemEnd,
+	"if":       itemIf,
+	"range":    itemRange,
+	"nil":      itemNil,
+	"template": itemTemplate,
+	"with":     itemWith,
+	"contains": itemContains,
 }
 
 const eof = -1
diff --git a/third-party/go/template/parse/node.go b/third-party/go/template/parse/node.go
index 57ca52c..938d5bf 100644
--- a/third-party/go/template/parse/node.go
+++ b/third-party/go/template/parse/node.go
@@ -50,27 +50,27 @@
 }
 
 const (
-	NodeText        NodeType = iota // Plain text.
-	NodeAction                      // A non-control action such as a field evaluation.
-	NodeBool                        // A boolean constant.
-	NodeChain                       // A sequence of field accesses.
-	NodeCommand                     // An element of a pipeline.
-	NodeDot                         // The cursor, dot.
-	nodeElse                        // An else action. Not added to tree.
-	nodeEnd                         // An end action. Not added to tree.
-	NodeField                       // A field or method name.
-	NodeIdentifier                  // An identifier; always a function name.
-	NodeIf                          // An if action.
-	NodeList                        // A list of Nodes.
-	NodeNil                         // An untyped nil constant.
-	NodeNumber                      // A numerical constant.
-	NodePipe                        // A pipeline of commands.
-	NodeRange                       // A range action.
-	NodeString                      // A string constant.
-	NodeTemplate                    // A template invocation action.
-	NodeVariable                    // A $ variable.
-	NodeWith                        // A with action.
-	NodeAtLeastOnce                 // An atLeastOnce action.
+	NodeText       NodeType = iota // Plain text.
+	NodeAction                     // A non-control action such as a field evaluation.
+	NodeBool                       // A boolean constant.
+	NodeChain                      // A sequence of field accesses.
+	NodeCommand                    // An element of a pipeline.
+	NodeDot                        // The cursor, dot.
+	nodeElse                       // An else action. Not added to tree.
+	nodeEnd                        // An end action. Not added to tree.
+	NodeField                      // A field or method name.
+	NodeIdentifier                 // An identifier; always a function name.
+	NodeIf                         // An if action.
+	NodeList                       // A list of Nodes.
+	NodeNil                        // An untyped nil constant.
+	NodeNumber                     // A numerical constant.
+	NodePipe                       // A pipeline of commands.
+	NodeRange                      // A range action.
+	NodeString                     // A string constant.
+	NodeTemplate                   // A template invocation action.
+	NodeVariable                   // A $ variable.
+	NodeWith                       // A with action.
+	NodeContains                   // A contains action.
 )
 
 // Nodes.
@@ -829,8 +829,8 @@
 		name = "range"
 	case NodeWith:
 		name = "with"
-	case NodeAtLeastOnce:
-		name = "atLeastOnce"
+	case NodeContains:
+		name = "contains"
 	default:
 		panic("unknown branch type")
 	}
@@ -859,8 +859,8 @@
 		return b.tr.newRange(b.Pos, b.Line, b.Pipe, b.List, b.ElseList)
 	case NodeWith:
 		return b.tr.newWith(b.Pos, b.Line, b.Pipe, b.List, b.ElseList)
-	case NodeAtLeastOnce:
-		return b.tr.newAtLeastOnce(b.Pos, b.Line, b.Pipe, b.List, b.ElseList)
+	case NodeContains:
+		return b.tr.newContains(b.Pos, b.Line, b.Pipe, b.List, b.ElseList)
 	default:
 		panic("unknown branch type")
 	}
@@ -905,17 +905,17 @@
 	return w.tr.newWith(w.Pos, w.Line, w.Pipe.CopyPipe(), w.List.CopyList(), w.ElseList.CopyList())
 }
 
-// AtLeastOnce represents a {{atLeastOnce}} action and its commands.
-type AtLeastOnceNode struct {
+// Contains represents a {{contains}} action and its commands.
+type ContainsNode struct {
 	BranchNode
 }
 
-func (t *Tree) newAtLeastOnce(pos Pos, line int, pipe *PipeNode, list *ListNode, elseList *ListNode) *AtLeastOnceNode {
-	return &AtLeastOnceNode{BranchNode{tr: t, NodeType: NodeAtLeastOnce, Pos: pos, Line: line, Pipe: pipe, List: list}}
+func (t *Tree) newContains(pos Pos, line int, pipe *PipeNode, list *ListNode, elseList *ListNode) *ContainsNode {
+	return &ContainsNode{BranchNode{tr: t, NodeType: NodeContains, Pos: pos, Line: line, Pipe: pipe, List: list}}
 }
 
-func (w *AtLeastOnceNode) Copy() Node {
-	return w.tr.newAtLeastOnce(w.Pos, w.Line, w.Pipe.CopyPipe(), w.List.CopyList(), w.ElseList.CopyList())
+func (w *ContainsNode) Copy() Node {
+	return w.tr.newContains(w.Pos, w.Line, w.Pipe.CopyPipe(), w.List.CopyList(), w.ElseList.CopyList())
 }
 
 // TemplateNode represents a {{template}} action.
diff --git a/third-party/go/template/parse/parse.go b/third-party/go/template/parse/parse.go
index 6698aaf..ce46c8b 100644
--- a/third-party/go/template/parse/parse.go
+++ b/third-party/go/template/parse/parse.go
@@ -365,8 +365,8 @@
 		return t.templateControl()
 	case itemWith:
 		return t.withControl()
-	case itemAtLeastOnce:
-		return t.atLeastOnceControl()
+	case itemContains:
+		return t.containsControl()
 	}
 	t.backup()
 	token := t.peek()
@@ -506,11 +506,11 @@
 	return t.newWith(t.parseControl(false, "with"))
 }
 
-// AtLeastOnce:
-//	{{atLeastOnce number}} itemList {{end}}
+// Contains:
+//	{{contains number}} itemList {{end}}
 // If keyword is past.
-func (t *Tree) atLeastOnceControl() Node {
-	return t.newAtLeastOnce(t.parseControl(false, "atLeastOnce"))
+func (t *Tree) containsControl() Node {
+	return t.newContains(t.parseControl(false, "contains"))
 }
 
 // End: