From badecc3aae9ec3517b7be26495abc2c132c2f615 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 19 May 2021 13:17:46 +0300 Subject: [PATCH] Refactor: zabbix client --- pkg/datasource/datasource.go | 12 +- pkg/datasource/datasource_test.go | 2 +- pkg/datasource/resource_handler.go | 3 +- pkg/datasource/response_handler.go | 3 +- pkg/datasource/zabbix.go | 365 +---------------------------- pkg/datasource/zabbix_test.go | 24 +- pkg/zabbix/cache.go | 55 +++++ pkg/zabbix/methods.go | 225 ++++++++++++++++++ pkg/zabbix/models.go | 76 ++++++ pkg/zabbix/settings.go | 64 +++++ pkg/zabbix/testing.go | 31 +++ pkg/zabbix/utils.go | 80 +++++++ pkg/zabbix/zabbix.go | 135 +++++++++++ pkg/zabbixapi/zabbix_api.go | 1 + 14 files changed, 705 insertions(+), 371 deletions(-) create mode 100644 pkg/zabbix/cache.go create mode 100644 pkg/zabbix/methods.go create mode 100644 pkg/zabbix/models.go create mode 100644 pkg/zabbix/settings.go create mode 100644 pkg/zabbix/testing.go create mode 100644 pkg/zabbix/utils.go create mode 100644 pkg/zabbix/zabbix.go diff --git a/pkg/datasource/datasource.go b/pkg/datasource/datasource.go index c862ce3..d606fc7 100644 --- a/pkg/datasource/datasource.go +++ b/pkg/datasource/datasource.go @@ -9,6 +9,7 @@ import ( "github.com/alexanderzobnin/grafana-zabbix/pkg/gtime" "github.com/alexanderzobnin/grafana-zabbix/pkg/httpclient" + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbix" "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbixapi" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -30,7 +31,7 @@ type ZabbixDatasource struct { // ZabbixDatasourceInstance stores state about a specific datasource // and provides methods to make requests to the Zabbix API type ZabbixDatasourceInstance struct { - zabbixAPI *zabbixapi.ZabbixAPI + zabbix *zabbix.Zabbix dsInfo *backend.DataSourceInstanceSettings Settings *ZabbixDatasourceSettings queryCache *DatasourceCache @@ -58,6 +59,7 @@ func newZabbixDatasourceInstance(settings backend.DataSourceInstanceSettings) (i client, err := httpclient.NewHttpClient(&settings, zabbixSettings.Timeout) if err != nil { + logger.Error("Error initializing HTTP client", "error", err) return nil, err } @@ -67,9 +69,15 @@ func newZabbixDatasourceInstance(settings backend.DataSourceInstanceSettings) (i return nil, err } + zabbixClient, err := zabbix.New(&settings, zabbixAPI) + if err != nil { + logger.Error("Error initializing Zabbix client", "error", err) + return nil, err + } + return &ZabbixDatasourceInstance{ dsInfo: &settings, - zabbixAPI: zabbixAPI, + zabbix: zabbixClient, Settings: zabbixSettings, queryCache: NewDatasourceCache(zabbixSettings.CacheTTL, 10*time.Minute), logger: logger, diff --git a/pkg/datasource/datasource_test.go b/pkg/datasource/datasource_test.go index 2046730..cef8d88 100644 --- a/pkg/datasource/datasource_test.go +++ b/pkg/datasource/datasource_test.go @@ -61,7 +61,7 @@ func TestZabbixBackend_getCachedDatasource(t *testing.T) { got, _ := ds.getDSInstance(tt.pluginContext) // Only checking the URL, being the easiest value to, and guarantee equality for - assert.Equal(t, tt.want.zabbixAPI.GetUrl().String(), got.zabbixAPI.GetUrl().String()) + assert.Equal(t, tt.want.zabbix.GetAPI().GetUrl().String(), got.zabbix.GetAPI().GetUrl().String()) }) } } diff --git a/pkg/datasource/resource_handler.go b/pkg/datasource/resource_handler.go index 81f5ef4..ca7da96 100644 --- a/pkg/datasource/resource_handler.go +++ b/pkg/datasource/resource_handler.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "net/http" + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbix" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" ) @@ -47,7 +48,7 @@ func (ds *ZabbixDatasource) ZabbixAPIHandler(rw http.ResponseWriter, req *http.R return } - apiReq := &ZabbixAPIRequest{Method: reqData.Method, Params: reqData.Params} + apiReq := &zabbix.ZabbixAPIRequest{Method: reqData.Method, Params: reqData.Params} result, err := dsInstance.ZabbixAPIQuery(req.Context(), apiReq) if err != nil { diff --git a/pkg/datasource/response_handler.go b/pkg/datasource/response_handler.go index cb5bf3f..6825f7e 100644 --- a/pkg/datasource/response_handler.go +++ b/pkg/datasource/response_handler.go @@ -4,11 +4,12 @@ import ( "fmt" "time" + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbix" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" ) -func convertHistoryToDataFrame(history History, items Items) *data.Frame { +func convertHistoryToDataFrame(history History, items zabbix.Items) *data.Frame { timeFileld := data.NewFieldFromFieldType(data.FieldTypeTime, 0) timeFileld.Name = "time" frame := data.NewFrame("History", timeFileld) diff --git a/pkg/datasource/zabbix.go b/pkg/datasource/zabbix.go index 5b1c57a..6b48f56 100644 --- a/pkg/datasource/zabbix.go +++ b/pkg/datasource/zabbix.go @@ -3,57 +3,18 @@ package datasource import ( "encoding/json" "fmt" - "regexp" - "strings" "time" - "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbixapi" + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbix" simplejson "github.com/bitly/go-simplejson" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "golang.org/x/net/context" ) -var CachedMethods = map[string]bool{ - "hostgroup.get": true, - "host.get": true, - "application.get": true, - "item.get": true, - "service.get": true, - "usermacro.get": true, - "proxy.get": true, -} - -// ZabbixQuery handles query requests to Zabbix -func (ds *ZabbixDatasourceInstance) ZabbixQuery(ctx context.Context, apiReq *ZabbixAPIRequest) (*simplejson.Json, error) { - var resultJson *simplejson.Json - var err error - - cachedResult, queryExistInCache := ds.queryCache.GetAPIRequest(apiReq) - if !queryExistInCache { - resultJson, err = ds.ZabbixRequest(ctx, apiReq.Method, apiReq.Params) - if err != nil { - return nil, err - } - - if _, ok := CachedMethods[apiReq.Method]; ok { - ds.logger.Debug("Writing result to cache", "method", apiReq.Method) - ds.queryCache.SetAPIRequest(apiReq, resultJson) - } - } else { - var ok bool - resultJson, ok = cachedResult.(*simplejson.Json) - if !ok { - resultJson = simplejson.New() - } - } - - return resultJson, nil -} - // ZabbixAPIQuery handles query requests to Zabbix API -func (ds *ZabbixDatasourceInstance) ZabbixAPIQuery(ctx context.Context, apiReq *ZabbixAPIRequest) (*ZabbixAPIResourceResponse, error) { - resultJson, err := ds.ZabbixQuery(ctx, apiReq) +func (ds *ZabbixDatasourceInstance) ZabbixAPIQuery(ctx context.Context, apiReq *zabbix.ZabbixAPIRequest) (*ZabbixAPIResourceResponse, error) { + resultJson, err := ds.zabbix.Request(ctx, apiReq) if err != nil { return nil, err } @@ -69,12 +30,12 @@ func BuildAPIResponse(responseData *interface{}) (*ZabbixAPIResourceResponse, er // TestConnection checks authentication and version of the Zabbix API and returns that info func (ds *ZabbixDatasourceInstance) TestConnection(ctx context.Context) (string, error) { - _, err := ds.getAllGroups(ctx) + _, err := ds.zabbix.GetAllGroups(ctx) if err != nil { return "", err } - response, err := ds.ZabbixRequest(ctx, "apiinfo.version", ZabbixAPIParams{}) + response, err := ds.zabbix.Request(ctx, &zabbix.ZabbixAPIRequest{Method: "apiinfo.version"}) if err != nil { return "", err } @@ -83,67 +44,13 @@ func (ds *ZabbixDatasourceInstance) TestConnection(ctx context.Context) (string, return string(resultByte), nil } -// ZabbixRequest checks authentication and makes a request to the Zabbix API -func (ds *ZabbixDatasourceInstance) ZabbixRequest(ctx context.Context, method string, params ZabbixAPIParams) (*simplejson.Json, error) { - ds.logger.Debug("Zabbix API request", "datasource", ds.dsInfo.Name, "method", method) - var result *simplejson.Json - var err error - - // Skip auth for methods that are not required it - if method == "apiinfo.version" { - return ds.zabbixAPI.RequestUnauthenticated(ctx, method, params) - } - - result, err = ds.zabbixAPI.Request(ctx, method, params) - notAuthorized := isNotAuthorized(err) - if err == zabbixapi.ErrNotAuthenticated || notAuthorized { - if notAuthorized { - ds.logger.Debug("Authentication token expired, performing re-login") - } - err = ds.login(ctx) - if err != nil { - return nil, err - } - return ds.ZabbixRequest(ctx, method, params) - } else if err != nil { - return nil, err - } - - return result, err -} - -func (ds *ZabbixDatasourceInstance) login(ctx context.Context) error { - jsonData, err := simplejson.NewJson(ds.dsInfo.JSONData) - if err != nil { - return err - } - - zabbixLogin := jsonData.Get("username").MustString() - var zabbixPassword string - if securePassword, exists := ds.dsInfo.DecryptedSecureJSONData["password"]; exists { - zabbixPassword = securePassword - } else { - // Fallback - zabbixPassword = jsonData.Get("password").MustString() - } - - err = ds.zabbixAPI.Authenticate(ctx, zabbixLogin, zabbixPassword) - if err != nil { - ds.logger.Error("Zabbix authentication error", "error", err) - return err - } - ds.logger.Debug("Successfully authenticated", "url", ds.zabbixAPI.GetUrl().String(), "user", zabbixLogin) - - return nil -} - func (ds *ZabbixDatasourceInstance) queryNumericItems(ctx context.Context, query *QueryModel) (*data.Frame, error) { groupFilter := query.Group.Filter hostFilter := query.Host.Filter appFilter := query.Application.Filter itemFilter := query.Item.Filter - items, err := ds.getItems(ctx, groupFilter, hostFilter, appFilter, itemFilter, "num") + items, err := ds.zabbix.GetItems(ctx, groupFilter, hostFilter, appFilter, itemFilter, "num") if err != nil { return nil, err } @@ -156,215 +63,7 @@ func (ds *ZabbixDatasourceInstance) queryNumericItems(ctx context.Context, query return frames, nil } -func (ds *ZabbixDatasourceInstance) getItems(ctx context.Context, groupFilter string, hostFilter string, appFilter string, itemFilter string, itemType string) (Items, error) { - hosts, err := ds.getHosts(ctx, groupFilter, hostFilter) - if err != nil { - return nil, err - } - var hostids []string - for _, k := range hosts { - hostids = append(hostids, k["hostid"].(string)) - } - - apps, err := ds.getApps(ctx, groupFilter, hostFilter, appFilter) - // Apps not supported in Zabbix 5.4 and higher - if isAppMethodNotFoundError(err) { - apps = []map[string]interface{}{} - } else if err != nil { - return nil, err - } - var appids []string - for _, l := range apps { - appids = append(appids, l["applicationid"].(string)) - } - - var allItems *simplejson.Json - if len(hostids) > 0 { - allItems, err = ds.getAllItems(ctx, hostids, nil, itemType) - } else if len(appids) > 0 { - allItems, err = ds.getAllItems(ctx, nil, appids, itemType) - } - - var items Items - - if allItems == nil { - items = Items{} - } else { - itemsJSON, err := allItems.MarshalJSON() - if err != nil { - return nil, err - } - - err = json.Unmarshal(itemsJSON, &items) - if err != nil { - return nil, err - } - } - - re, err := parseFilter(itemFilter) - if err != nil { - return nil, err - } - - filteredItems := Items{} - for _, item := range items { - itemName := item.ExpandItem() - if item.Status == "0" { - if re != nil { - if re.MatchString(itemName) { - filteredItems = append(filteredItems, item) - } - } else if itemName == itemFilter { - filteredItems = append(filteredItems, item) - } - } - } - return filteredItems, nil -} - -func (ds *ZabbixDatasourceInstance) getApps(ctx context.Context, groupFilter string, hostFilter string, appFilter string) ([]map[string]interface{}, error) { - hosts, err := ds.getHosts(ctx, groupFilter, hostFilter) - if err != nil { - return nil, err - } - var hostids []string - for _, k := range hosts { - hostids = append(hostids, k["hostid"].(string)) - } - allApps, err := ds.getAllApps(ctx, hostids) - if err != nil { - return nil, err - } - - re, err := parseFilter(appFilter) - if err != nil { - return nil, err - } - - var apps []map[string]interface{} - for _, i := range allApps.MustArray() { - name := i.(map[string]interface{})["name"].(string) - if re != nil { - if re.MatchString(name) { - apps = append(apps, i.(map[string]interface{})) - } - } else if name == appFilter { - apps = append(apps, i.(map[string]interface{})) - } - } - return apps, nil -} - -func (ds *ZabbixDatasourceInstance) getHosts(ctx context.Context, groupFilter string, hostFilter string) ([]map[string]interface{}, error) { - groups, err := ds.getGroups(ctx, groupFilter) - if err != nil { - return nil, err - } - var groupids []string - for _, k := range groups { - groupids = append(groupids, k["groupid"].(string)) - } - allHosts, err := ds.getAllHosts(ctx, groupids) - if err != nil { - return nil, err - } - - re, err := parseFilter(hostFilter) - if err != nil { - return nil, err - } - - var hosts []map[string]interface{} - for _, i := range allHosts.MustArray() { - name := i.(map[string]interface{})["name"].(string) - if re != nil { - if re.MatchString(name) { - hosts = append(hosts, i.(map[string]interface{})) - } - } else if name == hostFilter { - hosts = append(hosts, i.(map[string]interface{})) - } - - } - - return hosts, nil -} - -func (ds *ZabbixDatasourceInstance) getGroups(ctx context.Context, groupFilter string) ([]map[string]interface{}, error) { - allGroups, err := ds.getAllGroups(ctx) - if err != nil { - return nil, err - } - re, err := parseFilter(groupFilter) - if err != nil { - return nil, err - } - - var groups []map[string]interface{} - for _, i := range allGroups.MustArray() { - name := i.(map[string]interface{})["name"].(string) - if re != nil { - if re.MatchString(name) { - groups = append(groups, i.(map[string]interface{})) - } - } else if name == groupFilter { - groups = append(groups, i.(map[string]interface{})) - } - } - return groups, nil -} - -func (ds *ZabbixDatasourceInstance) getAllItems(ctx context.Context, hostids []string, appids []string, itemtype string) (*simplejson.Json, error) { - params := ZabbixAPIParams{ - "output": []string{"itemid", "name", "key_", "value_type", "hostid", "status", "state"}, - "sortfield": "name", - "webitems": true, - "filter": map[string]interface{}{}, - "selectHosts": []string{"hostid", "name"}, - "hostids": hostids, - "applicationids": appids, - } - - filter := params["filter"].(map[string]interface{}) - if itemtype == "num" { - filter["value_type"] = []int{0, 3} - } else if itemtype == "text" { - filter["value_type"] = []int{1, 2, 4} - } - - return ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "item.get", Params: params}) -} - -func (ds *ZabbixDatasourceInstance) getAllApps(ctx context.Context, hostids []string) (*simplejson.Json, error) { - params := ZabbixAPIParams{ - "output": "extend", - "hostids": hostids, - } - - return ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "application.get", Params: params}) -} - -func (ds *ZabbixDatasourceInstance) getAllHosts(ctx context.Context, groupids []string) (*simplejson.Json, error) { - params := ZabbixAPIParams{ - "output": []string{"name", "host"}, - "sortfield": "name", - "groupids": groupids, - } - - return ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "host.get", Params: params}) -} - -func (ds *ZabbixDatasourceInstance) getAllGroups(ctx context.Context) (*simplejson.Json, error) { - params := ZabbixAPIParams{ - "output": []string{"name"}, - "sortfield": "name", - "real_hosts": true, - } - - return ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "hostgroup.get", Params: params}) -} - -func (ds *ZabbixDatasourceInstance) queryNumericDataForItems(ctx context.Context, query *QueryModel, items Items) (*data.Frame, error) { +func (ds *ZabbixDatasourceInstance) queryNumericDataForItems(ctx context.Context, query *QueryModel, items zabbix.Items) (*data.Frame, error) { valueType := ds.getTrendValueType(query) consolidateBy := ds.getConsolidateBy(query) @@ -404,12 +103,12 @@ func (ds *ZabbixDatasourceInstance) getConsolidateBy(query *QueryModel) string { return consolidateBy } -func (ds *ZabbixDatasourceInstance) getHistotyOrTrend(ctx context.Context, query *QueryModel, items Items) (History, error) { +func (ds *ZabbixDatasourceInstance) getHistotyOrTrend(ctx context.Context, query *QueryModel, items zabbix.Items) (History, error) { timeRange := query.TimeRange useTrend := ds.isUseTrend(timeRange) allHistory := History{} - groupedItems := map[int]Items{} + groupedItems := map[int]zabbix.Items{} for _, j := range items { groupedItems[j.ValueType] = append(groupedItems[j.ValueType], j) @@ -433,10 +132,10 @@ func (ds *ZabbixDatasourceInstance) getHistotyOrTrend(ctx context.Context, query var response *simplejson.Json var err error if useTrend { - response, err = ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "trend.get", Params: params}) + response, err = ds.zabbix.Request(ctx, &zabbix.ZabbixAPIRequest{Method: "trend.get", Params: params}) } else { params["history"] = &k - response, err = ds.ZabbixQuery(ctx, &ZabbixAPIRequest{Method: "history.get", Params: params}) + response, err = ds.zabbix.Request(ctx, &zabbix.ZabbixAPIRequest{Method: "history.get", Params: params}) } if err != nil { @@ -475,45 +174,3 @@ func (ds *ZabbixDatasourceInstance) isUseTrend(timeRange backend.TimeRange) bool } return false } - -func parseFilter(filter string) (*regexp.Regexp, error) { - regex := regexp.MustCompile(`^/(.+)/(.*)$`) - flagRE := regexp.MustCompile("[imsU]+") - - matches := regex.FindStringSubmatch(filter) - if len(matches) <= 1 { - return nil, nil - } - - pattern := "" - if matches[2] != "" { - if flagRE.MatchString(matches[2]) { - pattern += "(?" + matches[2] + ")" - } else { - return nil, fmt.Errorf("error parsing regexp: unsupported flags `%s` (expected [imsU])", matches[2]) - } - } - pattern += matches[1] - - return regexp.Compile(pattern) -} - -func isNotAuthorized(err error) bool { - if err == nil { - return false - } - - message := err.Error() - return strings.Contains(message, "Session terminated, re-login, please.") || - strings.Contains(message, "Not authorised.") || - strings.Contains(message, "Not authorized.") -} - -func isAppMethodNotFoundError(err error) bool { - if err == nil { - return false - } - - message := err.Error() - return message == `Method not found. Incorrect API "application".` -} diff --git a/pkg/datasource/zabbix_test.go b/pkg/datasource/zabbix_test.go index 50e1d9e..872632c 100644 --- a/pkg/datasource/zabbix_test.go +++ b/pkg/datasource/zabbix_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/alexanderzobnin/grafana-zabbix/pkg/cache" - "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbixapi" + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbix" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/stretchr/testify/assert" @@ -32,23 +32,23 @@ var basicDatasourceInfo = &backend.DataSourceInstanceSettings{ ID: 1, Name: "TestDatasource", URL: "http://zabbix.org/zabbix", - JSONData: []byte(`{"username":"username", "password":"password"}}`), + JSONData: []byte(`{"username":"username", "password":"password", "cacheTTL":"10m"}`), } -func mockZabbixQuery(method string, params ZabbixAPIParams) *ZabbixAPIRequest { - return &ZabbixAPIRequest{ +func mockZabbixQuery(method string, params ZabbixAPIParams) *zabbix.ZabbixAPIRequest { + return &zabbix.ZabbixAPIRequest{ Method: method, Params: params, } } func MockZabbixDataSource(body string, statusCode int) *ZabbixDatasourceInstance { - zabbixAPI, _ := zabbixapi.MockZabbixAPI(body, statusCode) zabbixSettings, _ := readZabbixSettings(basicDatasourceInfo) + zabbixClient, _ := zabbix.MockZabbixClient(basicDatasourceInfo, body, statusCode) return &ZabbixDatasourceInstance{ dsInfo: basicDatasourceInfo, - zabbixAPI: zabbixAPI, + zabbix: zabbixClient, Settings: zabbixSettings, queryCache: NewDatasourceCache(cache.NoExpiration, 10*time.Minute), logger: log.New(), @@ -56,26 +56,26 @@ func MockZabbixDataSource(body string, statusCode int) *ZabbixDatasourceInstance } func MockZabbixDataSourceResponse(dsInstance *ZabbixDatasourceInstance, body string, statusCode int) *ZabbixDatasourceInstance { - zabbixAPI, _ := zabbixapi.MockZabbixAPI(body, statusCode) - dsInstance.zabbixAPI = zabbixAPI + zabbixClient, _ := zabbix.MockZabbixAPI(dsInstance.zabbix, body, statusCode) + dsInstance.zabbix = zabbixClient return dsInstance } func TestLogin(t *testing.T) { dsInstance := MockZabbixDataSource(`{"result":"secretauth"}`, 200) - err := dsInstance.login(context.Background()) + err := dsInstance.zabbix.Login(context.Background()) assert.Nil(t, err) - assert.Equal(t, "secretauth", dsInstance.zabbixAPI.GetAuth()) + assert.Equal(t, "secretauth", dsInstance.zabbix.GetAPI().GetAuth()) } func TestLoginError(t *testing.T) { dsInstance := MockZabbixDataSource(`{"result":""}`, 500) - err := dsInstance.login(context.Background()) + err := dsInstance.zabbix.Login(context.Background()) assert.NotNil(t, err) - assert.Equal(t, "", dsInstance.zabbixAPI.GetAuth()) + assert.Equal(t, "", dsInstance.zabbix.GetAPI().GetAuth()) } func TestZabbixAPIQuery(t *testing.T) { diff --git a/pkg/zabbix/cache.go b/pkg/zabbix/cache.go new file mode 100644 index 0000000..70f1ab3 --- /dev/null +++ b/pkg/zabbix/cache.go @@ -0,0 +1,55 @@ +package zabbix + +import ( + "crypto/sha1" + "encoding/hex" + "time" + + "github.com/alexanderzobnin/grafana-zabbix/pkg/cache" +) + +var cachedMethods = map[string]bool{ + "hostgroup.get": true, + "host.get": true, + "application.get": true, + "item.get": true, + "service.get": true, + "usermacro.get": true, + "proxy.get": true, +} + +func IsCachedRequest(method string) bool { + _, ok := cachedMethods[method] + return ok +} + +// ZabbixCache is a cache for datasource instance. +type ZabbixCache struct { + cache *cache.Cache +} + +// NewZabbixCache creates a DatasourceCache with expiration(ttl) time and cleanupInterval. +func NewZabbixCache(ttl time.Duration, cleanupInterval time.Duration) *ZabbixCache { + return &ZabbixCache{ + cache.NewCache(ttl, cleanupInterval), + } +} + +// GetAPIRequest gets request response from cache +func (c *ZabbixCache) GetAPIRequest(request *ZabbixAPIRequest) (interface{}, bool) { + requestHash := HashString(request.String()) + return c.cache.Get(requestHash) +} + +// SetAPIRequest writes request response to cache +func (c *ZabbixCache) SetAPIRequest(request *ZabbixAPIRequest, response interface{}) { + requestHash := HashString(request.String()) + c.cache.Set(requestHash, response) +} + +// HashString converts the given text string to hash string +func HashString(text string) string { + hash := sha1.New() + hash.Write([]byte(text)) + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/pkg/zabbix/methods.go b/pkg/zabbix/methods.go new file mode 100644 index 0000000..ca98b72 --- /dev/null +++ b/pkg/zabbix/methods.go @@ -0,0 +1,225 @@ +package zabbix + +import ( + "context" + "encoding/json" + + "github.com/bitly/go-simplejson" +) + +func (ds *Zabbix) GetItems(ctx context.Context, groupFilter string, hostFilter string, appFilter string, itemFilter string, itemType string) (Items, error) { + hosts, err := ds.GetHosts(ctx, groupFilter, hostFilter) + if err != nil { + return nil, err + } + var hostids []string + for _, k := range hosts { + hostids = append(hostids, k["hostid"].(string)) + } + + apps, err := ds.GetApps(ctx, groupFilter, hostFilter, appFilter) + // Apps not supported in Zabbix 5.4 and higher + if isAppMethodNotFoundError(err) { + apps = []map[string]interface{}{} + } else if err != nil { + return nil, err + } + var appids []string + for _, l := range apps { + appids = append(appids, l["applicationid"].(string)) + } + + var allItems *simplejson.Json + if len(hostids) > 0 { + allItems, err = ds.GetAllItems(ctx, hostids, nil, itemType) + } else if len(appids) > 0 { + allItems, err = ds.GetAllItems(ctx, nil, appids, itemType) + } + + var items Items + + if allItems == nil { + items = Items{} + } else { + itemsJSON, err := allItems.MarshalJSON() + if err != nil { + return nil, err + } + + err = json.Unmarshal(itemsJSON, &items) + if err != nil { + return nil, err + } + } + + re, err := parseFilter(itemFilter) + if err != nil { + return nil, err + } + + filteredItems := Items{} + for _, item := range items { + itemName := item.ExpandItem() + if item.Status == "0" { + if re != nil { + if re.MatchString(itemName) { + filteredItems = append(filteredItems, item) + } + } else if itemName == itemFilter { + filteredItems = append(filteredItems, item) + } + } + } + return filteredItems, nil +} + +func (ds *Zabbix) GetApps(ctx context.Context, groupFilter string, hostFilter string, appFilter string) ([]map[string]interface{}, error) { + hosts, err := ds.GetHosts(ctx, groupFilter, hostFilter) + if err != nil { + return nil, err + } + var hostids []string + for _, k := range hosts { + hostids = append(hostids, k["hostid"].(string)) + } + allApps, err := ds.GetAllApps(ctx, hostids) + if err != nil { + return nil, err + } + + re, err := parseFilter(appFilter) + if err != nil { + return nil, err + } + + var apps []map[string]interface{} + for _, i := range allApps.MustArray() { + name := i.(map[string]interface{})["name"].(string) + if re != nil { + if re.MatchString(name) { + apps = append(apps, i.(map[string]interface{})) + } + } else if name == appFilter { + apps = append(apps, i.(map[string]interface{})) + } + } + return apps, nil +} + +func (ds *Zabbix) GetHosts(ctx context.Context, groupFilter string, hostFilter string) ([]map[string]interface{}, error) { + groups, err := ds.GetGroups(ctx, groupFilter) + if err != nil { + return nil, err + } + var groupids []string + for _, k := range groups { + groupids = append(groupids, k["groupid"].(string)) + } + allHosts, err := ds.GetAllHosts(ctx, groupids) + if err != nil { + return nil, err + } + + re, err := parseFilter(hostFilter) + if err != nil { + return nil, err + } + + var hosts []map[string]interface{} + for _, i := range allHosts.MustArray() { + name := i.(map[string]interface{})["name"].(string) + if re != nil { + if re.MatchString(name) { + hosts = append(hosts, i.(map[string]interface{})) + } + } else if name == hostFilter { + hosts = append(hosts, i.(map[string]interface{})) + } + + } + + return hosts, nil +} + +func (ds *Zabbix) GetGroups(ctx context.Context, groupFilter string) ([]map[string]interface{}, error) { + allGroups, err := ds.GetAllGroups(ctx) + if err != nil { + return nil, err + } + re, err := parseFilter(groupFilter) + if err != nil { + return nil, err + } + + var groups []map[string]interface{} + for _, i := range allGroups.MustArray() { + name := i.(map[string]interface{})["name"].(string) + if re != nil { + if re.MatchString(name) { + groups = append(groups, i.(map[string]interface{})) + } + } else if name == groupFilter { + groups = append(groups, i.(map[string]interface{})) + } + } + return groups, nil +} + +func (ds *Zabbix) GetAllItems(ctx context.Context, hostids []string, appids []string, itemtype string) (*simplejson.Json, error) { + params := ZabbixAPIParams{ + "output": []string{"itemid", "name", "key_", "value_type", "hostid", "status", "state"}, + "sortfield": "name", + "webitems": true, + "filter": map[string]interface{}{}, + "selectHosts": []string{"hostid", "name"}, + "hostids": hostids, + "applicationids": appids, + } + + filter := params["filter"].(map[string]interface{}) + if itemtype == "num" { + filter["value_type"] = []int{0, 3} + } else if itemtype == "text" { + filter["value_type"] = []int{1, 2, 4} + } + + return ds.Request(ctx, &ZabbixAPIRequest{Method: "item.get", Params: params}) +} + +func (ds *Zabbix) GetAllApps(ctx context.Context, hostids []string) (*simplejson.Json, error) { + params := ZabbixAPIParams{ + "output": "extend", + "hostids": hostids, + } + + return ds.Request(ctx, &ZabbixAPIRequest{Method: "application.get", Params: params}) +} + +func (ds *Zabbix) GetAllHosts(ctx context.Context, groupids []string) (*simplejson.Json, error) { + params := ZabbixAPIParams{ + "output": []string{"name", "host"}, + "sortfield": "name", + "groupids": groupids, + } + + return ds.Request(ctx, &ZabbixAPIRequest{Method: "host.get", Params: params}) +} + +func (ds *Zabbix) GetAllGroups(ctx context.Context) (*simplejson.Json, error) { + params := ZabbixAPIParams{ + "output": []string{"name"}, + "sortfield": "name", + "real_hosts": true, + } + + return ds.Request(ctx, &ZabbixAPIRequest{Method: "hostgroup.get", Params: params}) +} + +func isAppMethodNotFoundError(err error) bool { + if err == nil { + return false + } + + message := err.Error() + return message == `Method not found. Incorrect API "application".` +} diff --git a/pkg/zabbix/models.go b/pkg/zabbix/models.go new file mode 100644 index 0000000..707f416 --- /dev/null +++ b/pkg/zabbix/models.go @@ -0,0 +1,76 @@ +package zabbix + +import ( + "encoding/json" + "time" +) + +type ZabbixDatasourceSettingsDTO struct { + Trends bool `json:"trends"` + TrendsFrom string `json:"trendsFrom"` + TrendsRange string `json:"trendsRange"` + CacheTTL string `json:"cacheTTL"` + Timeout string `json:"timeout"` + + DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` +} + +type ZabbixDatasourceSettings struct { + Trends bool + TrendsFrom time.Duration + TrendsRange time.Duration + CacheTTL time.Duration + Timeout time.Duration + + DisableReadOnlyUsersAck bool `json:"disableReadOnlyUsersAck"` +} + +type ZabbixAPIParams = map[string]interface{} + +type ZabbixAPIRequest struct { + Method string `json:"method"` + Params ZabbixAPIParams `json:"params,omitempty"` +} + +func (r *ZabbixAPIRequest) String() string { + jsonRequest, _ := json.Marshal(r.Params) + return r.Method + string(jsonRequest) +} + +type Items []Item + +type Item struct { + ID string `json:"itemid,omitempty"` + Key string `json:"key_,omitempty"` + Name string `json:"name,omitempty"` + ValueType int `json:"value_type,omitempty,string"` + HostID string `json:"hostid,omitempty"` + Hosts []ItemHost `json:"hosts,omitempty"` + Status string `json:"status,omitempty"` + State string `json:"state,omitempty"` +} + +type ItemHost struct { + ID string `json:"hostid,omitempty"` + Name string `json:"name,omitempty"` +} + +type Trend []TrendPoint + +type TrendPoint struct { + ItemID string `json:"itemid,omitempty"` + Clock int64 `json:"clock,omitempty,string"` + Num string `json:"num,omitempty"` + ValueMin string `json:"value_min,omitempty"` + ValueAvg string `json:"value_avg,omitempty"` + ValueMax string `json:"value_max,omitempty"` +} + +type History []HistoryPoint + +type HistoryPoint struct { + ItemID string `json:"itemid,omitempty"` + Clock int64 `json:"clock,omitempty,string"` + Value float64 `json:"value,omitempty,string"` + NS int64 `json:"ns,omitempty,string"` +} diff --git a/pkg/zabbix/settings.go b/pkg/zabbix/settings.go new file mode 100644 index 0000000..0b6d421 --- /dev/null +++ b/pkg/zabbix/settings.go @@ -0,0 +1,64 @@ +package zabbix + +import ( + "encoding/json" + "errors" + "strconv" + "time" + + "github.com/alexanderzobnin/grafana-zabbix/pkg/gtime" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func readZabbixSettings(dsInstanceSettings *backend.DataSourceInstanceSettings) (*ZabbixDatasourceSettings, error) { + zabbixSettingsDTO := &ZabbixDatasourceSettingsDTO{} + + err := json.Unmarshal(dsInstanceSettings.JSONData, &zabbixSettingsDTO) + if err != nil { + return nil, err + } + + if zabbixSettingsDTO.TrendsFrom == "" { + zabbixSettingsDTO.TrendsFrom = "7d" + } + if zabbixSettingsDTO.TrendsRange == "" { + zabbixSettingsDTO.TrendsRange = "4d" + } + if zabbixSettingsDTO.CacheTTL == "" { + zabbixSettingsDTO.CacheTTL = "1h" + } + + if zabbixSettingsDTO.Timeout == "" { + zabbixSettingsDTO.Timeout = "30" + } + + trendsFrom, err := gtime.ParseInterval(zabbixSettingsDTO.TrendsFrom) + if err != nil { + return nil, err + } + + trendsRange, err := gtime.ParseInterval(zabbixSettingsDTO.TrendsRange) + if err != nil { + return nil, err + } + + cacheTTL, err := gtime.ParseInterval(zabbixSettingsDTO.CacheTTL) + if err != nil { + return nil, err + } + + timeout, err := strconv.Atoi(zabbixSettingsDTO.Timeout) + if err != nil { + return nil, errors.New("failed to parse timeout: " + err.Error()) + } + + zabbixSettings := &ZabbixDatasourceSettings{ + Trends: zabbixSettingsDTO.Trends, + TrendsFrom: trendsFrom, + TrendsRange: trendsRange, + CacheTTL: cacheTTL, + Timeout: time.Duration(timeout) * time.Second, + } + + return zabbixSettings, nil +} diff --git a/pkg/zabbix/testing.go b/pkg/zabbix/testing.go new file mode 100644 index 0000000..48538c5 --- /dev/null +++ b/pkg/zabbix/testing.go @@ -0,0 +1,31 @@ +package zabbix + +import ( + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbixapi" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func MockZabbixClient(dsInfo *backend.DataSourceInstanceSettings, body string, statusCode int) (*Zabbix, error) { + zabbixAPI, err := zabbixapi.MockZabbixAPI(body, statusCode) + if err != nil { + return nil, err + } + + client, err := New(dsInfo, zabbixAPI) + if err != nil { + return nil, err + } + + return client, nil +} + +func MockZabbixAPI(client *Zabbix, body string, statusCode int) (*Zabbix, error) { + zabbixAPI, err := zabbixapi.MockZabbixAPI(body, statusCode) + if err != nil { + return nil, err + } + + client.api = zabbixAPI + + return client, nil +} diff --git a/pkg/zabbix/utils.go b/pkg/zabbix/utils.go new file mode 100644 index 0000000..88927df --- /dev/null +++ b/pkg/zabbix/utils.go @@ -0,0 +1,80 @@ +package zabbix + +import ( + "fmt" + "regexp" + "strings" +) + +func (item *Item) ExpandItem() string { + name := item.Name + key := item.Key + + if strings.Index(key, "[") == -1 { + return name + } + + keyRunes := []rune(item.Key) + keyParamsStr := string(keyRunes[strings.Index(key, "[")+1 : strings.LastIndex(key, "]")]) + keyParams := splitKeyParams(keyParamsStr) + + for i := len(keyParams); i >= 1; i-- { + name = strings.ReplaceAll(name, fmt.Sprintf("$%v", i), keyParams[i-1]) + } + + return name +} + +func splitKeyParams(paramStr string) []string { + paramRunes := []rune(paramStr) + params := []string{} + quoted := false + inArray := false + splitSymbol := "," + param := "" + + for _, r := range paramRunes { + symbol := string(r) + if symbol == `"` && inArray { + param += symbol + } else if symbol == `"` && quoted { + quoted = false + } else if symbol == `"` && !quoted { + quoted = true + } else if symbol == "[" && !quoted { + inArray = true + } else if symbol == "]" && !quoted { + inArray = false + } else if symbol == splitSymbol && !quoted && !inArray { + params = append(params, param) + param = "" + } else { + param += symbol + } + } + + params = append(params, param) + return params +} + +func parseFilter(filter string) (*regexp.Regexp, error) { + regex := regexp.MustCompile(`^/(.+)/(.*)$`) + flagRE := regexp.MustCompile("[imsU]+") + + matches := regex.FindStringSubmatch(filter) + if len(matches) <= 1 { + return nil, nil + } + + pattern := "" + if matches[2] != "" { + if flagRE.MatchString(matches[2]) { + pattern += "(?" + matches[2] + ")" + } else { + return nil, fmt.Errorf("error parsing regexp: unsupported flags `%s` (expected [imsU])", matches[2]) + } + } + pattern += matches[1] + + return regexp.Compile(pattern) +} diff --git a/pkg/zabbix/zabbix.go b/pkg/zabbix/zabbix.go new file mode 100644 index 0000000..8621672 --- /dev/null +++ b/pkg/zabbix/zabbix.go @@ -0,0 +1,135 @@ +package zabbix + +import ( + "context" + "strings" + "time" + + "github.com/alexanderzobnin/grafana-zabbix/pkg/zabbixapi" + "github.com/bitly/go-simplejson" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +// Zabbix is a wrapper for Zabbix API. It wraps Zabbix API queries and performs authentication, adds caching, +// deduplication and other performance optimizations. +type Zabbix struct { + api *zabbixapi.ZabbixAPI + dsInfo *backend.DataSourceInstanceSettings + cache *ZabbixCache + logger log.Logger +} + +// New returns new instance of Zabbix client. +func New(dsInfo *backend.DataSourceInstanceSettings, zabbixAPI *zabbixapi.ZabbixAPI) (*Zabbix, error) { + logger := log.New() + + zabbixSettings, err := readZabbixSettings(dsInfo) + if err != nil { + logger.Error("Error parsing Zabbix settings", "error", err) + return nil, err + } + + zabbixCache := NewZabbixCache(zabbixSettings.CacheTTL, 10*time.Minute) + + return &Zabbix{ + api: zabbixAPI, + dsInfo: dsInfo, + cache: zabbixCache, + logger: logger, + }, nil +} + +func (zabbix *Zabbix) GetAPI() *zabbixapi.ZabbixAPI { + return zabbix.api +} + +// Request wraps request with cache +func (ds *Zabbix) Request(ctx context.Context, apiReq *ZabbixAPIRequest) (*simplejson.Json, error) { + var resultJson *simplejson.Json + var err error + + cachedResult, queryExistInCache := ds.cache.GetAPIRequest(apiReq) + if !queryExistInCache { + resultJson, err = ds.request(ctx, apiReq.Method, apiReq.Params) + if err != nil { + return nil, err + } + + if IsCachedRequest(apiReq.Method) { + ds.logger.Debug("Writing result to cache", "method", apiReq.Method) + ds.cache.SetAPIRequest(apiReq, resultJson) + } + } else { + var ok bool + resultJson, ok = cachedResult.(*simplejson.Json) + if !ok { + resultJson = simplejson.New() + } + } + + return resultJson, nil +} + +// request checks authentication and makes a request to the Zabbix API. +func (zabbix *Zabbix) request(ctx context.Context, method string, params ZabbixAPIParams) (*simplejson.Json, error) { + zabbix.logger.Debug("Zabbix request", "method", method) + + // Skip auth for methods that are not required it + if method == "apiinfo.version" { + return zabbix.api.RequestUnauthenticated(ctx, method, params) + } + + result, err := zabbix.api.Request(ctx, method, params) + notAuthorized := isNotAuthorized(err) + if err == zabbixapi.ErrNotAuthenticated || notAuthorized { + if notAuthorized { + zabbix.logger.Debug("Authentication token expired, performing re-login") + } + err = zabbix.Login(ctx) + if err != nil { + return nil, err + } + return zabbix.request(ctx, method, params) + } else if err != nil { + return nil, err + } + + return result, err +} + +func (zabbix *Zabbix) Login(ctx context.Context) error { + jsonData, err := simplejson.NewJson(zabbix.dsInfo.JSONData) + if err != nil { + return err + } + + zabbixLogin := jsonData.Get("username").MustString() + var zabbixPassword string + if securePassword, exists := zabbix.dsInfo.DecryptedSecureJSONData["password"]; exists { + zabbixPassword = securePassword + } else { + // Fallback + zabbixPassword = jsonData.Get("password").MustString() + } + + err = zabbix.api.Authenticate(ctx, zabbixLogin, zabbixPassword) + if err != nil { + zabbix.logger.Error("Zabbix authentication error", "error", err) + return err + } + zabbix.logger.Debug("Successfully authenticated", "url", zabbix.api.GetUrl().String(), "user", zabbixLogin) + + return nil +} + +func isNotAuthorized(err error) bool { + if err == nil { + return false + } + + message := err.Error() + return strings.Contains(message, "Session terminated, re-login, please.") || + strings.Contains(message, "Not authorised.") || + strings.Contains(message, "Not authorized.") +} diff --git a/pkg/zabbixapi/zabbix_api.go b/pkg/zabbixapi/zabbix_api.go index 88310d1..167cf01 100644 --- a/pkg/zabbixapi/zabbix_api.go +++ b/pkg/zabbixapi/zabbix_api.go @@ -19,6 +19,7 @@ var ( ErrNotAuthenticated = errors.New("zabbix api: not authenticated") ) +// ZabbixAPI is a simple client responsible for making request to Zabbix API type ZabbixAPI struct { url *url.URL httpClient *http.Client