Repository: replicatedhq/troubleshoot
Pull request #127 (2020-01-29 23:18:33)

Node analyzers
This adds a nodeResources analyzer to the preflight and support bundle.

No new input or collector is needed, this uses the nodes.json that's already collected.

```
apiVersion: troubleshoot.replicated.com/v1beta1
kind: Preflight
metadata:
  name: sample
spec:
  analyzers:
    - nodeResources:
        checkName: Must have at least 3 nodes in the cluster
        outcomes:
          - fail:
              when: "< 3"
              message: This application requires at least 3 nodes
          - warn:
              when: "< 5"
              message: This application recommends at last 5 nodes.
          - pass:
              message: This cluster has enough nodes.
    - nodeResources:
        checkName: Must have 3 nodes with at least 6 cores
        filters:
          cpuCapacity: "6"
        outcomes:
          - fail:
              when: "< 3"
              message: This application requires at least 3 nodes with 6 cores each
          - pass:
              message: This cluster has enough nodes with enough codes
    - nodeResources:
        checkName: Must have 1 node with 16 GB (available) memory and 5 cores (on a single node)
        filters:
          allocatableMemory: 16Gi
          cpuCapacity: "5"
        outcomes:
          - fail:
              when: "< 1"
              message: This application requires at least 1 node with 16GB available memory
          - pass:
              message: This cluster has a node with enough memory.
```


Patch:
diff --git a/examples/preflight/node-resources.yaml b/examples/preflight/node-resources.yaml
new file mode 100644
index 00000000..50d7e2cd
--- /dev/null
+++ b/examples/preflight/node-resources.yaml
@@ -0,0 +1,38 @@
+apiVersion: troubleshoot.replicated.com/v1beta1
+kind: Preflight
+metadata:
+  name: sample
+spec:
+  analyzers:
+    - nodeResources:
+        checkName: Must have at least 3 nodes in the cluster
+        outcomes:
+          - fail:
+              when: "< 3"
+              message: This application requires at least 3 nodes
+          - warn:
+              when: "< 5"
+              message: This application recommends at last 5 nodes.
+          - pass:
+              message: This cluster has enough nodes.
+    - nodeResources:
+        checkName: Must have 3 nodes with at least 6 cores
+        filters:
+          cpuCapacity: "6"
+        outcomes:
+          - fail:
+              when: "< 3"
+              message: This application requires at least 3 nodes with 6 cores each
+          - pass:
+              message: This cluster has enough nodes with enough codes
+    - nodeResources:
+        checkName: Must have 1 node with 16 GB (available) memory and 5 cores (on a single node)
+        filters:
+          allocatableMemory: 16Gi
+          cpuCapacity: "5"
+        outcomes:
+          - fail:
+              when: "< 1"
+              message: This application requires at least 1 node with 16GB available memory
+          - pass:
+              message: This cluster has a node with enough memory.
diff --git a/ffi/main.go b/ffi/main.go
index 22fe48f8..21241b41 100644
--- a/ffi/main.go
+++ b/ffi/main.go
@@ -3,18 +3,17 @@ package main
 import "C"
 
 import (
-	"fmt"
 	"encoding/json"
-	
-	"gopkg.in/yaml.v2"
+	"fmt"
+
 	analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
-	"github.com/replicatedhq/troubleshoot/pkg/logger"
 	"github.com/replicatedhq/troubleshoot/pkg/convert"
-
+	"github.com/replicatedhq/troubleshoot/pkg/logger"
+	"gopkg.in/yaml.v2"
 )
 
 //export Analyze
-func Analyze(bundleURL string, analyzers string, outputFormat string, compatibility string) *C.char { 
+func Analyze(bundleURL string, analyzers string, outputFormat string, compatibility string) *C.char {
 	logger.SetQuiet(true)
 
 	result, err := analyzer.DownloadAndAnalyze(bundleURL, analyzers)
diff --git a/go.mod b/go.mod
index 3e2bdadf..89910b37 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,6 @@ require (
 	github.com/hashicorp/go-getter v1.3.1-0.20190627223108-da0323b9545e
 	github.com/hashicorp/go-multierror v1.0.0
 	github.com/imdario/mergo v0.3.7 // indirect
-	github.com/mattn/go-colorable v0.1.2 // indirect
 	github.com/mholt/archiver v3.1.1+incompatible
 	github.com/mitchellh/go-wordwrap v1.0.0 // indirect
 	github.com/nwaples/rardecode v1.0.0 // indirect
@@ -36,7 +35,9 @@ require (
 	k8s.io/apimachinery v0.17.0
 	k8s.io/cli-runtime v0.17.0
 	k8s.io/client-go v0.17.0
+	k8s.io/code-generator v0.16.5-beta.1 // indirect
 	sigs.k8s.io/controller-runtime v0.4.0
+	sigs.k8s.io/controller-tools v0.2.4 // indirect
 )
 
 replace github.com/appscode/jsonpatch => github.com/gomodules/jsonpatch v2.0.1+incompatible
diff --git a/go.sum b/go.sum
index e9536e92..c81535d0 100644
--- a/go.sum
+++ b/go.sum
@@ -163,6 +163,8 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh
 github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=
 github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gobuffalo/flect v0.1.5 h1:xpKq9ap8MbYfhuPCF0dBH854Gp9CxZjr/IocxELFflo=
+github.com/gobuffalo/flect v0.1.5/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
@@ -564,6 +566,7 @@ golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3
 golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac h1:MQEvx39qSf8vyrx3XRaOe+j1UDIzKwkYOVObRgGPVqI=
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -625,6 +628,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966 h1:B0J02caTR6tpSJozBJyiAzT6CtBzjclw4pgm9gg8Ys0=
+gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -646,8 +651,11 @@ k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90/go.mod h1:J69/JveO6XESwVgG53
 k8s.io/client-go v0.17.0 h1:8QOGvUGdqDMFrm9sD6IUFl256BcffynGoe80sxgTEDg=
 k8s.io/client-go v0.17.0/go.mod h1:TYgR6EUHs6k45hb6KWjVD6jFZvJV4gHDikv/It0xz+k=
 k8s.io/code-generator v0.0.0-20190912054826-cd179ad6a269/go.mod h1:V5BD6M4CyaN5m+VthcclXWsVcT1Hu+glwa1bi3MIsyE=
+k8s.io/code-generator v0.16.5-beta.1 h1:+zWxMQH3a6fd8lZe6utWyW/V7nmG2ZMXwtovSJI2p+0=
+k8s.io/code-generator v0.16.5-beta.1/go.mod h1:mJUgkl06XV4kstAnLHAIzJPVCOzVR+ZcfPIv4fUsFCY=
 k8s.io/component-base v0.0.0-20190918160511-547f6c5d7090/go.mod h1:933PBGtQFJky3TEwYx4aEPZ4IxqhWh3R6DCmzqIn1hA=
 k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/gengo v0.0.0-20190822140433-26a664648505 h1:ZY6yclUKVbZ+SdWnkfY+Je5vrMpKOxmGeKRbsXVmqYM=
 k8s.io/gengo v0.0.0-20190822140433-26a664648505/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
@@ -667,6 +675,8 @@ modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs
 modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I=
 sigs.k8s.io/controller-runtime v0.4.0 h1:wATM6/m+3w8lj8FXNaO6Fs/rq/vqoOjO1Q116Z9NPsg=
 sigs.k8s.io/controller-runtime v0.4.0/go.mod h1:ApC79lpY3PHW9xj/w9pj+lYkLgwAAUZwfXkME1Lajns=
+sigs.k8s.io/controller-tools v0.2.4 h1:la1h46EzElvWefWLqfsXrnsO3lZjpkI0asTpX6h8PLA=
+sigs.k8s.io/controller-tools v0.2.4/go.mod h1:m/ztfQNocGYBgTTCmFdnK94uVvgxeZeE3LtJvd/jIzA=
 sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0=
 sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU=
 sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI=
diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go
index 8e219c28..cddfab5f 100644
--- a/pkg/analyze/analyzer.go
+++ b/pkg/analyze/analyzer.go
@@ -79,6 +79,12 @@ func Analyze(analyzer *troubleshootv1beta1.Analyze, getFile getCollectedFileCont
 		}
 		return analyzeDistribution(analyzer.Distribution, getFile)
 	}
+	if analyzer.NodeResources != nil {
+		if analyzer.NodeResources.Exclude {
+			return nil, nil
+		}
+		return analyzeNodeResources(analyzer.NodeResources, getFile)
+	}
 	if analyzer.TextAnalyze != nil {
 		return analyzeTextAnalyze(analyzer.TextAnalyze, getFile)
 	}
diff --git a/pkg/analyze/node_resources.go b/pkg/analyze/node_resources.go
new file mode 100644
index 00000000..f23afa3f
--- /dev/null
+++ b/pkg/analyze/node_resources.go
@@ -0,0 +1,218 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta1 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+func analyzeNodeResources(analyzer *troubleshootv1beta1.NodeResources, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	collected, err := getCollectedFileContents("cluster-resources/nodes.json")
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get contents of nodes.json")
+	}
+
+	var nodes []corev1.Node
+	if err := json.Unmarshal(collected, &nodes); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal node list")
+	}
+
+	matchingNodeCount := 0
+
+	for _, node := range nodes {
+		isMatch, err := nodeMatchesFilters(node, analyzer.Filters)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to check if node matches filter")
+		}
+
+		if isMatch {
+			matchingNodeCount++
+		}
+	}
+
+	title := analyzer.CheckName
+	if title == "" {
+		title = "Node Resources"
+	}
+
+	result := &AnalyzeResult{
+		Title: title,
+	}
+
+	for _, outcome := range analyzer.Outcomes {
+		if outcome.Fail != nil {
+			isWhenMatch, err := compareNodeResourceConditionalToActual(outcome.Fail.When, matchingNodeCount)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to parse when")
+			}
+
+			if isWhenMatch {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return result, nil
+			}
+		} else if outcome.Warn != nil {
+			isWhenMatch, err := compareNodeResourceConditionalToActual(outcome.Warn.When, matchingNodeCount)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to parse when")
+			}
+
+			if isWhenMatch {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return result, nil
+			}
+		} else if outcome.Pass != nil {
+			isWhenMatch, err := compareNodeResourceConditionalToActual(outcome.Pass.When, matchingNodeCount)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to parse when")
+			}
+
+			if isWhenMatch {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return result, nil
+			}
+		}
+	}
+
+	return result, nil
+}
+
+func compareNodeResourceConditionalToActual(conditional string, actual int) (bool, error) {
+	if conditional == "" {
+		return true, nil
+	}
+
+	parts := strings.Split(strings.TrimSpace(conditional), " ")
+
+	if len(parts) != 2 {
+		return false, errors.New("unable to parse nodeResources conditional")
+	}
+
+	operator := parts[0]
+	desiredValue, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return false, errors.Wrap(err, "failed to parse nodeResource value")
+	}
+
+	switch operator {
+	case "=", "==", "===":
+		return desiredValue == actual, nil
+	case "<":
+		return actual < desiredValue, nil
+	case "<=":
+		return actual <= desiredValue, nil
+	case ">":
+		return actual > desiredValue, nil
+	case ">=":
+		return actual >= desiredValue, nil
+	}
+
+	return false, errors.New("unexpected conditional in nodeResources")
+}
+
+func nodeMatchesFilters(node corev1.Node, filters *troubleshootv1beta1.NodeResourceFilters) (bool, error) {
+	if filters == nil {
+		return true, nil
+	}
+
+	// all filters must pass for this to pass
+
+	if filters.CPUCapacity != "" {
+		parsed, err := resource.ParseQuantity(filters.CPUCapacity)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse cpu capacity")
+		}
+
+		if node.Status.Capacity.Cpu().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+	if filters.CPUAllocatable != "" {
+		parsed, err := resource.ParseQuantity(filters.CPUAllocatable)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse cpu allocatable")
+		}
+
+		if node.Status.Allocatable.Cpu().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+
+	if filters.MemoryCapacity != "" {
+		parsed, err := resource.ParseQuantity(filters.MemoryCapacity)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse memory capacity")
+		}
+
+		if node.Status.Capacity.Memory().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+	if filters.MemoryAllocatable != "" {
+		parsed, err := resource.ParseQuantity(filters.MemoryAllocatable)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse memory allocatable")
+		}
+
+		if node.Status.Allocatable.Memory().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+
+	if filters.PodCapacity != "" {
+		parsed, err := resource.ParseQuantity(filters.PodCapacity)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse pod capacity")
+		}
+
+		if node.Status.Capacity.Pods().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+	if filters.PodAllocatable != "" {
+		parsed, err := resource.ParseQuantity(filters.PodAllocatable)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse pod allocatable")
+		}
+
+		if node.Status.Allocatable.Pods().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+
+	if filters.EphemeralStorageCapacity != "" {
+		parsed, err := resource.ParseQuantity(filters.EphemeralStorageCapacity)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse ephemeralstorage capacity")
+		}
+
+		if node.Status.Capacity.StorageEphemeral().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+	if filters.EphemeralStorageAllocatable != "" {
+		parsed, err := resource.ParseQuantity(filters.EphemeralStorageAllocatable)
+		if err != nil {
+			return false, errors.Wrap(err, "failed to parse ephemeralstorage allocatable")
+		}
+
+		if node.Status.Allocatable.StorageEphemeral().Cmp(parsed) == -1 {
+			return false, nil
+		}
+	}
+
+	return true, nil
+}
diff --git a/pkg/apis/troubleshoot/v1beta1/analyzer_shared.go b/pkg/apis/troubleshoot/v1beta1/analyzer_shared.go
index 7ee57364..ab437495 100644
--- a/pkg/apis/troubleshoot/v1beta1/analyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta1/analyzer_shared.go
@@ -74,6 +74,23 @@ type Distribution struct {
 	Outcomes    []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
 
+type NodeResources struct {
+	AnalyzeMeta `json:",inline" yaml:",inline"`
+	Outcomes    []*Outcome           `json:"outcomes" yaml:"outcomes"`
+	Filters     *NodeResourceFilters `json:"filters,omitempty" yaml:"filters,omitempty"`
+}
+
+type NodeResourceFilters struct {
+	CPUCapacity                 string `json:"cpuCapacity,omitempty" yaml:"cpuCapacity,omitempty"`
+	CPUAllocatable              string `json:"cpuAllocatable,omitempty" yaml:"cpuAllocatable,omitempty"`
+	MemoryCapacity              string `json:"memoryCapacity,omitempty" yaml:"memoryCapacity,omitempty"`
+	MemoryAllocatable           string `json:"memoryAllocatable,omitempty" yaml:"memoryAllocatable,omitempty"`
+	PodCapacity                 string `json:"podCapacity,omitempty" yaml:"podCapacity,omitempty"`
+	PodAllocatable              string `json:"podAllocatable,omitempty" yaml:"podAllocatable,omitempty"`
+	EphemeralStorageCapacity    string `json:"ephemeralStorageCapacity,omitempty" yaml:"ephemeralStorageCapacity,omitempty"`
+	EphemeralStorageAllocatable string `json:"ephemeralStorageAllocatable,omitempty" yaml:"ephemeralStorageAllocatable,omitempty"`
+}
+
 type TextAnalyze struct {
 	AnalyzeMeta   `json:",inline" yaml:",inline"`
 	CollectorName string     `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
@@ -99,5 +116,6 @@ type Analyze struct {
 	StatefulsetStatus        *StatefulsetStatus        `json:"statefulsetStatus,omitempty" yaml:"statefulsetStatus,omitempty"`
 	ContainerRuntime         *ContainerRuntime         `json:"containerRuntime,omitempty" yaml:"containerRuntime,omitempty"`
 	Distribution             *Distribution             `json:"distribution,omitempty" yaml:"distribution,omitempty"`
+	NodeResources            *NodeResources            `json:"nodeResources,omitempty" yaml:"nodeResources,omitempty"`
 	TextAnalyze              *TextAnalyze              `json:"textAnalyze,omitempty" yaml:"textAnalyze,omitempty"`
 }
diff --git a/pkg/apis/troubleshoot/v1beta1/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta1/zz_generated.deepcopy.go
index 5123659f..b7af7d4d 100644
--- a/pkg/apis/troubleshoot/v1beta1/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta1/zz_generated.deepcopy.go
@@ -102,6 +102,11 @@ func (in *Analyze) DeepCopyInto(out *Analyze) {
 		*out = new(Distribution)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.NodeResources != nil {
+		in, out := &in.NodeResources, &out.NodeResources
+		*out = new(NodeResources)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.TextAnalyze != nil {
 		in, out := &in.TextAnalyze, &out.TextAnalyze
 		*out = new(TextAnalyze)
@@ -1044,6 +1049,53 @@ func (in *Logs) DeepCopy() *Logs {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NodeResourceFilters) DeepCopyInto(out *NodeResourceFilters) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeResourceFilters.
+func (in *NodeResourceFilters) DeepCopy() *NodeResourceFilters {
+	if in == nil {
+		return nil
+	}
+	out := new(NodeResourceFilters)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NodeResources) DeepCopyInto(out *NodeResources) {
+	*out = *in
+	out.AnalyzeMeta = in.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)
+			}
+		}
+	}
+	if in.Filters != nil {
+		in, out := &in.Filters, &out.Filters
+		*out = new(NodeResourceFilters)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeResources.
+func (in *NodeResources) DeepCopy() *NodeResources {
+	if in == nil {
+		return nil
+	}
+	out := new(NodeResources)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Outcome) DeepCopyInto(out *Outcome) {
 	*out = *in


Test patch:
diff --git a/pkg/analyze/node_resources_test.go b/pkg/analyze/node_resources_test.go
new file mode 100644
index 00000000..9353c271
--- /dev/null
+++ b/pkg/analyze/node_resources_test.go
@@ -0,0 +1,196 @@
+package analyzer
+
+import (
+	"testing"
+
+	troubleshootv1beta1 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta1"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+func Test_compareNodeResourceConditionalToActual(t *testing.T) {
+	tests := []struct {
+		name        string
+		conditional string
+		actual      int
+		expected    bool
+	}{
+		{
+			name:        "=",
+			conditional: "= 5",
+			actual:      5,
+			expected:    true,
+		},
+		{
+			name:        "<= (pass)",
+			conditional: "<= 5",
+			actual:      4,
+			expected:    true,
+		},
+		{
+			name:        "<= (fail)",
+			conditional: "<= 5",
+			actual:      6,
+			expected:    false,
+		},
+		{
+			name:        "> (pass)",
+			conditional: "> 5",
+			actual:      6,
+			expected:    true,
+		},
+		{
+			name:        ">=(fail)",
+			conditional: ">= 5",
+			actual:      4,
+			expected:    false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+
+			actual, err := compareNodeResourceConditionalToActual(test.conditional, test.actual)
+			req.NoError(err)
+
+			assert.Equal(t, test.expected, actual)
+
+		})
+	}
+}
+
+func Test_nodeMatchesFilters(t *testing.T) {
+	tests := []struct {
+		name         string
+		node         corev1.Node
+		filters      *troubleshootv1beta1.NodeResourceFilters
+		expectResult bool
+	}{
+		{
+			name: "true when empty filters",
+			node: corev1.Node{
+				Status: corev1.NodeStatus{
+					Capacity: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("20959212Ki"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7951376Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+					Allocatable: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("19316009748"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7848976Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+				},
+			},
+			filters:      &troubleshootv1beta1.NodeResourceFilters{},
+			expectResult: true,
+		},
+		{
+			name: "true while nil/missing filters",
+			node: corev1.Node{
+				Status: corev1.NodeStatus{
+					Capacity: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("20959212Ki"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7951376Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+					Allocatable: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("19316009748"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7848976Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+				},
+			},
+			expectResult: true,
+		},
+		{
+			name: "false when allocatable memory is too high",
+			node: corev1.Node{
+				Status: corev1.NodeStatus{
+					Capacity: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("20959212Ki"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7951376Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+					Allocatable: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("19316009748"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7848976Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+				},
+			},
+			filters: &troubleshootv1beta1.NodeResourceFilters{
+				MemoryAllocatable: "16Gi",
+			},
+			expectResult: false,
+		},
+		{
+			name: "true when allocatable memory is available",
+			node: corev1.Node{
+				Status: corev1.NodeStatus{
+					Capacity: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("20959212Ki"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7951376Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+					Allocatable: corev1.ResourceList{
+						"attachable-volumes-aws-ebs": resource.MustParse("25"),
+						"cpu":                        resource.MustParse("2"),
+						"ephemeral-storage":          resource.MustParse("19316009748"),
+						"hugepages-1Gi":              resource.MustParse("0"),
+						"hugepages-2Mi":              resource.MustParse("0"),
+						"memory":                     resource.MustParse("7848976Ki"),
+						"pods":                       resource.MustParse("29"),
+					},
+				},
+			},
+			filters: &troubleshootv1beta1.NodeResourceFilters{
+				MemoryAllocatable: "8Gi",
+			},
+			expectResult: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+
+			actual, err := nodeMatchesFilters(test.node, test.filters)
+			req.NoError(err)
+
+			assert.Equal(t, test.expectResult, actual)
+
+		})
+	}
+}