Integration Test improvements (#613)

- Save Solr Operator logs for each test (filtered to only the applicable lines for that test)
- Print the log directory on failure
- Include the KUBERNETES_VERSION on failure-retry scripts.
diff --git a/.gitignore b/.gitignore
index bf3ff90..54bb603 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,9 @@
 # Directory to test generated files
 generated-check
 
+# Integration test outputs
+tests/**/output
+
 # Python for the release wizard
 venv
 __pycache__
diff --git a/go.mod b/go.mod
index eef7232..6193a4c 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/stretchr/testify v1.8.2
 	golang.org/x/net v0.14.0
+	golang.org/x/text v0.12.0
 	helm.sh/helm/v3 v3.11.1
 	k8s.io/api v0.27.2
 	k8s.io/apimachinery v0.27.2
@@ -124,7 +125,6 @@
 	golang.org/x/sync v0.3.0 // indirect
 	golang.org/x/sys v0.11.0 // indirect
 	golang.org/x/term v0.11.0 // indirect
-	golang.org/x/text v0.12.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
 	golang.org/x/tools v0.12.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect
diff --git a/tests/e2e/suite_test.go b/tests/e2e/suite_test.go
index 770965b..40ebaed 100644
--- a/tests/e2e/suite_test.go
+++ b/tests/e2e/suite_test.go
@@ -18,6 +18,8 @@
 package e2e
 
 import (
+	"bufio"
+	"bytes"
 	"context"
 	"fmt"
 	solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
@@ -25,14 +27,23 @@
 	"github.com/go-logr/logr"
 	"github.com/onsi/ginkgo/v2/types"
 	zkApi "github.com/pravega/zookeeper-operator/api/v1beta1"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+	"io"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/client-go/rest"
 	"math/rand"
+	"os"
+	"path/filepath"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/config"
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+	"strings"
 	"testing"
 	"time"
 
@@ -44,6 +55,7 @@
 	// Available environment variables to customize tests
 	operatorImageEnv = "OPERATOR_IMAGE"
 	solrImageEnv     = "SOLR_IMAGE"
+	kubeVersionEnv   = "KUBERNETES_VERSION"
 
 	backupDirHostPath = "/tmp/backup"
 
@@ -64,6 +76,9 @@
 
 	operatorImage = getEnvWithDefault(operatorImageEnv, defaultOperatorImage)
 	solrImage     = getEnvWithDefault(solrImageEnv, defaultSolrImage)
+	kubeVersion   = getEnvWithDefault(kubeVersionEnv, defaultSolrImage)
+
+	outputDir = "output"
 )
 
 // Run e2e tests using the Ginkgo runner.
@@ -81,6 +96,9 @@
 	logger = zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))
 	logf.SetLogger(logger)
 
+	Expect(os.RemoveAll(outputDir+"/")).To(Succeed(), "Could not delete existing output directory before tests start: %s", outputDir)
+	Expect(os.Mkdir(outputDir, os.ModeDir|os.ModePerm)).To(Succeed(), "Could not create directory for test output: %s", outputDir)
+
 	var err error
 	k8sConfig, err = config.GetConfig()
 	Expect(err).NotTo(HaveOccurred(), "Could not load in default kubernetes config")
@@ -139,6 +157,7 @@
 	randomSeed    int64
 	operatorImage string
 	solrImage     string
+	kubeVersion   string
 }
 
 // ColorableString for ReportEntry to use
@@ -149,18 +168,20 @@
 // non-colorable String() is used by go's string formatting support but ignored by ReportEntry
 func (rc RetryCommand) String() string {
 	return fmt.Sprintf(
-		"make e2e-tests TEST_FILES=%q TEST_FILTER=%q TEST_SEED=%d TEST_PARALLELISM=%d %s=%q %s=%q",
+		"make e2e-tests TEST_FILES=%q TEST_FILTER=%q TEST_SEED=%d TEST_PARALLELISM=%d %s=%q %s=%q %s=%q",
 		rc.report.FileName(),
 		rc.report.FullText(),
 		rc.randomSeed,
 		rc.parallelism,
 		solrImageEnv, rc.solrImage,
 		operatorImageEnv, rc.operatorImage,
+		kubeVersionEnv, rc.kubeVersion,
 	)
 }
 
 type FailureInformation struct {
-	namespace string
+	namespace       string
+	outputDirectory string
 }
 
 // ColorableString for ReportEntry to use
@@ -171,18 +192,38 @@
 // non-colorable String() is used by go's string formatting support but ignored by ReportEntry
 func (fi FailureInformation) String() string {
 	return fmt.Sprintf(
-		"Namespace: %s\n",
+		"Namespace: %s\nLogs Directory: %s\n",
 		fi.namespace,
+		fi.outputDirectory,
 	)
 }
 
 var _ = ReportAfterEach(func(report SpecReport) {
+	testName := cases.Title(language.AmericanEnglish, cases.NoLower).String(report.FullText())
+	testOutputDir := outputDir + "/" + strings.ReplaceAll(strings.ReplaceAll(testName, "  ", "-"), " ", "")
+	// We count "ran" as "passed" or "failed"
+	if report.State.Is(types.SpecStatePassed | types.SpecStateFailureStates) {
+		Expect(os.Mkdir(testOutputDir, os.ModeDir|os.ModePerm)).To(Succeed(), "Could not create directory for test output: %s", testOutputDir)
+		testOutputDir += "/"
+		// Always save the logs of the Solr Operator for the test
+		writePodLogsToFile(
+			testOutputDir+"solr-operator.log",
+			getSolrOperatorPodName(solrOperatorReleaseNamespace),
+			solrOperatorReleaseNamespace,
+			report.StartTime,
+			fmt.Sprintf("%q: %q", "namespace", testNamespace()),
+		)
+	}
+
 	if report.Failed() {
 		ginkgoConfig, _ := GinkgoConfiguration()
+		testOutputDir, _ := filepath.Abs(testOutputDir)
 		AddReportEntry(
 			"Failure Information",
+			types.CodeLocation{},
 			FailureInformation{
-				namespace: testNamespace(),
+				namespace:       testNamespace(),
+				outputDirectory: testOutputDir,
 			},
 		)
 		AddReportEntry(
@@ -194,7 +235,57 @@
 				randomSeed:    GinkgoRandomSeed(),
 				operatorImage: operatorImage,
 				solrImage:     solrImage,
+				kubeVersion:   kubeVersion,
 			},
 		)
 	}
 })
+
+func getSolrOperatorPodName(namespace string) string {
+	labelSelector := labels.SelectorFromSet(map[string]string{"control-plane": "solr-operator"})
+	listOps := &client.ListOptions{
+		Namespace:     namespace,
+		LabelSelector: labelSelector,
+		Limit:         int64(1),
+	}
+
+	foundPods := &corev1.PodList{}
+	Expect(k8sClient.List(context.TODO(), foundPods, listOps)).To(Succeed(), "Could not fetch Solr Operator pod")
+	Expect(foundPods).ToNot(BeNil(), "No Solr Operator pods could be found")
+	Expect(foundPods.Items).ToNot(BeEmpty(), "No Solr Operator pods could be found")
+	return foundPods.Items[0].Name
+}
+
+func writePodLogsToFile(filename string, podName string, podNamespace string, startTimeRaw time.Time, filterLinesWithString string) {
+	logFile, err := os.Create(filename)
+	defer logFile.Close()
+	Expect(err).ToNot(HaveOccurred(), "Could not open file to save logs: %s", filename)
+
+	startTime := metav1.NewTime(startTimeRaw)
+	podLogOpts := corev1.PodLogOptions{
+		SinceTime: &startTime,
+	}
+	req := rawK8sClient.CoreV1().Pods(podNamespace).GetLogs(podName, &podLogOpts)
+	podLogs, logsErr := req.Stream(context.Background())
+	defer podLogs.Close()
+	Expect(logsErr).ToNot(HaveOccurred(), "Could not open stream to fetch pod logs. namespace: %s, pod: %s", podNamespace, podName)
+
+	var logReader io.Reader
+	logReader = podLogs
+
+	if filterLinesWithString != "" {
+		filteredWriter := bytes.NewBufferString("")
+		scanner := bufio.NewScanner(podLogs)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if strings.Contains(line, filterLinesWithString) {
+				io.WriteString(filteredWriter, line)
+				io.WriteString(filteredWriter, "\n")
+			}
+		}
+		logReader = filteredWriter
+	}
+
+	_, err = io.Copy(logFile, logReader)
+	Expect(err).ToNot(HaveOccurred(), "Could not write podLogs to file: %s", filename)
+}
diff --git a/tests/e2e/test_utils_test.go b/tests/e2e/test_utils_test.go
index 2330e99..cc4a724 100644
--- a/tests/e2e/test_utils_test.go
+++ b/tests/e2e/test_utils_test.go
@@ -21,6 +21,7 @@
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
 	"github.com/apache/solr-operator/controllers/util"
@@ -87,7 +88,7 @@
 	histClient := action.NewHistory(actionConfig)
 	histClient.Max = 1
 	var solrOperatorHelmRelease *release.Release
-	if _, err = histClient.Run(solrOperatorReleaseName); err == driver.ErrReleaseNotFound {
+	if _, err = histClient.Run(solrOperatorReleaseName); errors.Is(err, driver.ErrReleaseNotFound) {
 		installClient := action.NewInstall(actionConfig)
 
 		installClient.ReleaseName = solrOperatorReleaseName