From 65e9990a879846074334843ba5a8f31675c321db Mon Sep 17 00:00:00 2001 From: Mikel Vuka Date: Fri, 2 Feb 2024 11:08:52 +0100 Subject: [PATCH] Elasticsearch: Implement CheckHealth method in the backend (#81671) * Elasticsearch: Implement CheckHealth method * improve logger output * remove frontend healthcheck * Revert "remove frontend healthcheck" This reverts commit 676265f39e48017872790ca7fb43209a86201a09. * adapt test --------- Co-authored-by: Sven Grossmann --- pkg/tsdb/elasticsearch/healthcheck.go | 107 +++++++++++++++++++++ pkg/tsdb/elasticsearch/healthcheck_test.go | 80 +++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 pkg/tsdb/elasticsearch/healthcheck.go create mode 100644 pkg/tsdb/elasticsearch/healthcheck_test.go diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go new file mode 100644 index 00000000000..9422b87fc81 --- /dev/null +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -0,0 +1,107 @@ +package elasticsearch + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "path" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + logger := eslog.FromContext(ctx) + + ds, err := s.getDSInfo(ctx, req.PluginContext) + if err != nil { + logger.Error("Failed to get data source info", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to get data source info", + }, err + } + + esUrl, err := url.Parse(ds.URL) + if err != nil { + logger.Error("Failed to parse data source URL", "error", err, "url", ds.URL) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to parse data source URL", + }, err + } + + esUrl.Path = path.Join(esUrl.Path, "_cluster/health") + esUrl.RawQuery = "wait_for_status=yellow" + + request, err := http.NewRequestWithContext(ctx, "GET", esUrl.String(), nil) + if err != nil { + logger.Error("Failed to create request", "error", err, "url", esUrl.String()) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to create request", + }, err + } + + start := time.Now() + logger.Debug("Sending healthcheck request to Elasticsearch", "url", esUrl.String()) + response, err := ds.HTTPClient.Do(request) + + if err != nil { + logger.Error("Failed to do healthcheck request", "error", err, "url", esUrl.String()) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to do healthcheck request", + }, err + } + + if response.StatusCode == http.StatusRequestTimeout { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "Elasticsearch data source is not healthy", + }, nil + } + + logger.Info("Response received from Elasticsearch", "statusCode", response.StatusCode, "status", "ok", "duration", time.Since(start)) + + defer func() { + if err := response.Body.Close(); err != nil { + logger.Warn("Failed to close response body", "error", err) + } + }() + + body, err := io.ReadAll(response.Body) + if err != nil { + logger.Error("Error reading response body bytes", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to read response", + }, err + } + + jsonData := map[string]any{} + + err = json.Unmarshal(body, &jsonData) + if err != nil { + logger.Error("Error during json unmarshal of the body", "error", err) + return &backend.CheckHealthResult{ + Status: backend.HealthStatusUnknown, + Message: "Failed to unmarshal response", + }, err + } + + status := backend.HealthStatusOk + message := "Elasticsearch data source is healthy" + + if jsonData["status"] == "red" { + status = backend.HealthStatusError + message = "Elasticsearch data source is not healthy" + } + + return &backend.CheckHealthResult{ + Status: status, + Message: message, + }, nil +} diff --git a/pkg/tsdb/elasticsearch/healthcheck_test.go b/pkg/tsdb/elasticsearch/healthcheck_test.go new file mode 100644 index 00000000000..432c2d24413 --- /dev/null +++ b/pkg/tsdb/elasticsearch/healthcheck_test.go @@ -0,0 +1,80 @@ +package elasticsearch + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" + "github.com/stretchr/testify/assert" +) + +func Test_Healthcheck_OK(t *testing.T) { + service := GetMockService(true) + res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusOk, res.Status) + assert.Equal(t, "Elasticsearch data source is healthy", res.Message) +} + +func Test_Healthcheck_Timeout(t *testing.T) { + service := GetMockService(false) + res, _ := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusError, res.Status) + assert.Equal(t, "Elasticsearch data source is not healthy", res.Message) +} + +type FakeRoundTripper struct { + isDsHealthy bool +} + +func (fakeRoundTripper *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + var res *http.Response + if fakeRoundTripper.isDsHealthy { + res = &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"green\"}")), + } + } else { + res = &http.Response{ + StatusCode: http.StatusRequestTimeout, + Status: "408 Request Timeout", + Body: io.NopCloser(bytes.NewBufferString("{\"status\":\"red\"}")), + } + } + return res, nil +} + +type FakeInstanceManager struct { + isDsHealthy bool +} + +func (fakeInstanceManager *FakeInstanceManager) Get(tx context.Context, pluginContext backend.PluginContext) (instancemgmt.Instance, error) { + httpClient, _ := sdkhttpclient.New(sdkhttpclient.Options{}) + httpClient.Transport = &FakeRoundTripper{isDsHealthy: fakeInstanceManager.isDsHealthy} + + return es.DatasourceInfo{ + HTTPClient: httpClient, + }, nil +} + +func (*FakeInstanceManager) Do(_ context.Context, _ backend.PluginContext, _ instancemgmt.InstanceCallbackFunc) error { + return nil +} + +func GetMockService(isDsHealthy bool) *Service { + return &Service{ + im: &FakeInstanceManager{isDsHealthy: isDsHealthy}, + } +}