Repository: replicatedhq/troubleshoot
Pull request #959 (2023-01-12 13:54:40)

Upgrade dependencies
## Description, Motivation and Context

Some more dependency upgrades that dependabot did not, and can't pick up.

Tests ran
- Generated before and after support bundles on a kurl cluster using the [time](https://troubleshoot.sh/docs/host-collect-analyze/time/) and [registry images](https://troubleshoot.sh/docs/analyze/registry-images/) collectors which behaved as expected
- semver module has sufficient automated tests

<!--- If it relates to an open issue, please link to the issue here.
e.g.
Fixes: #876
-->

Fixes: #876

## Checklist

- [x] New and existing tests pass locally with introduced changes.
- [ ] Tests for the changes have been added (for bug fixes / features)
- [x] The commit message(s) are informative and highlight any breaking changes
- [ ] Any documentation required has been added/updated. For changes to https://troubleshoot.sh/ create a PR [here](https://github.com/replicatedhq/troubleshoot.sh/pulls)

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->



Patch:
diff --git a/Makefile b/Makefile
index e34dc3a2..5f8952da 100644
--- a/Makefile
+++ b/Makefile
@@ -45,6 +45,12 @@ ffi: fmt vet
 test: generate fmt vet
 	go test ${BUILDFLAGS} ./pkg/... ./cmd/... -coverprofile cover.out
 
+# Go tests that require a K8s instance
+# TODOLATER: merge with test, so we get unified coverage reports? it'll add 21~sec to the test job though...
+.PHONY: test-integration
+test-integration:
+	go test -v --tags "integration exclude_graphdriver_devicemapper exclude_graphdriver_btrfs" ./pkg/... ./cmd/...
+
 .PHONY: preflight-e2e-test
 preflight-e2e-test:
 	./test/validate-preflight-e2e.sh
diff --git a/go.mod b/go.mod
index 6cf13b90..dfeb2fb7 100644
--- a/go.mod
+++ b/go.mod
@@ -4,15 +4,15 @@ go 1.19
 
 require (
 	github.com/ahmetalpbalkan/go-cursor v0.0.0-20131010032410-8136607ea412
-	github.com/blang/semver v3.5.1+incompatible
+	github.com/blang/semver/v4 v4.0.0
 	github.com/containers/image/v5 v5.23.1
-	github.com/docker/distribution v2.8.1+incompatible
+	github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2
 	github.com/fatih/color v1.13.0
 	github.com/go-logr/logr v1.2.3
 	github.com/go-redis/redis/v7 v7.4.1
 	github.com/go-sql-driver/mysql v1.7.0
 	github.com/gobwas/glob v0.2.3
-	github.com/godbus/dbus v4.1.0+incompatible
+	github.com/godbus/dbus/v5 v5.1.0
 	github.com/google/gofuzz v1.2.0
 	github.com/google/uuid v1.3.0
 	github.com/gorilla/handlers v1.5.1
@@ -50,6 +50,7 @@ require (
 require (
 	cloud.google.com/go/compute/metadata v0.2.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
+	github.com/docker/distribution v2.8.1+incompatible // indirect
 	github.com/emicklei/go-restful/v3 v3.9.0 // indirect
 	github.com/evanphx/json-patch/v5 v5.6.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index b5402354..81f40d61 100644
--- a/go.sum
+++ b/go.sum
@@ -147,8 +147,9 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
 github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
 github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
 github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
-github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
@@ -333,6 +334,7 @@ github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc=
+github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v20.10.21+incompatible h1:qVkgyYUnOLQ98LtXBrwd/duVqPT2X4SHndOuGsfwyhU=
@@ -458,11 +460,11 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
 github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
 github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
 github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
-github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4=
-github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU=
 github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
diff --git a/pkg/analyze/cluster_version.go b/pkg/analyze/cluster_version.go
index 8a9c8974..988c7e31 100644
--- a/pkg/analyze/cluster_version.go
+++ b/pkg/analyze/cluster_version.go
@@ -4,7 +4,7 @@ import (
 	"encoding/json"
 	"strings"
 
-	"github.com/blang/semver"
+	"github.com/blang/semver/v4"
 	"github.com/pkg/errors"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/replicatedhq/troubleshoot/pkg/collect"
diff --git a/pkg/analyze/database_shared.go b/pkg/analyze/database_shared.go
index 55fe8f5d..9cbd6b03 100644
--- a/pkg/analyze/database_shared.go
+++ b/pkg/analyze/database_shared.go
@@ -5,7 +5,7 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/blang/semver"
+	"github.com/blang/semver/v4"
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/troubleshoot/pkg/collect"
 )
diff --git a/pkg/analyze/host_os_info.go b/pkg/analyze/host_os_info.go
index fed480d3..48edaaed 100644
--- a/pkg/analyze/host_os_info.go
+++ b/pkg/analyze/host_os_info.go
@@ -6,7 +6,7 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/blang/semver"
+	"github.com/blang/semver/v4"
 	"github.com/pkg/errors"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/replicatedhq/troubleshoot/pkg/collect"
diff --git a/pkg/collect/host_time.go b/pkg/collect/host_time.go
index ce16da3d..304d33f1 100644
--- a/pkg/collect/host_time.go
+++ b/pkg/collect/host_time.go
@@ -7,7 +7,7 @@ import (
 	"log"
 	"strings"
 
-	"github.com/godbus/dbus"
+	"github.com/godbus/dbus/v5"
 	"github.com/pkg/errors"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 )
diff --git a/pkg/collect/registry.go b/pkg/collect/registry.go
index 43409b43..1f4d7254 100644
--- a/pkg/collect/registry.go
+++ b/pkg/collect/registry.go
@@ -13,8 +13,8 @@ import (
 	dockerref "github.com/containers/image/v5/docker/reference"
 	"github.com/containers/image/v5/transports/alltransports"
 	"github.com/containers/image/v5/types"
-	"github.com/docker/distribution/registry/api/errcode"
-	registryv2 "github.com/docker/distribution/registry/api/v2"
+	"github.com/distribution/distribution/v3/registry/api/errcode"
+	registryv2 "github.com/distribution/distribution/v3/registry/api/v2"
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
diff --git a/pkg/preflight/interactive_results.go b/pkg/preflight/interactive_results.go
index ac2c3604..6e6c82e5 100644
--- a/pkg/preflight/interactive_results.go
+++ b/pkg/preflight/interactive_results.go
@@ -2,8 +2,6 @@ package preflight
 
 import (
 	"fmt"
-	"io/ioutil"
-	"os"
 	"time"
 
 	"github.com/pkg/errors"
@@ -11,7 +9,6 @@ import (
 	"github.com/replicatedhq/termui/v3/widgets"
 	"github.com/replicatedhq/troubleshoot/cmd/util"
 	analyzerunner "github.com/replicatedhq/troubleshoot/pkg/analyze"
-	"github.com/replicatedhq/troubleshoot/pkg/convert"
 )
 
 var (
@@ -44,7 +41,7 @@ func showInteractiveResults(preflightName string, outputPath string, analyzeResu
 					return nil
 				}
 			case "s":
-				filename, err := save(preflightName, outputPath, analyzeResults)
+				filename, err := outputToFile(preflightName, outputPath, analyzeResults)
 				if err != nil {
 					// show
 				} else {
@@ -220,60 +217,6 @@ func estimateNumberOfLines(text string, width int) int {
 	return lines
 }
 
-func save(preflightName string, outputPath string, analyzeResults []*analyzerunner.AnalyzeResult) (string, error) {
-	filename := ""
-	if outputPath != "" {
-		// use override output path
-		overridePath, err := convert.ValidateOutputPath(outputPath)
-		if err != nil {
-			return "", errors.Wrap(err, "override output file path")
-		}
-		filename = overridePath
-	} else {
-		// use default output path
-		filename = fmt.Sprintf("%s-results-%s.txt", preflightName, time.Now().Format("2006-01-02T15_04_05"))
-	}
-
-	_, err := os.Stat(filename)
-	if err == nil {
-		os.Remove(filename)
-	}
-
-	results := fmt.Sprintf("%s Preflight Checks\n\n", util.AppName(preflightName))
-	for _, analyzeResult := range analyzeResults {
-		result := ""
-
-		if analyzeResult.IsPass {
-			result = "Check PASS\n"
-		} else if analyzeResult.IsWarn {
-			result = "Check WARN\n"
-		} else if analyzeResult.IsFail {
-			result = "Check FAIL\n"
-		}
-
-		result = result + fmt.Sprintf("Title: %s\n", analyzeResult.Title)
-		result = result + fmt.Sprintf("Message: %s\n", analyzeResult.Message)
-
-		if analyzeResult.URI != "" {
-			result = result + fmt.Sprintf("URI: %s\n", analyzeResult.URI)
-		}
-
-		if analyzeResult.Strict {
-			result = result + fmt.Sprintf("Strict: %t\n", analyzeResult.Strict)
-		}
-
-		result = result + "\n------------\n"
-
-		results = results + result
-	}
-
-	if err := ioutil.WriteFile(filename, []byte(results), 0644); err != nil {
-		return "", errors.Wrap(err, "failed to save preflight results")
-	}
-
-	return filename, nil
-}
-
 func showSaved(filename string) {
 	termWidth, termHeight := ui.TerminalDimensions()
 
diff --git a/pkg/preflight/output_to_file.go b/pkg/preflight/output_to_file.go
new file mode 100644
index 00000000..e879995c
--- /dev/null
+++ b/pkg/preflight/output_to_file.go
@@ -0,0 +1,67 @@
+package preflight
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"time"
+
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/troubleshoot/cmd/util"
+	analyzerunner "github.com/replicatedhq/troubleshoot/pkg/analyze"
+	"github.com/replicatedhq/troubleshoot/pkg/convert"
+)
+
+func outputToFile(preflightName string, outputPath string, analyzeResults []*analyzerunner.AnalyzeResult) (string, error) {
+	filename := ""
+	if outputPath != "" {
+		// use override output path
+		overridePath, err := convert.ValidateOutputPath(outputPath)
+		if err != nil {
+			return "", errors.Wrap(err, "override output file path")
+		}
+		filename = overridePath
+	} else {
+		// use default output path
+		filename = fmt.Sprintf("%s-results-%s.txt", preflightName, time.Now().Format("2006-01-02T15_04_05"))
+	}
+
+	_, err := os.Stat(filename)
+	if err == nil {
+		os.Remove(filename)
+	}
+
+	results := fmt.Sprintf("%s Preflight Checks\n\n", util.AppName(preflightName))
+	for _, analyzeResult := range analyzeResults {
+		result := ""
+
+		if analyzeResult.IsPass {
+			result = "Check PASS\n"
+		} else if analyzeResult.IsWarn {
+			result = "Check WARN\n"
+		} else if analyzeResult.IsFail {
+			result = "Check FAIL\n"
+		}
+
+		result = result + fmt.Sprintf("Title: %s\n", analyzeResult.Title)
+		result = result + fmt.Sprintf("Message: %s\n", analyzeResult.Message)
+
+		if analyzeResult.URI != "" {
+			result = result + fmt.Sprintf("URI: %s\n", analyzeResult.URI)
+		}
+
+		if analyzeResult.Strict {
+			result = result + fmt.Sprintf("Strict: %t\n", analyzeResult.Strict)
+		}
+
+		result = result + "\n------------\n"
+
+		results = results + result
+	}
+
+	if err := ioutil.WriteFile(filename, []byte(results), 0644); err != nil {
+		return "", errors.Wrap(err, "failed to save preflight results")
+	}
+
+	return filename, nil
+}
diff --git a/pkg/preflight/run.go b/pkg/preflight/run.go
index 64841ed6..40f3e0c9 100644
--- a/pkg/preflight/run.go
+++ b/pkg/preflight/run.go
@@ -236,7 +236,7 @@ func RunPreflights(interactive bool, output string, format string, args []string
 		return showInteractiveResults(preflightSpecName, output, analyzeResults)
 	}
 
-	return showStdoutResults(format, preflightSpecName, analyzeResults)
+	return showTextResults(format, preflightSpecName, output, analyzeResults)
 }
 
 func collectInteractiveProgress(ctx context.Context, progressCh <-chan interface{}) func() error {
diff --git a/pkg/preflight/stdout_results.go b/pkg/preflight/stdout_results.go
deleted file mode 100644
index d509ebae..00000000
--- a/pkg/preflight/stdout_results.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package preflight
-
-import (
-	"encoding/json"
-	"fmt"
-
-	"github.com/pkg/errors"
-	analyzerunner "github.com/replicatedhq/troubleshoot/pkg/analyze"
-	"gopkg.in/yaml.v2"
-)
-
-func showStdoutResults(format string, preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) error {
-	if format == "human" {
-		return showStdoutResultsHuman(preflightName, analyzeResults)
-	} else if format == "json" {
-		return showStdoutResultsJSON(preflightName, analyzeResults)
-	} else if format == "yaml" {
-		return showStdoutResultsYAML(preflightName, analyzeResults)
-	}
-
-	return errors.Errorf("unknown output format: %q", format)
-}
-
-func showStdoutResultsHuman(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) error {
-	fmt.Println("")
-	var failed bool
-	for _, analyzeResult := range analyzeResults {
-		testResultfailed := outputResult(analyzeResult)
-		if testResultfailed {
-			failed = true
-		}
-	}
-	if failed {
-		fmt.Printf("--- FAIL   %s\n", preflightName)
-		fmt.Println("FAILED")
-	} else {
-		fmt.Printf("--- PASS   %s\n", preflightName)
-		fmt.Println("PASS")
-	}
-	return nil
-}
-
-type stdoutResultOutput struct {
-	Title   string `json:"title" yaml:"title"`
-	Message string `json:"message" yaml:"message"`
-	URI     string `json:"uri,omitempty" yaml:"uri,omitempty"`
-	Strict  bool   `json:"strict,omitempty" yaml:"strict,omitempty"`
-}
-
-type stdoutOutput struct {
-	Pass []stdoutResultOutput `json:"pass,omitempty" yaml:"pass,omitempty"`
-	Warn []stdoutResultOutput `json:"warn,omitempty" yaml:"warn,omitempty"`
-	Fail []stdoutResultOutput `json:"fail,omitempty" yaml:"fail,omitempty"`
-}
-
-// Used by both JSON and YAML outputs
-func showStdoutResultsStructured(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) *stdoutOutput {
-	output := stdoutOutput{
-		Pass: []stdoutResultOutput{},
-		Warn: []stdoutResultOutput{},
-		Fail: []stdoutResultOutput{},
-	}
-
-	for _, analyzeResult := range analyzeResults {
-		resultOutput := stdoutResultOutput{
-			Title:   analyzeResult.Title,
-			Message: analyzeResult.Message,
-			URI:     analyzeResult.URI,
-		}
-
-		if analyzeResult.Strict {
-			resultOutput.Strict = analyzeResult.Strict
-		}
-
-		if analyzeResult.IsPass {
-			output.Pass = append(output.Pass, resultOutput)
-		} else if analyzeResult.IsWarn {
-			output.Warn = append(output.Warn, resultOutput)
-		} else if analyzeResult.IsFail {
-			output.Fail = append(output.Fail, resultOutput)
-		}
-	}
-
-	return &output
-}
-
-func showStdoutResultsJSON(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) error {
-	output := showStdoutResultsStructured(preflightName, analyzeResults)
-
-	b, err := json.MarshalIndent(*output, "", "  ")
-	if err != nil {
-		return errors.Wrap(err, "failed to marshal results as json")
-	}
-
-	fmt.Printf("%s\n", b)
-
-	return nil
-}
-
-func showStdoutResultsYAML(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) error {
-	output := showStdoutResultsStructured(preflightName, analyzeResults)
-
-	b, err := yaml.Marshal(*output)
-	if err != nil {
-		return errors.Wrap(err, "failed to marshal results as yaml")
-	}
-
-	fmt.Printf("%s\n", b)
-
-	return nil
-}
-
-func outputResult(analyzeResult *analyzerunner.AnalyzeResult) bool {
-	if analyzeResult.IsPass {
-		fmt.Printf("   --- PASS %s\n", analyzeResult.Title)
-		fmt.Printf("      --- %s\n", analyzeResult.Message)
-	} else if analyzeResult.IsWarn {
-		fmt.Printf("   --- WARN: %s\n", analyzeResult.Title)
-		fmt.Printf("      --- %s\n", analyzeResult.Message)
-	} else if analyzeResult.IsFail {
-		fmt.Printf("   --- FAIL: %s\n", analyzeResult.Title)
-		fmt.Printf("      --- %s\n", analyzeResult.Message)
-	}
-
-	if analyzeResult.Strict {
-		fmt.Printf("      --- Strict: %t\n", analyzeResult.Strict)
-	}
-
-	if analyzeResult.IsFail {
-		return true
-	}
-	return false
-}
diff --git a/pkg/preflight/text_results.go b/pkg/preflight/text_results.go
new file mode 100644
index 00000000..b8cf7956
--- /dev/null
+++ b/pkg/preflight/text_results.go
@@ -0,0 +1,153 @@
+package preflight
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/pkg/errors"
+	analyzerunner "github.com/replicatedhq/troubleshoot/pkg/analyze"
+	"gopkg.in/yaml.v2"
+)
+
+// Text results can go to stdout or to an output file
+
+func showTextResults(format string, preflightName string, outputPath string, analyzeResults []*analyzerunner.AnalyzeResult) error {
+	results := ""
+	var err error
+	if format == "human" {
+		results, err = showTextResultsHuman(preflightName, analyzeResults)
+	} else if format == "json" {
+		results, err = showTextResultsJSON(preflightName, analyzeResults)
+	} else if format == "yaml" {
+		results, err = showTextResultsYAML(preflightName, analyzeResults)
+	} else {
+		return errors.Errorf("unknown output format: %q", format)
+	}
+	if err != nil {
+		return err
+	}
+
+	if outputPath != "" {
+		// Write to output file
+		resultsBytes := []byte(results)
+		err := os.WriteFile(outputPath, resultsBytes, 0644)
+		if err != nil {
+			return err
+		}
+		fmt.Printf("Output written to '%s'\n", outputPath)
+		return nil
+	} else {
+		// Print to stdout
+		fmt.Printf("%s", results)
+		return nil
+	}
+}
+
+func showTextResultsHuman(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) (string, error) {
+	results := fmt.Sprintln("")
+	var failed bool
+	for _, analyzeResult := range analyzeResults {
+		testResultfailed := false
+		results, testResultfailed = outputResult(results, analyzeResult)
+		if testResultfailed {
+			failed = true
+		}
+	}
+	if failed {
+		results = fmt.Sprintf("%s--- FAIL   %s\n", results, preflightName)
+		results = fmt.Sprintf("%sFAILED\n", results)
+	} else {
+		results = fmt.Sprintf("%s--- PASS   %s\n", results, preflightName)
+		results = fmt.Sprintf("%sPASS\n", results)
+	}
+	return results, nil
+}
+
+type textResultOutput struct {
+	Title   string `json:"title" yaml:"title"`
+	Message string `json:"message" yaml:"message"`
+	URI     string `json:"uri,omitempty" yaml:"uri,omitempty"`
+	Strict  bool   `json:"strict,omitempty" yaml:"strict,omitempty"`
+}
+
+type textOutput struct {
+	Pass []textResultOutput `json:"pass,omitempty" yaml:"pass,omitempty"`
+	Warn []textResultOutput `json:"warn,omitempty" yaml:"warn,omitempty"`
+	Fail []textResultOutput `json:"fail,omitempty" yaml:"fail,omitempty"`
+}
+
+// Used by both JSON and YAML outputs
+func showTextResultsStructured(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) *textOutput {
+	output := textOutput{
+		Pass: []textResultOutput{},
+		Warn: []textResultOutput{},
+		Fail: []textResultOutput{},
+	}
+
+	for _, analyzeResult := range analyzeResults {
+		resultOutput := textResultOutput{
+			Title:   analyzeResult.Title,
+			Message: analyzeResult.Message,
+			URI:     analyzeResult.URI,
+		}
+
+		if analyzeResult.Strict {
+			resultOutput.Strict = analyzeResult.Strict
+		}
+
+		if analyzeResult.IsPass {
+			output.Pass = append(output.Pass, resultOutput)
+		} else if analyzeResult.IsWarn {
+			output.Warn = append(output.Warn, resultOutput)
+		} else if analyzeResult.IsFail {
+			output.Fail = append(output.Fail, resultOutput)
+		}
+	}
+
+	return &output
+}
+
+func showTextResultsJSON(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) (string, error) {
+	output := showTextResultsStructured(preflightName, analyzeResults)
+
+	b, err := json.MarshalIndent(*output, "", "  ")
+	if err != nil {
+		return "", errors.Wrap(err, "failed to marshal results as json")
+	}
+
+	return fmt.Sprintf("%s\n", b), nil
+}
+
+func showTextResultsYAML(preflightName string, analyzeResults []*analyzerunner.AnalyzeResult) (string, error) {
+	output := showTextResultsStructured(preflightName, analyzeResults)
+
+	b, err := yaml.Marshal(*output)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to marshal results as yaml")
+	}
+
+	return fmt.Sprintf("%s\n", b), nil
+}
+
+func outputResult(results string, analyzeResult *analyzerunner.AnalyzeResult) (string, bool) {
+	if analyzeResult.IsPass {
+		results = fmt.Sprintf("%s   --- PASS %s\n", results, analyzeResult.Title)
+		results = fmt.Sprintf("%s      --- %s\n", results, analyzeResult.Message)
+	} else if analyzeResult.IsWarn {
+		results = fmt.Sprintf("%s   --- WARN: %s\n", results, analyzeResult.Title)
+		results = fmt.Sprintf("%s      --- %s\n", results, analyzeResult.Message)
+	} else if analyzeResult.IsFail {
+		results = fmt.Sprintf("%s   --- FAIL: %s\n", results, analyzeResult.Title)
+		results = fmt.Sprintf("%s      --- %s\n", results, analyzeResult.Message)
+	}
+
+	if analyzeResult.Strict {
+		results = fmt.Sprintf("%s      --- Strict: %t\n", results, analyzeResult.Strict)
+	}
+
+	if analyzeResult.IsFail {
+		return results, true
+	}
+	return results, false
+}


Test patch:
diff --git a/.github/workflows/build-test-deploy.yaml b/.github/workflows/build-test-deploy.yaml
index 9581bb5e..af8d5e48 100644
--- a/.github/workflows/build-test-deploy.yaml
+++ b/.github/workflows/build-test-deploy.yaml
@@ -30,6 +30,27 @@ jobs:
 
       - run: make test
 
+  test-integration:
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/setup-go@v3
+        with:
+          go-version: "1.19"
+
+      - name: setup env
+        run: |
+          echo "GOPATH=$(go env GOPATH)" >> $GITHUB_ENV
+          echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
+        shell: bash
+
+      - uses: actions/checkout@v3
+      - uses: replicatedhq/action-k3s@main
+        id: k3s
+        with:
+          version: v1.23.6-k3s1
+
+      - run: make test-integration
+
   ensure-schemas-are-generated:
     runs-on: ubuntu-latest
     steps:
diff --git a/internal/testutils/utils.go b/internal/testutils/utils.go
index cbb0a0f9..0b071dd7 100644
--- a/internal/testutils/utils.go
+++ b/internal/testutils/utils.go
@@ -1,6 +1,9 @@
 package testutils
 
 import (
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -22,3 +25,10 @@ func FileDir() string {
 	_, filename, _, _ := runtime.Caller(0)
 	return filepath.Dir(filename)
 }
+
+// Generates a temporary filename
+func TempFilename(prefix string) string {
+	randBytes := make([]byte, 16)
+	rand.Read(randBytes)
+	return filepath.Join(os.TempDir(), fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(randBytes)))
+}
diff --git a/pkg/analyze/cluster_version_test.go b/pkg/analyze/cluster_version_test.go
index a77d10e1..597da255 100644
--- a/pkg/analyze/cluster_version_test.go
+++ b/pkg/analyze/cluster_version_test.go
@@ -4,7 +4,7 @@ import (
 	"reflect"
 	"testing"
 
-	"github.com/blang/semver"
+	"github.com/blang/semver/v4"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 )
 
diff --git a/pkg/preflight/run_test.go b/pkg/preflight/run_test.go
new file mode 100644
index 00000000..0c8062d0
--- /dev/null
+++ b/pkg/preflight/run_test.go
@@ -0,0 +1,216 @@
+//go:build integration
+
+// NOTE: requires a Kubernetes cluster in place currently, hence hidden behind a tag above
+// TODOLATER: get a mocked or ephemeral/Docker based K8s for use? see below some approaches which haven't played out yet
+// Test using: go test --tags=integration
+
+package preflight
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	/*
+		See TODOLATER below
+
+		"k8s.io/client-go/kubernetes/fake"
+		k8sruntime "k8s.io/apimachinery/pkg/runtime"
+		discoveryfake "k8s.io/client-go/discovery/fake"
+	*/)
+
+func TestRunPreflights(t *testing.T) {
+	t.Parallel()
+
+	// A very simple preflight spec (local file)
+	preflightFile := filepath.Join(testutils.FileDir(), "../../testdata/preflightspec/troubleshoot_v1beta2_preflight_gotest.yaml")
+
+	wantOutputContentHuman := `
+   --- PASS Compare JSON Example
+      --- The collected data matches the value.
+--- PASS   go-test-preflight
+PASS
+`
+
+	wantOutputContentJson := `{
+  "pass": [
+    {
+      "title": "Compare JSON Example",
+      "message": "The collected data matches the value."
+    }
+  ]
+}
+`
+
+	wantOutputContentYaml := `pass:
+- title: Compare JSON Example
+  message: The collected data matches the value.
+
+`
+
+	tests := []struct {
+		name        string
+		interactive bool
+		output      string
+		format      string
+		args        []string
+		//
+		wantErr bool
+		// May be in stdout or file, depending on above value
+		wantOutputContent string
+	}{
+		// TODOLATER: test interactive true as well
+		{
+			name:              "noninteractive-stdout-human",
+			interactive:       false,
+			output:            "",
+			format:            "human",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentHuman,
+		},
+		{
+			name:              "noninteractive-stdout-json",
+			interactive:       false,
+			output:            "",
+			format:            "json",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentJson,
+		},
+		{
+			name:              "noninteractive-stdout-yaml",
+			interactive:       false,
+			output:            "",
+			format:            "yaml",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentYaml,
+		},
+		{
+			name:              "noninteractive-tofile-human",
+			interactive:       false,
+			output:            testutils.TempFilename("preflight_out_test_"),
+			format:            "human",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentHuman,
+		},
+		{
+			name:              "noninteractive-tofile-json",
+			interactive:       false,
+			output:            testutils.TempFilename("preflight_out_test_"),
+			format:            "json",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentJson,
+		},
+		{
+			name:              "noninteractive-tofile-yaml",
+			interactive:       false,
+			output:            testutils.TempFilename("preflight_out_test_"),
+			format:            "yaml",
+			args:              []string{preflightFile},
+			wantErr:           false,
+			wantOutputContent: wantOutputContentYaml,
+		},
+	}
+
+	// Use a fake/mocked K8s API, since some collectors are mandatory and need an API server to hit
+	// TODOLATER: for this to work, we need to refactor all of the collectors and analyzers to allow passing in a fake clientset?
+	// ...or we need to find a way to expose the fake clientset with a mocked API server and a respective kubeconfig for use
+	// Using gnomock k3s for now instead (requires Docker for test execution)
+	/*
+		k8sObjects := []k8sruntime.Object{
+			// TODO: populate with things that mandatory collectors need
+		}
+		k8sApi := fake.NewSimpleClientset(k8sObjects...)
+		k8sApi.Discovery().(*discoveryfake.FakeDiscovery).FakedServerVersion = &version.Info{
+			Major: "1",
+			Minor: "26",
+		}
+		fmt.Printf("%+v\n", k8sApi)
+	*/
+
+	// A K3s instance in Docker for testing, for mandatory collectors
+	// NOTE: only has amd64 images, doesn't work on arm64 (MacOS)? need to build and specify custom images for it
+	// ...plus there's no new images since 2020~?
+	/*
+		p := k3s.Preset(k3s.WithVersion("v1.19.12"))
+		c, err := gnomock.Start(
+			p,
+			gnomock.WithContainerName("k3s"),
+			gnomock.WithDebugMode(),
+		)
+		if err != nil {
+			t.Fatal(err)
+		}
+		kubeconfig, err := k3s.Config(c)
+		if err != nil {
+			t.Fatal(err)
+		}
+		fmt.Printf("kubeconfig: %+v\n", kubeconfig)
+	*/
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Capture stdout/stderr along the way
+			rOut, wOut, err := os.Pipe()
+			require.Nil(t, err)
+			rErr, wErr, err := os.Pipe()
+			require.Nil(t, err)
+			// Redirect stdout/err to the pipes temporarily
+			origStdout := os.Stdout
+			os.Stdout = wOut
+			origStderr := os.Stderr
+			os.Stderr = wErr
+
+			tErr := RunPreflights(tt.interactive, tt.output, tt.format, tt.args)
+
+			// Stop redirection of stdout/stderr
+			bufOut := make([]byte, 1024)
+			nOut, err := rOut.Read(bufOut)
+			require.Nil(t, err)
+			bufErr := make([]byte, 1024)
+			// nErr
+			_, err = rErr.Read(bufErr)
+			require.Nil(t, err)
+			os.Stdout = origStdout
+			os.Stderr = origStderr
+
+			if tt.wantErr {
+				assert.Error(t, tErr)
+			} else {
+				require.NoError(t, tErr)
+			}
+
+			useBufOut := string(bufOut[:nOut])
+			//useBufErr := string(bufErr[:nErr])
+			//fmt.Printf("stdout: %+v\n", useBufOut)
+			//fmt.Printf("stderr: %+v\n", useBufErr)
+
+			if tt.output != "" {
+				// Output file is expected, make sure it exists
+				assert.FileExists(t, tt.output)
+				// If it exists, check contents of output file against expected
+				readOutputFile, err := os.ReadFile(tt.output)
+				require.NoError(t, err)
+				assert.Equal(t, string(readOutputFile), tt.wantOutputContent)
+			} else {
+				// Expected no output file, make sure it doesn't exist
+				assert.NoFileExists(t, tt.output)
+				// Check stdout against expected output
+				assert.Equal(t, useBufOut, tt.wantOutputContent)
+			}
+
+			// Remove the (temp) output file if it exists
+			if _, err := os.Stat(tt.output); err == nil {
+				err = os.Remove(tt.output)
+				require.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/testdata/preflightspec/troubleshoot_v1beta2_preflight_gotest.yaml b/testdata/preflightspec/troubleshoot_v1beta2_preflight_gotest.yaml
new file mode 100644
index 00000000..eb93c53a
--- /dev/null
+++ b/testdata/preflightspec/troubleshoot_v1beta2_preflight_gotest.yaml
@@ -0,0 +1,39 @@
+apiVersion: troubleshoot.sh/v1beta2
+kind: Preflight
+metadata:
+  name: go-test-preflight
+spec:
+  # TODO: mock a local HTTP server to send this to
+  #uploadResultsTo: http://someurl
+  collectors:
+    - data:
+        name: example.json
+        data: |
+          {
+            "foo": "bar",
+            "stuff": {
+              "foo": "bar",
+              "bar": true
+            },
+            "morestuff": [
+              {
+                "foo": {
+                  "bar": 123
+                }
+              }
+            ]
+          }
+  analyzers:
+    - jsonCompare:
+        checkName: Compare JSON Example
+        fileName: example.json
+        path: "morestuff.[0].foo.bar"
+        value: |
+          123
+        outcomes:
+          - fail:
+              when: "false"
+              message: The collected data does not match the value.
+          - pass:
+              when: "true"
+              message: The collected data matches the value.