From f25149f45cf23773479547217010b0cfbebd2ae2 Mon Sep 17 00:00:00 2001 From: Andrew Reed Date: Tue, 9 Feb 2021 02:56:55 +0000 Subject: [PATCH] Host HTTP request analyzer --- examples/preflight/host-http.yaml | 22 +++ pkg/analyze/analyzer.go | 7 + pkg/analyze/host_http.go | 140 ++++++++++++++++++ pkg/analyze/host_http_test.go | 104 +++++++++++++ .../v1beta2/hostanalyzer_shared.go | 8 + .../v1beta2/hostcollector_shared.go | 8 + .../v1beta2/zz_generated.deepcopy.go | 68 +++++++++ pkg/collect/host_collector.go | 2 + pkg/collect/host_http.go | 39 +++++ pkg/collect/http.go | 8 +- 10 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 examples/preflight/host-http.yaml create mode 100644 pkg/analyze/host_http.go create mode 100644 pkg/analyze/host_http_test.go create mode 100644 pkg/collect/host_http.go 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/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) + }) + } +} 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,