Repository: replicatedhq/troubleshoot
Pull request #314 (2021-02-09 02:57:44)

Host HTTP request analyzer


Patch:
diff --git a/examples/preflight/host-http.yaml b/examples/preflight/host-http.yaml
new file mode 100644
index 00000000..0f4372c2
--- /dev/null
+++ b/examples/preflight/host-http.yaml
@@ -0,0 +1,22 @@
+apiVersion: troubleshoot.sh/v1beta2
+kind: HostPreflight
+metadata:
+  name: http
+spec:
+  collectors:
+    - http:
+        collectorName: registry
+        get:
+          url: https://registry.replicated.com
+  analyzers:
+    - http:
+        collectorName: registry
+        outcomes:
+          - fail:
+              when: "error"
+              message: Error connecting to registry
+          - pass:
+              when: "statusCode == 404"
+              message: Connected to registry
+          - fail:
+              message: "Unexpected response"
diff --git a/pkg/analyze/analyzer.go b/pkg/analyze/analyzer.go
index 53d548b8..78b70ee6 100644
--- a/pkg/analyze/analyzer.go
+++ b/pkg/analyze/analyzer.go
@@ -76,6 +76,13 @@ func HostAnalyze(hostAnalyzer *troubleshootv1beta2.HostAnalyze, getFile getColle
 		}
 		return []*AnalyzeResult{result}, nil
 	}
+	if hostAnalyzer.HTTP != nil {
+		result, err := analyzeHostHTTP(hostAnalyzer.HTTP, getFile)
+		if err != nil {
+			return nil, err
+		}
+		return []*AnalyzeResult{result}, nil
+	}
 
 	return nil, errors.New("invalid analyzer")
 }
diff --git a/pkg/analyze/host_http.go b/pkg/analyze/host_http.go
new file mode 100644
index 00000000..b07a7b73
--- /dev/null
+++ b/pkg/analyze/host_http.go
@@ -0,0 +1,140 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+)
+
+type httpResult struct {
+	Error    *collect.HTTPError
+	Response *collect.HTTPResponse
+}
+
+func analyzeHostHTTP(hostAnalyzer *troubleshootv1beta2.HTTPAnalyze, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
+	name := filepath.Join("http", "result.json")
+	if hostAnalyzer.CollectorName != "" {
+		name = filepath.Join("http", hostAnalyzer.CollectorName+".json")
+	}
+	contents, err := getCollectedFileContents(name)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get collected file")
+	}
+
+	httpInfo := &httpResult{}
+	if err := json.Unmarshal(contents, httpInfo); err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal http result")
+	}
+
+	result := AnalyzeResult{}
+
+	title := hostAnalyzer.CheckName
+	if title == "" {
+		title = "HTTP Request"
+	}
+	result.Title = title
+
+	for _, outcome := range hostAnalyzer.Outcomes {
+		if outcome.Fail != nil {
+			if outcome.Fail.When == "" {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostHTTPConditionalToActual(outcome.Fail.When, httpInfo)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Fail.When)
+			}
+
+			if isMatch {
+				result.IsFail = true
+				result.Message = outcome.Fail.Message
+				result.URI = outcome.Fail.URI
+
+				return &result, nil
+			}
+		} else if outcome.Warn != nil {
+			if outcome.Warn.When == "" {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostHTTPConditionalToActual(outcome.Warn.When, httpInfo)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Warn.When)
+			}
+
+			if isMatch {
+				result.IsWarn = true
+				result.Message = outcome.Warn.Message
+				result.URI = outcome.Warn.URI
+
+				return &result, nil
+			}
+		} else if outcome.Pass != nil {
+			if outcome.Pass.When == "" {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return &result, nil
+			}
+
+			isMatch, err := compareHostHTTPConditionalToActual(outcome.Pass.When, httpInfo)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to compare %s", outcome.Pass.When)
+			}
+
+			if isMatch {
+				result.IsPass = true
+				result.Message = outcome.Pass.Message
+				result.URI = outcome.Pass.URI
+
+				return &result, nil
+			}
+		}
+	}
+
+	return &result, nil
+}
+
+func compareHostHTTPConditionalToActual(conditional string, result *httpResult) (res bool, err error) {
+	if conditional == "error" {
+		return result.Error != nil, nil
+	}
+
+	parts := strings.Split(conditional, " ")
+	if len(parts) != 3 {
+		return false, fmt.Errorf("Failed to parse conditional: got %d parts", len(parts))
+	}
+
+	if parts[0] != "statusCode" {
+		return false, errors.New(`Conditional must begin with keyword "statusCode"`)
+	}
+
+	if parts[1] != "=" && parts[1] != "==" && parts[1] != "===" {
+		return false, errors.New(`Only supported operator is "=="`)
+	}
+
+	i, err := strconv.Atoi(parts[2])
+	if err != nil {
+		return false, err
+	}
+
+	if result.Response == nil {
+		return false, err
+	}
+	return result.Response.Status == i, nil
+}
diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
index 0900ee50..39480d97 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go
@@ -28,6 +28,12 @@ type DiskUsageAnalyze struct {
 	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
 }
 
+type HTTPAnalyze struct {
+	AnalyzeMeta   `json:",inline" yaml:",inline"`
+	CollectorName string     `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
+	Outcomes      []*Outcome `json:"outcomes" yaml:"outcomes"`
+}
+
 type HostAnalyze struct {
 	CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"`
 	//
@@ -38,4 +44,6 @@ type HostAnalyze struct {
 	Memory *MemoryAnalyze `json:"memory,omitempty" yaml:"memory,omitempty"`
 
 	TCPPortStatus *TCPPortStatusAnalyze `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"`
+
+	HTTP *HTTPAnalyze `json:"http" yaml:"http"`
 }
diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
index f654a3d2..417eacbf 100644
--- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
+++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go
@@ -52,6 +52,13 @@ type DiskUsage struct {
 	Path              string `json:"path"`
 }
 
+type HostHTTP struct {
+	HostCollectorMeta `json:",inline" yaml:",inline"`
+	Get               *Get  `json:"get,omitempty" yaml:"get,omitempty"`
+	Post              *Post `json:"post,omitempty" yaml:"post,omitempty"`
+	Put               *Put  `json:"put,omitempty" yaml:"put,omitempty"`
+}
+
 type HostCollect struct {
 	CPU              *CPU              `json:"cpu,omitempty" yaml:"cpu,omitempty"`
 	Memory           *Memory           `json:"memory,omitempty" yaml:"memory,omitempty"`
@@ -61,6 +68,7 @@ type HostCollect struct {
 	Kubernetes       *Kubernetes       `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"`
 	IPV4Interfaces   *IPV4Interfaces   `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"`
 	DiskUsage        *DiskUsage        `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"`
+	HTTP             *HostHTTP         `json:"http,omitempty" yaml:"http,omitempty"`
 }
 
 func (c *HostCollect) GetName() string {
diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
index dc18d28e..7ba38223 100644
--- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go
@@ -1027,6 +1027,33 @@ func (in *HTTP) DeepCopy() *HTTP {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HTTPAnalyze) DeepCopyInto(out *HTTPAnalyze) {
+	*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 HTTPAnalyze.
+func (in *HTTPAnalyze) DeepCopy() *HTTPAnalyze {
+	if in == nil {
+		return nil
+	}
+	out := new(HTTPAnalyze)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HTTPLoadBalancer) DeepCopyInto(out *HTTPLoadBalancer) {
 	*out = *in
@@ -1071,6 +1098,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
 		*out = new(TCPPortStatusAnalyze)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.HTTP != nil {
+		in, out := &in.HTTP, &out.HTTP
+		*out = new(HTTPAnalyze)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
@@ -1126,6 +1158,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) {
 		*out = new(DiskUsage)
 		**out = **in
 	}
+	if in.HTTP != nil {
+		in, out := &in.HTTP, &out.HTTP
+		*out = new(HostHTTP)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect.
@@ -1154,6 +1191,37 @@ func (in *HostCollectorMeta) DeepCopy() *HostCollectorMeta {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostHTTP) DeepCopyInto(out *HostHTTP) {
+	*out = *in
+	out.HostCollectorMeta = in.HostCollectorMeta
+	if in.Get != nil {
+		in, out := &in.Get, &out.Get
+		*out = new(Get)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Post != nil {
+		in, out := &in.Post, &out.Post
+		*out = new(Post)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Put != nil {
+		in, out := &in.Put, &out.Put
+		*out = new(Put)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostHTTP.
+func (in *HostHTTP) DeepCopy() *HostHTTP {
+	if in == nil {
+		return nil
+	}
+	out := new(HostHTTP)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HostPreflight) DeepCopyInto(out *HostPreflight) {
 	*out = *in
diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go
index c684c9ea..5b0b0e3b 100644
--- a/pkg/collect/host_collector.go
+++ b/pkg/collect/host_collector.go
@@ -28,6 +28,8 @@ func (c *HostCollector) RunCollectorSync() (result map[string][]byte, err error)
 		result, err = HostDiskUsage(c)
 	} else if c.Collect.TCPPortStatus != nil {
 		result, err = HostTCPPortStatus(c)
+	} else if c.Collect.HTTP != nil {
+		result, err = HostHTTP(c)
 	} else {
 		err = errors.New("no spec found to run")
 		return
diff --git a/pkg/collect/host_http.go b/pkg/collect/host_http.go
new file mode 100644
index 00000000..e00f8a44
--- /dev/null
+++ b/pkg/collect/host_http.go
@@ -0,0 +1,39 @@
+package collect
+
+import (
+	"net/http"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+)
+
+func HostHTTP(c *HostCollector) (map[string][]byte, error) {
+	httpCollector := c.Collect.HTTP
+	var response *http.Response
+	var err error
+
+	if httpCollector.Get != nil {
+		response, err = doGet(httpCollector.Get)
+	} else if httpCollector.Post != nil {
+		response, err = doPost(httpCollector.Post)
+	} else if httpCollector.Put != nil {
+		response, err = doPut(httpCollector.Put)
+	} else {
+		return nil, errors.New("no supported http request type")
+	}
+
+	output, err := responseToOutput(response, err, false)
+	if err != nil {
+		return nil, err
+	}
+
+	fileName := "result.json"
+	if httpCollector.CollectorName != "" {
+		fileName = httpCollector.CollectorName + ".json"
+	}
+	httpOutput := map[string][]byte{
+		filepath.Join("http", fileName): output,
+	}
+
+	return httpOutput, nil
+}
diff --git a/pkg/collect/http.go b/pkg/collect/http.go
index 22f7a776..0140efc3 100644
--- a/pkg/collect/http.go
+++ b/pkg/collect/http.go
@@ -12,13 +12,13 @@ import (
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 )
 
-type httpResponse struct {
+type HTTPResponse struct {
 	Status  int               `json:"status"`
 	Body    string            `json:"body"`
 	Headers map[string]string `json:"headers"`
 }
 
-type httpError struct {
+type HTTPError struct {
 	Message string `json:"message"`
 }
 
@@ -106,7 +106,7 @@ func doPut(put *troubleshootv1beta2.Put) (*http.Response, error) {
 func responseToOutput(response *http.Response, err error, doRedact bool) ([]byte, error) {
 	output := make(map[string]interface{})
 	if err != nil {
-		output["error"] = httpError{
+		output["error"] = HTTPError{
 			Message: err.Error(),
 		}
 	} else {
@@ -120,7 +120,7 @@ func responseToOutput(response *http.Response, err error, doRedact bool) ([]byte
 			headers[k] = strings.Join(v, ",")
 		}
 
-		output["response"] = httpResponse{
+		output["response"] = HTTPResponse{
 			Status:  response.StatusCode,
 			Body:    string(body),
 			Headers: headers,


Test patch:
diff --git a/pkg/analyze/host_http_test.go b/pkg/analyze/host_http_test.go
new file mode 100644
index 00000000..e24461b4
--- /dev/null
+++ b/pkg/analyze/host_http_test.go
@@ -0,0 +1,104 @@
+package analyzer
+
+import (
+	"encoding/json"
+	"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 TestAnalyzeHostHTTP(t *testing.T) {
+	tests := []struct {
+		name         string
+		httpResult   *httpResult
+		hostAnalyzer *troubleshootv1beta2.HTTPAnalyze
+		result       *AnalyzeResult
+		expectErr    bool
+	}{
+		{
+			name: "error",
+			httpResult: &httpResult{
+				Error: &collect.HTTPError{
+					Message: "i/o timeout",
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.HTTPAnalyze{
+				CollectorName: "registry",
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							When:    "error",
+							Message: "Failed to reach replicated.registry.com",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "HTTP Request",
+				IsFail:  true,
+				Message: "Failed to reach replicated.registry.com",
+			},
+		},
+		{
+			name: "200",
+			httpResult: &httpResult{
+				Response: &collect.HTTPResponse{
+					Status: 200,
+				},
+			},
+			hostAnalyzer: &troubleshootv1beta2.HTTPAnalyze{
+				CollectorName: "registry",
+				Outcomes: []*troubleshootv1beta2.Outcome{
+					{
+						Fail: &troubleshootv1beta2.SingleOutcome{
+							When:    "error",
+							Message: "Failed to reach replicated.registry.com",
+						},
+					},
+					{
+						Warn: &troubleshootv1beta2.SingleOutcome{
+							When:    "statusCode == 204",
+							Message: "No content",
+						},
+					},
+					{
+						Pass: &troubleshootv1beta2.SingleOutcome{
+							When:    "statusCode == 200",
+							Message: "Successfully reached registry",
+						},
+					},
+				},
+			},
+			result: &AnalyzeResult{
+				Title:   "HTTP Request",
+				IsPass:  true,
+				Message: "Successfully reached registry",
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+			b, err := json.Marshal(test.httpResult)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			getCollectedFileContents := func(filename string) ([]byte, error) {
+				return b, nil
+			}
+
+			result, err := analyzeHostHTTP(test.hostAnalyzer, getCollectedFileContents)
+			if test.expectErr {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+			}
+
+			assert.Equal(t, test.result, result)
+		})
+	}
+}