Repository: replicatedhq/troubleshoot
Pull request #506 (2021-12-07 23:41:46)

host system packages collector/analyzer


Patch:
diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml
index 5785c01f..92e57d75 100644
--- a/config/crds/troubleshoot.sh_hostcollectors.yaml
+++ b/config/crds/troubleshoot.sh_hostcollectors.yaml
@@ -548,6 +548,49 @@ spec:
                       required:
                       - outcomes
                       type: object
+                    systemPackages:
+                      properties:
+                        checkName:
+                          type: string
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                        outcomes:
+                          items:
+                            properties:
+                              fail:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                              pass:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                              warn:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                            type: object
+                          type: array
+                      required:
+                      - outcomes
+                      type: object
                     tcpConnect:
                       properties:
                         checkName:
@@ -951,6 +994,89 @@ spec:
                         exclude:
                           type: BoolString
                       type: object
+                    systemPackages:
+                      properties:
+                        amzn:
+                          items:
+                            type: string
+                          type: array
+                        amzn2:
+                          items:
+                            type: string
+                          type: array
+                        centos:
+                          items:
+                            type: string
+                          type: array
+                        centos7:
+                          items:
+                            type: string
+                          type: array
+                        centos8:
+                          items:
+                            type: string
+                          type: array
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                        ol:
+                          items:
+                            type: string
+                          type: array
+                        ol7:
+                          items:
+                            type: string
+                          type: array
+                        ol8:
+                          items:
+                            type: string
+                          type: array
+                        rhel:
+                          items:
+                            type: string
+                          type: array
+                        rhel7:
+                          items:
+                            type: string
+                          type: array
+                        rhel8:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu16:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu18:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu20:
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - amzn
+                      - amzn2
+                      - centos
+                      - centos7
+                      - centos8
+                      - ol
+                      - ol7
+                      - ol8
+                      - rhel
+                      - rhel7
+                      - rhel8
+                      - ubuntu
+                      - ubuntu16
+                      - ubuntu18
+                      - ubuntu20
+                      type: object
                     tcpConnect:
                       properties:
                         address:
diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml
index 7aa83f3f..b7d64d73 100644
--- a/config/crds/troubleshoot.sh_hostpreflights.yaml
+++ b/config/crds/troubleshoot.sh_hostpreflights.yaml
@@ -548,6 +548,49 @@ spec:
                       required:
                       - outcomes
                       type: object
+                    systemPackages:
+                      properties:
+                        checkName:
+                          type: string
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                        outcomes:
+                          items:
+                            properties:
+                              fail:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                              pass:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                              warn:
+                                properties:
+                                  message:
+                                    type: string
+                                  uri:
+                                    type: string
+                                  when:
+                                    type: string
+                                type: object
+                            type: object
+                          type: array
+                      required:
+                      - outcomes
+                      type: object
                     tcpConnect:
                       properties:
                         checkName:
@@ -951,6 +994,89 @@ spec:
                         exclude:
                           type: BoolString
                       type: object
+                    systemPackages:
+                      properties:
+                        amzn:
+                          items:
+                            type: string
+                          type: array
+                        amzn2:
+                          items:
+                            type: string
+                          type: array
+                        centos:
+                          items:
+                            type: string
+                          type: array
+                        centos7:
+                          items:
+                            type: string
+                          type: array
+                        centos8:
+                          items:
+                            type: string
+                          type: array
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                        ol:
+                          items:
+                            type: string
+                          type: array
+                        ol7:
+                          items:
+                            type: string
+                          type: array
+                        ol8:
+                          items:
+                            type: string
+                          type: array
+                        rhel:
+                          items:
+                            type: string
+                          type: array
+                        rhel7:
+                          items:
+                            type: string
+                          type: array
+                        rhel8:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu16:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu18:
+                          items:
+                            type: string
+                          type: array
+                        ubuntu20:
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - amzn
+                      - amzn2
+                      - centos
+                      - centos7
+                      - centos8
+                      - ol
+                      - ol7
+                      - ol8
+                      - rhel
+                      - rhel7
+                      - rhel8
+                      - ubuntu
+                      - ubuntu16
+                      - ubuntu18
+                      - ubuntu20
+                      type: object
                     tcpConnect:
                       properties:
                         address:
@@ -1219,6 +1345,13 @@ spec:
                         exclude:
                           type: BoolString
                       type: object
+                    systemPackages:
+                      properties:
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                      type: object
                     tcpConnect:
                       properties:
                         address:
diff --git a/config/crds/troubleshoot.sh_preflights.yaml b/config/crds/troubleshoot.sh_preflights.yaml
index 6411ed97..6f38415c 100644
--- a/config/crds/troubleshoot.sh_preflights.yaml
+++ b/config/crds/troubleshoot.sh_preflights.yaml
@@ -1780,6 +1780,13 @@ spec:
                         exclude:
                           type: BoolString
                       type: object
+                    systemPackages:
+                      properties:
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                      type: object
                     tcpConnect:
                       properties:
                         address:
diff --git a/config/crds/troubleshoot.sh_remotecollectors.yaml b/config/crds/troubleshoot.sh_remotecollectors.yaml
index 79b91af1..8218f57c 100644
--- a/config/crds/troubleshoot.sh_remotecollectors.yaml
+++ b/config/crds/troubleshoot.sh_remotecollectors.yaml
@@ -284,6 +284,13 @@ spec:
                         exclude:
                           type: BoolString
                       type: object
+                    systemPackages:
+                      properties:
+                        collectorName:
+                          type: string
+                        exclude:
+                          type: BoolString
+                      type: object
                     tcpConnect:
                       properties:
                         address:
diff --git a/examples/preflight/host/system-packages.yaml b/examples/preflight/host/system-packages.yaml
new file mode 100644
index 00000000..fe8e413f
--- /dev/null
+++ b/examples/preflight/host/system-packages.yaml
@@ -0,0 +1,50 @@
+apiVersion: troubleshoot.sh/v1beta2
+kind: HostPreflight
+metadata:
+  name: packages
+spec:
+  collectors:
+    - systemPackages:
+        ubuntu:
+          - open-iscsi
+        ubuntu16:
+          - nfs-common
+        ubuntu18:
+          - nmap
+        ubuntu20:
+          - nmap
+          - nfs-common
+        rhel:
+          - iscsi-initiator-utils
+        rhel7:
+          - libzstd
+        rhel8:
+          - nfs-utils
+          - openssl
+        centos:
+          - iscsi-initiator-utils
+        centos7:
+          - libzstd
+        centos8:
+          - nfs-utils
+          - openssl
+        ol:
+          - lvm2
+        ol7:
+          - libzstd
+          - openssl
+        ol8:
+          - nfs-utils
+        amzn:
+          - libzstd
+        amzn2:
+          - nfs-utils
+          - openssl
+  analyzers:
+    - systemPackages:
+        outcomes:
+          - fail:
+              when: "{{ not .IsInstalled }}"
+              message: Package {{ .Name }} is not installed.
+          - pass:
+              message: Package {{ .Name }} is installed.
diff --git a/go.mod b/go.mod
index 9ab472bc..b22834c0 100644
--- a/go.mod
+++ b/go.mod
@@ -182,6 +182,7 @@ require (
 	k8s.io/klog/v2 v2.9.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c // indirect
 	k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a // indirect
+	periph.io/x/periph v3.6.8+incompatible // indirect
 	sigs.k8s.io/kustomize/api v0.8.11 // indirect
 	sigs.k8s.io/kustomize/kyaml v0.11.0 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect
diff --git a/go.sum b/go.sum
index 231b98a5..5ebcf33d 100644
--- a/go.sum
+++ b/go.sum
@@ -1654,6 +1654,8 @@ k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk=
 k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a h1:8dYfu/Fc9Gz2rNJKB9IQRGgQOh2clmRzNIPPY1xLY5g=
 k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+periph.io/x/periph v3.6.8+incompatible h1:lki0ie6wHtvlilXhIkabdCUQMpb5QN4Fx33yNQdqnaA=
+periph.io/x/periph v3.6.8+incompatible/go.mod h1:EWr+FCIU2dBWz5/wSWeiIUJTriYv9v2j2ENBmgYyy7Y=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/pkg/analyze/cluster_pod_statuses.go b/pkg/analyze/cluster_pod_statuses.go
index 29eb5cef..a8d49f10 100644
--- a/pkg/analyze/cluster_pod_statuses.go
+++ b/pkg/analyze/cluster_pod_statuses.go
@@ -66,13 +66,13 @@ func clusterPodStatuses(analyzer *troubleshootv1beta2.ClusterPodStatuses, getChi
 				r.URI = outcome.Pass.URI
 				when = outcome.Pass.When
 			} else {
-				fmt.Println("error: found an empty outcome in a clusterPodStatuses analyzer") // don't stop
+				println("error: found an empty outcome in a clusterPodStatuses analyzer") // don't stop
 				continue
 			}
 
 			parts := strings.Split(strings.TrimSpace(when), " ")
 			if len(parts) < 2 {
-				fmt.Printf("invalid 'when' format: %s\n", when) // don't stop
+				println(fmt.Sprintf("invalid 'when' format: %s\n", when)) // don't stop
 				continue
 			}
 
diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go
index 952ccc0c..6b04b153 100644
--- a/pkg/analyze/host_analyzer.go
+++ b/pkg/analyze/host_analyzer.go
@@ -28,6 +28,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b
 		return &AnalyzeHostTime{analyzer.Time}, true
 	case analyzer.BlockDevices != nil:
 		return &AnalyzeHostBlockDevices{analyzer.BlockDevices}, true
+	case analyzer.SystemPackages != nil:
+		return &AnalyzeHostSystemPackages{analyzer.SystemPackages}, true
 	case analyzer.KernelModules != nil:
 		return &AnalyzeHostKernelModules{analyzer.KernelModules}, true
 	case analyzer.TCPConnect != nil:
diff --git a/pkg/analyze/host_system_packages.go b/pkg/analyze/host_system_packages.go
new file mode 100644
index 00000000..c9b75561
--- /dev/null
+++ b/pkg/analyze/host_system_packages.go
@@ -0,0 +1,231 @@
+package analyzer
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+	"unicode"
+
+	"text/template"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+)
+
+type AnalyzeHostSystemPackages struct {
+	hostAnalyzer *troubleshootv1beta2.SystemPackagesAnalyze
+}
+
+func (a *AnalyzeHostSystemPackages) Title() string {
+	return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "System Packages")
+}
+
+func (a *AnalyzeHostSystemPackages) IsExcluded() (bool, error) {
+	return isExcluded(a.hostAnalyzer.Exclude)
+}
+
+func (a *AnalyzeHostSystemPackages) Analyze(getCollectedFileContents func(string) ([]byte, error)) ([]*AnalyzeResult, error) {
+	hostAnalyzer := a.hostAnalyzer
+
+	packagesFileName := "system/packages.json"
+	if a.hostAnalyzer.CollectorName != "" {
+		packagesFileName = fmt.Sprintf("system/%s-packages.json", a.hostAnalyzer.CollectorName)
+	}
+
+	contents, err := getCollectedFileContents(packagesFileName)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get collected file")
+	}
+
+	var info collect.SystemPackagesInfo
+	if err := json.Unmarshal(contents, &info); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal system packages info")
+	}
+
+	allResults := []*AnalyzeResult{}
+
+	for _, pkg := range info.Packages {
+		templateMap := getSystemPackageTemplateMap(pkg, info.OS, info.OSVersion)
+
+		for _, outcome := range hostAnalyzer.Outcomes {
+			r := AnalyzeResult{}
+			when := ""
+
+			if outcome.Fail != nil {
+				r.IsFail = true
+				r.Message = outcome.Fail.Message
+				r.URI = outcome.Fail.URI
+				when = outcome.Fail.When
+			} else if outcome.Warn != nil {
+				r.IsWarn = true
+				r.Message = outcome.Warn.Message
+				r.URI = outcome.Warn.URI
+				when = outcome.Warn.When
+			} else if outcome.Pass != nil {
+				r.IsPass = true
+				r.Message = outcome.Pass.Message
+				r.URI = outcome.Pass.URI
+				when = outcome.Pass.When
+			} else {
+				println("error: found an empty outcome in a systemPackages analyzer") // don't stop
+				continue
+			}
+
+			match, err := compareSystemPackagesConditionalToActual(when, templateMap)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", when)
+			}
+
+			if !match {
+				continue
+			}
+
+			tmpl := template.New("package")
+
+			// template the title
+			titleTmpl, err := tmpl.Parse(a.Title())
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to create new title template")
+			}
+			var t bytes.Buffer
+			err = titleTmpl.Execute(&t, templateMap)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to execute title template")
+			}
+			r.Title = t.String()
+
+			// template the message
+			msgTmpl, err := tmpl.Parse(r.Message)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to create new message template")
+			}
+			var m bytes.Buffer
+			err = msgTmpl.Execute(&m, templateMap)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to execute message template")
+			}
+			r.Message = m.String()
+
+			// add to results, break and check the next pod
+			allResults = append(allResults, &r)
+			break
+		}
+	}
+
+	return allResults, nil
+}
+
+func getSystemPackageTemplateMap(pkg collect.SystemPackage, osName string, osVersion string) map[string]interface{} {
+	osVersionParts := strings.Split(osVersion, ".")
+	osVersionMajor, _ := strconv.ParseInt(osVersionParts[0], 10, 64)
+
+	var osVersionMinor int64 = -1
+	if len(osVersionParts) > 1 {
+		osVersionMinor, _ = strconv.ParseInt(osVersionParts[1], 10, 64)
+	}
+
+	m := map[string]interface{}{
+		"OS":             osName,
+		"OSVersion":      osVersion,
+		"OSVersionMajor": osVersionMajor,
+		"OSVersionMinor": osVersionMinor,
+		"Name":           pkg.Name,
+		"Error":          pkg.Error,
+		"ExitCode":       pkg.ExitCode,
+		"IsInstalled":    isSystemPackageInstalled(pkg),
+	}
+
+	for k, v := range getSystemPackageDetailsMap(pkg) {
+		m[k] = v
+	}
+
+	return m
+}
+
+func isSystemPackageInstalled(pkg collect.SystemPackage) bool {
+	if pkg.Error != "" {
+		return false
+	}
+	if pkg.ExitCode != "0" {
+		return false
+	}
+	if strings.Contains(pkg.Details, "not installed") {
+		return false
+	}
+	if strings.Contains(pkg.Details, "No matching Packages") {
+		return false
+	}
+	return true
+}
+
+func getSystemPackageDetailsMap(pkg collect.SystemPackage) map[string]string {
+	// TODO: handle multiline values
+	m := map[string]string{}
+
+	buffer := bytes.NewBuffer([]byte(pkg.Details))
+	scanner := bufio.NewScanner(buffer)
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		parts := strings.SplitN(line, ":", 2)
+
+		if len(parts) != 2 {
+			continue
+		}
+
+		key := parts[0]
+
+		// key should start with an upper case
+		if !unicode.IsUpper([]rune(key)[0]) {
+			continue
+		}
+
+		// key shouldn't start with a space
+		if unicode.IsSpace([]rune(key)[0]) {
+			continue
+		}
+
+		// sanitize the key and value
+		key = strings.ReplaceAll(key, " ", "")
+		key = strings.ReplaceAll(key, "-", "")
+		value := strings.TrimSpace(parts[1])
+
+		if key == "" || value == "" {
+			continue
+		}
+
+		m[key] = value
+	}
+
+	return m
+}
+
+func compareSystemPackagesConditionalToActual(conditional string, templateMap map[string]interface{}) (res bool, err error) {
+	if conditional == "" {
+		return true, nil
+	}
+
+	tmpl := template.New("conditional")
+
+	conditionalTmpl, err := tmpl.Parse(conditional)
+	if err != nil {
+		return false, errors.Wrap(err, "failed to create new when template")
+	}
+
+	var when bytes.Buffer
+	err = conditionalTmpl.Execute(&when, templateMap)
+	if err != nil {
+		return false, errors.Wrap(err, "failed to execute when template")
+	}
+
+	t, err := strconv.ParseBool(when.String())
+	if err != nil {
+		return false, errors.Wrap(err, "failed to parse templated when expression as a boolean")
+	}
+
+	return t, nil
+}
diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
index 73fe5dd0..c15e132e 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
@@ -51,6 +51,13 @@ type BlockDevicesAnalyze struct {
 	IncludeUnmountedPartitions bool       `json:"includeUnmountedPartitions" yaml:"includeUnmountedPartitions"`
 	Outcomes                   []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
+
+type SystemPackagesAnalyze struct {
+	AnalyzeMeta   `json:",inline" yaml:",inline"`
+	CollectorName string     `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
+	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
 type KernelModulesAnalyze struct {
 	AnalyzeMeta `json:",inline" yaml:",inline"`
 	Outcomes    []*Outcome `json:"outcomes" yaml:"outcomes"`
@@ -105,6 +112,8 @@ type HostAnalyze struct {
 
 	BlockDevices *BlockDevicesAnalyze `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"`
 
+	SystemPackages *SystemPackagesAnalyze `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"`
+
 	KernelModules *KernelModulesAnalyze `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"`
 
 	TCPConnect *TCPConnectAnalyze `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"`
diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
index 0a31776b..707c216d 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
@@ -67,6 +67,25 @@ type HostBlockDevices struct {
 	HostCollectorMeta `json:",inline" yaml:",inline"`
 }
 
+type HostSystemPackages struct {
+	HostCollectorMeta `json:",inline" yaml:",inline"`
+	Ubuntu            []string `json:"ubuntu"`
+	Ubuntu16          []string `json:"ubuntu16"`
+	Ubuntu18          []string `json:"ubuntu18"`
+	Ubuntu20          []string `json:"ubuntu20"`
+	RHEL              []string `json:"rhel"`
+	RHEL7             []string `json:"rhel7"`
+	RHEL8             []string `json:"rhel8"`
+	CentOS            []string `json:"centos"`
+	CentOS7           []string `json:"centos7"`
+	CentOS8           []string `json:"centos8"`
+	OracleLinux       []string `json:"ol"`
+	OracleLinux7      []string `json:"ol7"`
+	OracleLinux8      []string `json:"ol8"`
+	AmazonLinux       []string `json:"amzn"`
+	AmazonLinux2      []string `json:"amzn2"`
+}
+
 type HostKernelModules struct {
 	HostCollectorMeta `json:",inline" yaml:",inline"`
 }
@@ -143,6 +162,7 @@ type HostCollect struct {
 	HTTP                  *HostHTTP              `json:"http,omitempty" yaml:"http,omitempty"`
 	Time                  *HostTime              `json:"time,omitempty" yaml:"time,omitempty"`
 	BlockDevices          *HostBlockDevices      `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"`
+	SystemPackages        *HostSystemPackages    `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"`
 	KernelModules         *HostKernelModules     `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"`
 	TCPConnect            *TCPConnect            `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"`
 	FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"`
diff --git a/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go b/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go
index 9b40ce25..b4449424 100644
--- a/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/remote_collector_shared.go
@@ -70,6 +70,10 @@ type RemoteBlockDevices struct {
 	RemoteCollectorMeta `json:",inline" yaml:",inline"`
 }
 
+type RemoteSystemPackages struct {
+	RemoteCollectorMeta `json:",inline" yaml:",inline"`
+}
+
 type RemoteKernelModules struct {
 	RemoteCollectorMeta `json:",inline" yaml:",inline"`
 }
@@ -142,6 +146,7 @@ type RemoteCollect struct {
 	HTTP                  *RemoteHTTP                  `json:"http,omitempty" yaml:"http,omitempty"`
 	Time                  *RemoteTime                  `json:"time,omitempty" yaml:"time,omitempty"`
 	BlockDevices          *RemoteBlockDevices          `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"`
+	SystemPackages        *RemoteSystemPackages        `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"`
 	KernelModules         *RemoteKernelModules         `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"`
 	TCPConnect            *RemoteTCPConnect            `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"`
 	FilesystemPerformance *RemoteFilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"`
diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
index bb3b97d9..cfff118c 100644
--- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
@@ -1430,6 +1430,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
 		*out = new(BlockDevicesAnalyze)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.SystemPackages != nil {
+		in, out := &in.SystemPackages, &out.SystemPackages
+		*out = new(SystemPackagesAnalyze)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.KernelModules != nil {
 		in, out := &in.KernelModules, &out.KernelModules
 		*out = new(KernelModulesAnalyze)
@@ -1551,6 +1556,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) {
 		*out = new(HostBlockDevices)
 		**out = **in
 	}
+	if in.SystemPackages != nil {
+		in, out := &in.SystemPackages, &out.SystemPackages
+		*out = new(HostSystemPackages)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.KernelModules != nil {
 		in, out := &in.KernelModules, &out.KernelModules
 		*out = new(HostKernelModules)
@@ -1975,6 +1985,97 @@ func (in *HostServicesAnalyze) DeepCopy() *HostServicesAnalyze {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostSystemPackages) DeepCopyInto(out *HostSystemPackages) {
+	*out = *in
+	out.HostCollectorMeta = in.HostCollectorMeta
+	if in.Ubuntu != nil {
+		in, out := &in.Ubuntu, &out.Ubuntu
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Ubuntu16 != nil {
+		in, out := &in.Ubuntu16, &out.Ubuntu16
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Ubuntu18 != nil {
+		in, out := &in.Ubuntu18, &out.Ubuntu18
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Ubuntu20 != nil {
+		in, out := &in.Ubuntu20, &out.Ubuntu20
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.RHEL != nil {
+		in, out := &in.RHEL, &out.RHEL
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.RHEL7 != nil {
+		in, out := &in.RHEL7, &out.RHEL7
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.RHEL8 != nil {
+		in, out := &in.RHEL8, &out.RHEL8
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.CentOS != nil {
+		in, out := &in.CentOS, &out.CentOS
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.CentOS7 != nil {
+		in, out := &in.CentOS7, &out.CentOS7
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.CentOS8 != nil {
+		in, out := &in.CentOS8, &out.CentOS8
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.OracleLinux != nil {
+		in, out := &in.OracleLinux, &out.OracleLinux
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.OracleLinux7 != nil {
+		in, out := &in.OracleLinux7, &out.OracleLinux7
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.OracleLinux8 != nil {
+		in, out := &in.OracleLinux8, &out.OracleLinux8
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AmazonLinux != nil {
+		in, out := &in.AmazonLinux, &out.AmazonLinux
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.AmazonLinux2 != nil {
+		in, out := &in.AmazonLinux2, &out.AmazonLinux2
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostSystemPackages.
+func (in *HostSystemPackages) DeepCopy() *HostSystemPackages {
+	if in == nil {
+		return nil
+	}
+	out := new(HostSystemPackages)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HostTime) DeepCopyInto(out *HostTime) {
 	*out = *in
@@ -2869,6 +2970,11 @@ func (in *RemoteCollect) DeepCopyInto(out *RemoteCollect) {
 		*out = new(RemoteBlockDevices)
 		**out = **in
 	}
+	if in.SystemPackages != nil {
+		in, out := &in.SystemPackages, &out.SystemPackages
+		*out = new(RemoteSystemPackages)
+		**out = **in
+	}
 	if in.KernelModules != nil {
 		in, out := &in.KernelModules, &out.KernelModules
 		*out = new(RemoteKernelModules)
@@ -3184,6 +3290,22 @@ func (in *RemoteServices) DeepCopy() *RemoteServices {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RemoteSystemPackages) DeepCopyInto(out *RemoteSystemPackages) {
+	*out = *in
+	out.RemoteCollectorMeta = in.RemoteCollectorMeta
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteSystemPackages.
+func (in *RemoteSystemPackages) DeepCopy() *RemoteSystemPackages {
+	if in == nil {
+		return nil
+	}
+	out := new(RemoteSystemPackages)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *RemoteTCPConnect) DeepCopyInto(out *RemoteTCPConnect) {
 	*out = *in
@@ -3647,6 +3769,33 @@ func (in *SysctlAnalyze) DeepCopy() *SysctlAnalyze {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SystemPackagesAnalyze) DeepCopyInto(out *SystemPackagesAnalyze) {
+	*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)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemPackagesAnalyze.
+func (in *SystemPackagesAnalyze) DeepCopy() *SystemPackagesAnalyze {
+	if in == nil {
+		return nil
+	}
+	out := new(SystemPackagesAnalyze)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *TCPConnect) DeepCopyInto(out *TCPConnect) {
 	*out = *in
diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go
index d5d12d0a..77409741 100644
--- a/pkg/collect/host_collector.go
+++ b/pkg/collect/host_collector.go
@@ -30,6 +30,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect) (HostCollector
 		return &CollectHostTime{collector.Time}, true
 	case collector.BlockDevices != nil:
 		return &CollectHostBlockDevices{collector.BlockDevices}, true
+	case collector.SystemPackages != nil:
+		return &CollectHostSystemPackages{collector.SystemPackages}, true
 	case collector.KernelModules != nil:
 		return &CollectHostKernelModules{
 			hostCollector: collector.KernelModules,
diff --git a/pkg/collect/host_system_package.go b/pkg/collect/host_system_package.go
new file mode 100644
index 00000000..fbdb850c
--- /dev/null
+++ b/pkg/collect/host_system_package.go
@@ -0,0 +1,183 @@
+package collect
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os/exec"
+	"strings"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"periph.io/x/periph/host/distro"
+)
+
+type SystemPackagesInfo struct {
+	OS        string          `json:"os"`
+	OSVersion string          `json:"osVersion"`
+	Packages  []SystemPackage `json:"packages"`
+}
+
+type SystemPackage struct {
+	Name     string `json:"name"`
+	Details  string `json:"details"`
+	ExitCode string `json:"exitCode"`
+	Error    string `json:"error,omitempty"`
+}
+
+type CollectHostSystemPackages struct {
+	hostCollector *troubleshootv1beta2.HostSystemPackages
+}
+
+func (c *CollectHostSystemPackages) Title() string {
+	return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "System Packages")
+}
+
+func (c *CollectHostSystemPackages) IsExcluded() (bool, error) {
+	return isExcluded(c.hostCollector.Exclude)
+}
+
+func (c *CollectHostSystemPackages) Collect(progressChan chan<- interface{}) (map[string][]byte, error) {
+	info := SystemPackagesInfo{}
+
+	osReleaseMap := distro.OSRelease()
+	info.OS = osReleaseMap["ID"]
+	info.OSVersion = osReleaseMap["VERSION_ID"]
+
+	if info.OS == "" {
+		// special case for Amazon 2014.03
+		b, err := ioutil.ReadFile("/etc/system-release")
+		if err == nil && bytes.Contains(b, []byte("Amazon Linux")) {
+			info.OS = "amzn"
+			v, err := exec.Command("awk", "/Amazon Linux/{print $NF}", "/etc/system-release").Output()
+			if err == nil {
+				info.OSVersion = string(v)
+			}
+		}
+	}
+
+	if info.OS == "" {
+		return nil, errors.New("distribution id could not be detected or is unsupported.")
+	}
+	if info.OSVersion == "" {
+		return nil, errors.New("distribution version could not be detected or is unsupported.")
+	}
+
+	packages := []string{}
+
+	switch info.OS {
+	case "ubuntu":
+		if len(c.hostCollector.Ubuntu) > 0 {
+			packages = append(packages, c.hostCollector.Ubuntu...)
+		}
+		if len(c.hostCollector.Ubuntu16) > 0 && matchMajorVersion(info.OSVersion, "16") {
+			packages = append(packages, c.hostCollector.Ubuntu16...)
+		}
+		if len(c.hostCollector.Ubuntu18) > 0 && matchMajorVersion(info.OSVersion, "18") {
+			packages = append(packages, c.hostCollector.Ubuntu18...)
+		}
+		if len(c.hostCollector.Ubuntu20) > 0 && matchMajorVersion(info.OSVersion, "20") {
+			packages = append(packages, c.hostCollector.Ubuntu20...)
+		}
+	case "centos":
+		if len(c.hostCollector.CentOS) > 0 {
+			packages = append(packages, c.hostCollector.CentOS...)
+		}
+		if len(c.hostCollector.CentOS7) > 0 && matchMajorVersion(info.OSVersion, "7") {
+			packages = append(packages, c.hostCollector.CentOS7...)
+		}
+		if len(c.hostCollector.CentOS8) > 0 && matchMajorVersion(info.OSVersion, "8") {
+			packages = append(packages, c.hostCollector.CentOS8...)
+		}
+	case "rhel":
+		if len(c.hostCollector.RHEL) > 0 {
+			packages = append(packages, c.hostCollector.RHEL...)
+		}
+		if len(c.hostCollector.RHEL7) > 0 && matchMajorVersion(info.OSVersion, "7") {
+			packages = append(packages, c.hostCollector.RHEL7...)
+		}
+		if len(c.hostCollector.RHEL8) > 0 && matchMajorVersion(info.OSVersion, "8") {
+			packages = append(packages, c.hostCollector.RHEL8...)
+		}
+	case "ol":
+		if len(c.hostCollector.OracleLinux) > 0 {
+			packages = append(packages, c.hostCollector.OracleLinux...)
+		}
+		if len(c.hostCollector.OracleLinux7) > 0 && matchMajorVersion(info.OSVersion, "7") {
+			packages = append(packages, c.hostCollector.OracleLinux7...)
+		}
+		if len(c.hostCollector.OracleLinux8) > 0 && matchMajorVersion(info.OSVersion, "8") {
+			packages = append(packages, c.hostCollector.OracleLinux8...)
+		}
+	case "amzn":
+		if len(c.hostCollector.AmazonLinux) > 0 {
+			packages = append(packages, c.hostCollector.AmazonLinux...)
+		}
+		if len(c.hostCollector.AmazonLinux2) > 0 && matchMajorVersion(info.OSVersion, "2") {
+			packages = append(packages, c.hostCollector.AmazonLinux2...)
+		}
+	default:
+		return nil, errors.Errorf("unsupported distribution: %s", info.OS)
+	}
+
+	for _, p := range packages {
+		sysPkg := SystemPackage{
+			Name: p,
+		}
+
+		var cmd *exec.Cmd
+		switch info.OS {
+		case "ubuntu":
+			cmd = exec.Command("dpkg", "-s", p)
+		case "centos", "rhel", "amzn", "ol":
+			cmd = exec.Command("rpm", "-qi", p)
+		default:
+			return nil, errors.Errorf("unsupported distribution: %s", info.OS)
+		}
+
+		var stdout, stderr bytes.Buffer
+		cmd.Stdout = &stdout
+		cmd.Stderr = &stderr
+
+		err := cmd.Run()
+		if err != nil {
+			if werr, ok := err.(*exec.ExitError); ok {
+				sysPkg.ExitCode = strings.TrimPrefix(werr.Error(), "exit status ")
+			} else {
+				return nil, errors.Wrap(err, "failed to run")
+			}
+		} else {
+			sysPkg.ExitCode = "0"
+		}
+
+		sysPkg.Details = stdout.String()
+		sysPkg.Error = stderr.String()
+
+		info.Packages = append(info.Packages, sysPkg)
+	}
+
+	b, err := json.Marshal(info)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to marshal system packages info")
+	}
+
+	outputFileName := "system/packages.json"
+	if c.hostCollector.CollectorName != "" {
+		outputFileName = fmt.Sprintf("system/%s-packages.json", c.hostCollector.CollectorName)
+	}
+
+	return map[string][]byte{
+		outputFileName: b,
+	}, nil
+}
+
+func matchMajorVersion(version string, major string) bool {
+	if version == major {
+		return true
+	}
+	if strings.HasPrefix(version, fmt.Sprintf("%s.", major)) {
+		return true
+	}
+	return false
+}
diff --git a/pkg/collect/remote_collector.go b/pkg/collect/remote_collector.go
index 00a141c2..128f9c29 100644
--- a/pkg/collect/remote_collector.go
+++ b/pkg/collect/remote_collector.go
@@ -229,6 +229,13 @@ func (c *RemoteCollector) toHostCollector() (*troubleshootv1beta2.HostCollect, e
 				Exclude:       c.Collect.BlockDevices.Exclude,
 			},
 		}
+	case c.Collect.SystemPackages != nil:
+		hostCollect.SystemPackages = &troubleshootv1beta2.HostSystemPackages{
+			HostCollectorMeta: troubleshootv1beta2.HostCollectorMeta{
+				CollectorName: c.Collect.SystemPackages.CollectorName,
+				Exclude:       c.Collect.SystemPackages.Exclude,
+			},
+		}
 	case c.Collect.KernelModules != nil:
 		hostCollect.KernelModules = &troubleshootv1beta2.HostKernelModules{
 			HostCollectorMeta: troubleshootv1beta2.HostCollectorMeta{
diff --git a/schemas/preflight-troubleshoot-v1beta2.json b/schemas/preflight-troubleshoot-v1beta2.json
index 58ba4d4c..98a3037d 100644
--- a/schemas/preflight-troubleshoot-v1beta2.json
+++ b/schemas/preflight-troubleshoot-v1beta2.json
@@ -2646,6 +2646,17 @@
                   }
                 }
               },
+              "systemPackages": {
+                "type": "object",
+                "properties": {
+                  "collectorName": {
+                    "type": "string"
+                  },
+                  "exclude": {
+                    "oneOf": [{"type": "string"},{"type": "boolean"}]
+                  }
+                }
+              },
               "tcpConnect": {
                 "type": "object",
                 "required": [


Test patch:
diff --git a/.github/workflows/build-test-deploy.yaml b/.github/workflows/build-test-deploy.yaml
index 1d620adf..b540450a 100644
--- a/.github/workflows/build-test-deploy.yaml
+++ b/.github/workflows/build-test-deploy.yaml
@@ -1,10 +1,10 @@
 on:
   pull_request:
     branches:
-      - master
+      - main
   push:
     branches:
-      - "master"
+      - "main"
     tags:
       - "v*.*.*"
   
diff --git a/pkg/analyze/host_system_packages_test.go b/pkg/analyze/host_system_packages_test.go
new file mode 100644
index 00000000..3f139074
--- /dev/null
+++ b/pkg/analyze/host_system_packages_test.go
@@ -0,0 +1,120 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAnalyzeSystemPackages(t *testing.T) {
+	tests := []struct {
+		name         string
+		info         collect.SystemPackagesInfo
+		hostAnalyzer *troubleshootv1beta2.SystemPackagesAnalyze
+		result       []*AnalyzeResult
+		expectErr    bool
+	}{
+		{
+			name: "basic",
+			info: collect.SystemPackagesInfo{
+				OS:        "ubuntu",
+				OSVersion: "18.04",
+				Packages: []collect.SystemPackage{
+					{
+						Name:     "libzstd",
+						Details:  "installed",
+						ExitCode: "0",
+						Error:    "",
+					},
+					{
+						Name:     "nfs-common",
+						Details:  "not installed",
+						ExitCode: "1",
+						Error:    "package 'nfs-common' is not installed and no information is available",
+					},
+					{
+						Name:     "iscsi-initiator-utils",
+						Details:  "whatever",
+						ExitCode: "1",
+						Error:    "whatever",
+					},
+					{
+						Name:     "open-iscsi",
+						Details:  "No matching Packages for 'open-iscsi'",
+						ExitCode: "0",
+						Error:    "whatever",
+					},
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.SystemPackagesAnalyze{
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							When:    "{{ not .IsInstalled }}",
+							Message: "Package {{ .Name }} is not installed.",
+						},
+					},
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							Message: "Package {{ .Name }} is installed.",
+						},
+					},
+				},
+			},
+			result: []*AnalyzeResult{
+				{
+					Title:   "System Packages",
+					Message: "Package libzstd is installed.",
+					IsPass:  true,
+				},
+				{
+					Title:   "System Packages",
+					Message: "Package nfs-common is not installed.",
+					IsFail:  true,
+				},
+				{
+					Title:   "System Packages",
+					Message: "Package iscsi-initiator-utils is not installed.",
+					IsFail:  true,
+				},
+				{
+					Title:   "System Packages",
+					Message: "Package open-iscsi is not installed.",
+					IsFail:  true,
+				},
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+			b, err := json.Marshal(test.info)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			getCollectedFileContents := func(filename string) ([]byte, error) {
+				return b, nil
+			}
+
+			result, err := (&AnalyzeHostSystemPackages{test.hostAnalyzer}).Analyze(getCollectedFileContents)
+			if test.expectErr {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+			}
+
+			for _, v := range result {
+				c, _ := json.Marshal(v)
+				fmt.Println(string(c))
+			}
+
+			assert.Equal(t, test.result, result)
+		})
+	}
+}