Repository: replicatedhq/troubleshoot
Pull request #598 (2022-06-15 17:47:08)

feat: adds new yamlCompare and jsonCompare analyzers
This PR adds two new types of analyzers `yamlCompare` and `jsonCompare` that can be used to compare an expected value against collected data.  The `when` condition functions similarly to the [text analyzer](https://troubleshoot.sh/docs/analyze/regex/) and takes a boolean input to determine whether the outcome has been satisfied (if `when` is not included, the default behavior is to pass when there is a match).  Additionally, there is a `path` field in both that allows the user to drill down to a particular field in the collected data structure before performing the comparison.

This PR also adds an `annotations` field to the analyzer metadata so that implementations can utilize them for various purposes (more on annotations [here](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/)).

Example yamlCompare spec:
```
apiVersion: troubleshoot.sh/v1beta2
kind: Preflight
metadata:
  name: example-yaml-compare
spec:
  collectors:
    - data:
        name: example.yaml
        data: |
          foo: bar
          stuff:
            foo: bar
            bar: foo
          morestuff:
          - foo:
              bar: baz
  analyzers:
    - yamlCompare:
        checkName: Compare some yaml
        fileName: example.yaml
        path: "morestuff.[0].foo"
        value: |
          bar: baz
        outcomes:
          - warn:
              when: "false"
              message: The collected data does not match the value
              title: Not a match
              uri: https://troubleshoot.sh
          - pass:
              when: "true"
              message: The collected data matches the value
              title: Match
              uri: https://troubleshoot.sh
```

Example jsonCompare spec:
```
apiVersion: troubleshoot.sh/v1beta2
kind: Preflight
metadata:
  name: example-json-compare
spec:
  collectors:
    - data:
        name: example.json
        data: |
          {
            "foo": "bar",
            "stuff": {
              "foo": "bar",
              "bar": true
            },
            "morestuff": [
              {
                "foo": {
                  "bar": 123
                }
              }
            ]
          }
  analyzers:
    - jsonCompare:
        checkName: Compare some json
        fileName: example.json
        path: "morestuff.[0].foo.bar"
        value: |
          123
        outcomes:
          - warn:
              when: "false"
              message: The collected data does not match the value
              title: Not a match
              uri: https://troubleshoot.sh
          - pass:
              when: "true"
              message: The collected data matches the value
              title: Match
              uri: https://troubleshoot.sh
```

divolgin: Could we utilize `when` on outcomes to make it also possible to fail when values match?  Like this, but less ugly

```
        outcomes:
          - error:
               when: < 5
          - warn:
               when: < 10
          - pass:
               ## default
```


Patch:
diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go
index a992d870..c1fe6bf1 100644
--- a/pkg/analyze/analyzer.go
+++ b/pkg/analyze/analyzer.go
@@ -339,6 +339,36 @@ func Analyze(analyzer *troubleshootv1beta2.Analyze, getFile getCollectedFileCont
 		}
 		return results, nil
 	}
+	if analyzer.YamlCompare != nil {
+		isExcluded, err := isExcluded(analyzer.YamlCompare.Exclude)
+		if err != nil {
+			return nil, err
+		}
+		if isExcluded {
+			return nil, nil
+		}
+		result, err := analyzeYamlCompare(analyzer.YamlCompare, getFile)
+		if err != nil {
+			return nil, err
+		}
+		result.Strict = analyzer.YamlCompare.Strict.BoolOrDefaultFalse()
+		return []*AnalyzeResult{result}, nil
+	}
+	if analyzer.JsonCompare != nil {
+		isExcluded, err := isExcluded(analyzer.JsonCompare.Exclude)
+		if err != nil {
+			return nil, err
+		}
+		if isExcluded {
+			return nil, nil
+		}
+		result, err := analyzeJsonCompare(analyzer.JsonCompare, getFile)
+		if err != nil {
+			return nil, err
+		}
+		result.Strict = analyzer.JsonCompare.Strict.BoolOrDefaultFalse()
+		return []*AnalyzeResult{result}, nil
+	}
 	if analyzer.Postgres != nil {
 		isExcluded, err := isExcluded(analyzer.Postgres.Exclude)
 		if err != nil {
diff --git a/pkg/analyze/json_compare.go b/pkg/analyze/json_compare.go
new file mode 100644
index 00000000..4dc7a112
--- /dev/null
+++ b/pkg/analyze/json_compare.go
@@ -0,0 +1,112 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"reflect"
+	"strconv"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	iutils "github.com/replicatedhq/troubleshoot/pkg/interfaceutils"
+)
+
+func analyzeJsonCompare(analyzer *troubleshootv1beta2.JsonCompare, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	fullPath := filepath.Join(analyzer.CollectorName, analyzer.FileName)
+	collected, err := getCollectedFileContents(fullPath)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to read collected file name: %s", fullPath)
+	}
+
+	var actual interface{}
+	err = json.Unmarshal(collected, &actual)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse collected data as json")
+	}
+
+	if analyzer.Path != "" {
+		actual, err = iutils.GetAtPath(actual, analyzer.Path)
+		if err != nil {
+			return nil, errors.Wrapf(err, "failed to get object at path: %s", analyzer.Path)
+		}
+	}
+
+	var expected interface{}
+	err = json.Unmarshal([]byte(analyzer.Value), &expected)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse expected value as json")
+	}
+
+	title := analyzer.CheckName
+	if title == "" {
+		title = analyzer.CollectorName
+	}
+
+	result := &AnalyzeResult{
+		Title:   title,
+		IconKey: "kubernetes_text_analyze",
+		IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+	}
+
+	equal := reflect.DeepEqual(actual, expected)
+
+	for _, outcome := range analyzer.Outcomes {
+		if outcome.Fail != nil {
+			when := false
+			if outcome.Fail.When != "" {
+				when, err = strconv.ParseBool(outcome.Fail.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Fail.When)
+				}
+			}
+
+			if when == equal {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return result, nil
+			}
+		} else if outcome.Warn != nil {
+			when := false
+			if outcome.Warn.When != "" {
+				when, err = strconv.ParseBool(outcome.Warn.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Warn.When)
+				}
+			}
+
+			if when == equal {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return result, nil
+			}
+		} else if outcome.Pass != nil {
+			when := true // default to passing when values are equal
+			if outcome.Pass.When != "" {
+				when, err = strconv.ParseBool(outcome.Pass.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Pass.When)
+				}
+			}
+
+			if when == equal {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return result, nil
+			}
+		}
+	}
+
+	return &AnalyzeResult{
+		Title:   title,
+		IconKey: "kubernetes_text_analyze",
+		IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+		IsFail:  true,
+		Message: "Invalid analyzer",
+	}, nil
+}
diff --git a/pkg/analyze/yaml_compare.go b/pkg/analyze/yaml_compare.go
new file mode 100644
index 00000000..9b72affb
--- /dev/null
+++ b/pkg/analyze/yaml_compare.go
@@ -0,0 +1,109 @@
+package analyzer
+
+import (
+	"path/filepath"
+	"reflect"
+	"strconv"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	iutils "github.com/replicatedhq/troubleshoot/pkg/interfaceutils"
+	"gopkg.in/yaml.v2"
+)
+
+func analyzeYamlCompare(analyzer *troubleshootv1beta2.YamlCompare, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	fullPath := filepath.Join(analyzer.CollectorName, analyzer.FileName)
+	collected, err := getCollectedFileContents(fullPath)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to read collected file name: %s", fullPath)
+	}
+
+	var actual interface{}
+	err = yaml.Unmarshal(collected, &actual)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse collected data as yaml doc")
+	}
+
+	if analyzer.Path != "" {
+		actual, err = iutils.GetAtPath(actual, analyzer.Path)
+		if err != nil {
+			return nil, errors.Wrapf(err, "failed to get object at path: %s", analyzer.Path)
+		}
+	}
+
+	var expected interface{}
+	err = yaml.Unmarshal([]byte(analyzer.Value), &expected)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse expected value as yaml doc")
+	}
+
+	title := analyzer.CheckName
+	if title == "" {
+		title = analyzer.CollectorName
+	}
+
+	result := &AnalyzeResult{
+		Title:   title,
+		IconKey: "kubernetes_text_analyze",
+		IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+	}
+
+	equal := reflect.DeepEqual(actual, expected)
+
+	for _, outcome := range analyzer.Outcomes {
+		if outcome.Fail != nil {
+			when := false
+			if outcome.Fail.When != "" {
+				when, err = strconv.ParseBool(outcome.Fail.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Fail.When)
+				}
+			}
+
+			if when == equal {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+				return result, nil
+			}
+		} else if outcome.Warn != nil {
+			when := false
+			if outcome.Warn.When != "" {
+				when, err = strconv.ParseBool(outcome.Warn.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Warn.When)
+				}
+			}
+
+			if when == equal {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+				return result, nil
+			}
+		} else if outcome.Pass != nil {
+			when := true // default to passing when values are equal
+			if outcome.Pass.When != "" {
+				when, err = strconv.ParseBool(outcome.Pass.When)
+				if err != nil {
+					return nil, errors.Wrapf(err, "failed to process when statement: %s", outcome.Pass.When)
+				}
+			}
+
+			if when == equal {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+				return result, nil
+			}
+		}
+	}
+
+	return &AnalyzeResult{
+		Title:   title,
+		IconKey: "kubernetes_text_analyze",
+		IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+		IsFail:  true,
+		Message: "Invalid analyzer",
+	}, nil
+}
diff --git a/pkg/apis/troubleshoot/v1beta2/analyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/analyzer_shared.go
index 21f114fb..9e370ba2 100644
--- a/pkg/apis/troubleshoot/v1beta2/analyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/analyzer_shared.go
@@ -131,6 +131,24 @@ type TextAnalyze struct {
 	Outcomes        []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
 
+type YamlCompare struct {
+	AnalyzeMeta   `json:",inline" yaml:",inline"`
+	CollectorName string     `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
+	FileName      string     `json:"fileName,omitempty" yaml:"fileName,omitempty"`
+	Path          string     `json:"path,omitempty" yaml:"path,omitempty"`
+	Value         string     `json:"value,omitempty" yaml:"value,omitempty"`
+	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
+type JsonCompare struct {
+	AnalyzeMeta   `json:",inline" yaml:",inline"`
+	CollectorName string     `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
+	FileName      string     `json:"fileName,omitempty" yaml:"fileName,omitempty"`
+	Path          string     `json:"path,omitempty" yaml:"path,omitempty"`
+	Value         string     `json:"value,omitempty" yaml:"value,omitempty"`
+	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
 type DatabaseAnalyze struct {
 	AnalyzeMeta   `json:",inline" yaml:",inline"`
 	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
@@ -175,9 +193,10 @@ type SysctlAnalyze struct {
 }
 
 type AnalyzeMeta struct {
-	CheckName string                  `json:"checkName,omitempty" yaml:"checkName,omitempty"`
-	Exclude   *multitype.BoolOrString `json:"exclude,omitempty" yaml:"exclude,omitempty"`
-	Strict    *multitype.BoolOrString `json:"strict,omitempty" yaml:"strict,omitempty"`
+	CheckName   string                  `json:"checkName,omitempty" yaml:"checkName,omitempty"`
+	Exclude     *multitype.BoolOrString `json:"exclude,omitempty" yaml:"exclude,omitempty"`
+	Strict      *multitype.BoolOrString `json:"strict,omitempty" yaml:"strict,omitempty"`
+	Annotations map[string]string       `json:"annotations,omitempty" yaml:"annotations,omitempty"`
 }
 
 type Analyze struct {
@@ -197,6 +216,8 @@ type Analyze struct {
 	Distribution             *Distribution             `json:"distribution,omitempty" yaml:"distribution,omitempty"`
 	NodeResources            *NodeResources            `json:"nodeResources,omitempty" yaml:"nodeResources,omitempty"`
 	TextAnalyze              *TextAnalyze              `json:"textAnalyze,omitempty" yaml:"textAnalyze,omitempty"`
+	YamlCompare              *YamlCompare              `json:"yamlCompare,omitempty" yaml:"yamlCompare,omitempty"`
+	JsonCompare              *JsonCompare              `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"`
 	Postgres                 *DatabaseAnalyze          `json:"postgres,omitempty" yaml:"postgres,omitempty"`
 	Mysql                    *DatabaseAnalyze          `json:"mysql,omitempty" yaml:"mysql,omitempty"`
 	Redis                    *DatabaseAnalyze          `json:"redis,omitempty" yaml:"redis,omitempty"`
diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
index 819435be..0862fdce 100644
--- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
@@ -134,6 +134,16 @@ func (in *Analyze) DeepCopyInto(out *Analyze) {
 		*out = new(TextAnalyze)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.YamlCompare != nil {
+		in, out := &in.YamlCompare, &out.YamlCompare
+		*out = new(YamlCompare)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.JsonCompare != nil {
+		in, out := &in.JsonCompare, &out.JsonCompare
+		*out = new(JsonCompare)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.Postgres != nil {
 		in, out := &in.Postgres, &out.Postgres
 		*out = new(DatabaseAnalyze)
@@ -226,6 +236,13 @@ func (in *AnalyzeMeta) DeepCopyInto(out *AnalyzeMeta) {
 		*out = new(multitype.BoolOrString)
 		**out = **in
 	}
+	if in.Annotations != nil {
+		in, out := &in.Annotations, &out.Annotations
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnalyzeMeta.
@@ -2276,6 +2293,33 @@ func (in *JobStatus) DeepCopy() *JobStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *JsonCompare) DeepCopyInto(out *JsonCompare) {
+	*out = *in
+	in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta)
+	if in.Outcomes != nil {
+		in, out := &in.Outcomes, &out.Outcomes
+		*out = make([]*Outcome, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(Outcome)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JsonCompare.
+func (in *JsonCompare) DeepCopy() *JsonCompare {
+	if in == nil {
+		return nil
+	}
+	out := new(JsonCompare)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *KernelModulesAnalyze) DeepCopyInto(out *KernelModulesAnalyze) {
 	*out = *in
@@ -4079,3 +4123,30 @@ func (in *WeaveReportAnalyze) DeepCopy() *WeaveReportAnalyze {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *YamlCompare) DeepCopyInto(out *YamlCompare) {
+	*out = *in
+	in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta)
+	if in.Outcomes != nil {
+		in, out := &in.Outcomes, &out.Outcomes
+		*out = make([]*Outcome, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(Outcome)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new YamlCompare.
+func (in *YamlCompare) DeepCopy() *YamlCompare {
+	if in == nil {
+		return nil
+	}
+	out := new(YamlCompare)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/pkg/interfaceutils/interfaceutils.go b/pkg/interfaceutils/interfaceutils.go
new file mode 100644
index 00000000..5a4fef09
--- /dev/null
+++ b/pkg/interfaceutils/interfaceutils.go
@@ -0,0 +1,54 @@
+package interfaceutils
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+func GetAtPath(input interface{}, path string) (interface{}, error) {
+	parts := strings.SplitN(path, ".", 2)
+	key := parts[0]
+	if isArrayIndex(key) {
+		i, err := getArrayIndexValue(key)
+		if err != nil {
+			return nil, errors.Wrapf(err, "failed to get index value of %s", key)
+		}
+		obj, ok := input.([]interface{})
+		if !ok {
+			return nil, errors.New(fmt.Sprintf("input is not an array: %+v", input))
+		}
+		input = obj[i]
+	} else {
+		switch t := input.(type) {
+		case map[interface{}]interface{}:
+			input = input.(map[interface{}]interface{})[key]
+		case map[string]interface{}:
+			input = input.(map[string]interface{})[key]
+		default:
+			return nil, errors.New(fmt.Sprintf("input is not a map, but rather a %v: %+v", t, input))
+		}
+	}
+
+	if len(parts) > 1 {
+		return GetAtPath(input, parts[1])
+	}
+
+	return input, nil
+}
+
+func isArrayIndex(key string) bool {
+	return strings.HasPrefix(key, "[") && strings.HasSuffix(key, "]")
+}
+
+func getArrayIndexValue(key string) (int, error) {
+	key = strings.TrimPrefix(key, "[")
+	key = strings.TrimSuffix(key, "]")
+	i, err := strconv.Atoi(key)
+	if err != nil {
+		return -1, errors.Wrapf(err, "failed to parse index %s", key)
+	}
+	return i, nil
+}


Test patch:
diff --git a/pkg/analyze/json_compare_test.go b/pkg/analyze/json_compare_test.go
new file mode 100644
index 00000000..4fd0bd71
--- /dev/null
+++ b/pkg/analyze/json_compare_test.go
@@ -0,0 +1,563 @@
+package analyzer
+
+import (
+	"testing"
+
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_jsonCompare(t *testing.T) {
+	tests := []struct {
+		name         string
+		isError      bool
+		analyzer     troubleshootv1beta2.JsonCompare
+		expectResult AnalyzeResult
+		fileContents []byte
+	}{
+		{
+			name: "basic comparison",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-1",
+				FileName:      "json-compare-1.json",
+				Value: `{
+					"foo": "bar",
+					"stuff": {
+						"foo": "bar",
+						"bar": true
+					},
+					"morestuff": [
+						{
+							"foo": {
+								"bar": 123
+							}
+						}
+					]
+				}`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "json-compare-1",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "basic comparison, but fail on match",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "json-compare-1-1",
+				FileName:      "json-compare-1-1.json",
+				Value: `{
+					"foo": "bar",
+					"stuff": {
+						"foo": "bar",
+						"bar": true
+					},
+					"morestuff": [
+						{
+							"foo": {
+								"bar": 123
+							}
+						}
+					]
+				}`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "json-compare-1-1",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "comparison using path 1",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-2",
+				FileName:      "json-compare-2.json",
+				Path:          "morestuff",
+				Value: `[
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "json-compare-2",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "comparison using path 2",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-3",
+				FileName:      "json-compare-3.json",
+				Path:          "morestuff.[0].foo.bar",
+				Value:         `123`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "json-compare-3",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "comparison using path 2, but warn on match",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Warn: &troubleshootv1beta2.SingleOutcome{
+							Message: "warn",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "json-compare-3-1",
+				FileName:      "json-compare-3-1.json",
+				Path:          "morestuff.[0].foo.bar",
+				Value:         `123`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  true,
+				IsFail:  false,
+				Title:   "json-compare-3-1",
+				Message: "warn",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "basic comparison fail",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-4",
+				FileName:      "json-compare-4.json",
+				Value: `{
+					"foo": "bar",
+					"stuff": {
+						"foo": "bar",
+						"bar": true
+					},
+					"morestuff": [
+						{
+							"foo": {
+								"bar": 123
+							}
+						}
+					]
+				}`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "json-compare-4",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"otherstuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "comparison using path fail 1",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-5",
+				FileName:      "json-compare-5.json",
+				Path:          "morestuff",
+				Value: `[
+					{
+						"foo": {
+							"bar": 321
+						}
+					}
+				]`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "json-compare-5",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "comparison using path, but pass when not matching",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "json-compare-5-1",
+				FileName:      "json-compare-5-1.json",
+				Path:          "morestuff",
+				Value: `[
+					{
+						"foo": {
+							"bar": 321
+						}
+					}
+				]`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "json-compare-5-1",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"morestuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name: "basic comparison warn",
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Warn: &troubleshootv1beta2.SingleOutcome{
+							Message: "warn",
+						},
+					},
+				},
+				CollectorName: "json-compare-6",
+				FileName:      "json-compare-6.json",
+				Value: `{
+					"foo": "bar",
+					"stuff": {
+						"foo": "bar",
+						"bar": true
+					},
+					"morestuff": [
+						{
+							"foo": {
+								"bar": 123
+							}
+						}
+					]
+				}`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  true,
+				IsFail:  false,
+				Title:   "json-compare-6",
+				Message: "warn",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`{
+				"foo": "bar",
+				"stuff": {
+					"foo": "bar",
+					"bar": true
+				},
+				"otherstuff": [
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]
+			}`),
+		},
+		{
+			name:    "invalid json error",
+			isError: true,
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-7",
+				FileName:      "json-compare-7.json",
+				Path:          "morestuff",
+				Value: `[
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]`,
+			},
+			fileContents: []byte(`{ "this: - is-invalid: json }`),
+		},
+		{
+			name:    "no json error",
+			isError: true,
+			analyzer: troubleshootv1beta2.JsonCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "json-compare-8",
+				FileName:      "json-compare-8.json",
+				Path:          "morestuff",
+				Value: `[
+					{
+						"foo": {
+							"bar": 123
+						}
+					}
+				]`,
+			},
+			fileContents: []byte(``),
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+
+			getCollectedFileContents := func(n string) ([]byte, error) {
+				return test.fileContents, nil
+			}
+
+			actual, err := analyzeJsonCompare(&test.analyzer, getCollectedFileContents)
+			if !test.isError {
+				req.NoError(err)
+				req.Equal(test.expectResult, *actual)
+			} else {
+				req.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/analyze/yaml_compare_test.go b/pkg/analyze/yaml_compare_test.go
new file mode 100644
index 00000000..737f4a79
--- /dev/null
+++ b/pkg/analyze/yaml_compare_test.go
@@ -0,0 +1,452 @@
+package analyzer
+
+import (
+	"testing"
+
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_yamlCompare(t *testing.T) {
+	tests := []struct {
+		name         string
+		isError      bool
+		analyzer     troubleshootv1beta2.YamlCompare
+		expectResult AnalyzeResult
+		fileContents []byte
+	}{
+		{
+			name: "basic comparison",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-1",
+				FileName:      "yaml-compare-1.yaml",
+				Value: `foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "yaml-compare-1",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "basic comparison, but fail on match",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-1-1",
+				FileName:      "yaml-compare-1-1.yaml",
+				Value: `foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "yaml-compare-1-1",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "comparison using path 1",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-2",
+				FileName:      "yaml-compare-2.yaml",
+				Path:          "morestuff",
+				Value: `- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "yaml-compare-2",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "comparison using path, but warn when matching",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Warn: &troubleshootv1beta2.SingleOutcome{
+							Message: "warn",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-2-1",
+				FileName:      "yaml-compare-2-1.yaml",
+				Path:          "morestuff",
+				Value: `- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  true,
+				IsFail:  false,
+				Title:   "yaml-compare-2-1",
+				Message: "warn",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "comparison using path 2",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-3",
+				FileName:      "yaml-compare-3.yaml",
+				Path:          "morestuff.[0].foo",
+				Value:         `bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "yaml-compare-3",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "basic comparison fail",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-4",
+				FileName:      "yaml-compare-4.yaml",
+				Value: `foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "yaml-compare-4",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+otherstuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "basic comparison pass when not matching",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+							When:    "false",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+							When:    "true",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-4-1",
+				FileName:      "yaml-compare-4-1.yaml",
+				Value: `foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  true,
+				IsWarn:  false,
+				IsFail:  false,
+				Title:   "yaml-compare-4-1",
+				Message: "pass",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+otherstuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "comparison using path fail 1",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-5",
+				FileName:      "yaml-compare-5.yaml",
+				Path:          "morestuff",
+				Value: `- bar:
+    foo: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  false,
+				IsFail:  true,
+				Title:   "yaml-compare-5",
+				Message: "fail",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name: "basic comparison warn",
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Warn: &troubleshootv1beta2.SingleOutcome{
+							Message: "warn",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-6",
+				FileName:      "yaml-compare-6.yaml",
+				Value: `foo: bar
+stuff:
+  foo: bar
+  bar: foo
+morestuff:
+- foo:
+    bar: baz`,
+			},
+			expectResult: AnalyzeResult{
+				IsPass:  false,
+				IsWarn:  true,
+				IsFail:  false,
+				Title:   "yaml-compare-6",
+				Message: "warn",
+				IconKey: "kubernetes_text_analyze",
+				IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
+			},
+			fileContents: []byte(`foo: bar
+stuff:
+  foo: bar
+  bar: foo
+otherstuff:
+- foo:
+    bar: baz`),
+		},
+		{
+			name:    "invalid yaml error",
+			isError: true,
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-7",
+				FileName:      "yaml-compare-7.yaml",
+				Path:          "morestuff",
+				Value: `- foo:
+    bar: baz`,
+			},
+			fileContents: []byte(`{ "this: - is-invalid: yaml }`),
+		},
+		{
+			name:    "no yaml error",
+			isError: true,
+			analyzer: troubleshootv1beta2.YamlCompare{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "pass",
+						},
+					},
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							Message: "fail",
+						},
+					},
+				},
+				CollectorName: "yaml-compare-8",
+				FileName:      "yaml-compare-8.yaml",
+				Path:          "morestuff",
+				Value: `- foo:
+    bar: baz`,
+			},
+			fileContents: []byte(``),
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+
+			getCollectedFileContents := func(n string) ([]byte, error) {
+				return test.fileContents, nil
+			}
+
+			actual, err := analyzeYamlCompare(&test.analyzer, getCollectedFileContents)
+			if !test.isError {
+				req.NoError(err)
+				req.Equal(test.expectResult, *actual)
+			} else {
+				req.Error(err)
+			}
+		})
+	}
+}