Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Provide New Relic scaler #2387

Merged
merged 20 commits into from
Jan 3, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions pkg/scalers/newrelic_scaler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package scalers

import (
"context"
"encoding/hex"
"fmt"
"log"
"strconv"

"crypto/md5"
kedautil "github.com/kedacore/keda/v2/pkg/util"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/metrics/pkg/apis/external_metrics"

v2beta2 "k8s.io/api/autoscaling/v2beta2"
"k8s.io/apimachinery/pkg/labels"
logf "sigs.k8s.io/controller-runtime/pkg/log"

"github.com/newrelic/newrelic-client-go/newrelic"
"github.com/newrelic/newrelic-client-go/pkg/nrdb"
marcelobartsch-jt marked this conversation as resolved.
Show resolved Hide resolved
)

const (
account = "account"
queryKey = "queryKey"
region = "region"
metricName = "metricName"
nrql = "nrql"
threshold = "threshold"
noDataErr = "noDataErr"
)

type newrelicScaler struct {
metadata *newrelicMetadata
nrClient *newrelic.NewRelic
}

type newrelicMetadata struct {
account int
region string
queryKey string
metricName string
noDataErr bool
nrql string
threshold int
scalerIndex int
}

var newrelicLog = logf.Log.WithName("new-relic_scaler")

func NewNewRelicScaler(config *ScalerConfig) (Scaler, error) {
meta, err := parseNewRelicMetadata(config)
if err != nil {
return nil, fmt.Errorf("error parsing new-relic metadata: %s", err)
}

nrClient, err := newrelic.New(
newrelic.ConfigPersonalAPIKey(meta.queryKey),
newrelic.ConfigRegion(meta.region))

if err != nil {
log.Fatal("error initializing client:", err)
}

logMsg := fmt.Sprintf("Initializing New Relic Scaler (account %d in region %s)", meta.account, meta.region)

newrelicLog.Info(logMsg)

return &newrelicScaler{
metadata: meta,
nrClient: nrClient}, nil
}

func parseNewRelicMetadata(config *ScalerConfig) (*newrelicMetadata, error) {
meta := newrelicMetadata{}
var err error
if val, ok := config.TriggerMetadata[account]; ok && val != "" {
t, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("error parsing %s: %s", account, err)
}
meta.account = t
} else {
return nil, fmt.Errorf("no %s given", account)
}

if val, ok := config.TriggerMetadata[nrql]; ok && val != "" {
meta.nrql = val
} else {
return nil, fmt.Errorf("no %s given", nrql)
}

meta.queryKey, err = GetFromAuthOrMeta(config, queryKey)
if err != nil {
return nil, fmt.Errorf("no %s given", queryKey)
}

if val, ok := config.TriggerMetadata[metricName]; ok && val != "" {
marcelobartsch-jt marked this conversation as resolved.
Show resolved Hide resolved
meta.metricName = val
}

meta.region, err = GetFromAuthOrMeta(config, region)
if err != nil {
meta.region = "US"
newrelicLog.Info("Using default 'US' region")
}

if val, ok := config.TriggerMetadata[threshold]; ok && val != "" {
t, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("error parsing %s", threshold)
}
meta.threshold = t
} else {
return nil, fmt.Errorf("missing %s value", threshold)
}

// If Query Return an Empty Data , shall we treat it as an error or not
// default is NO error is returned when query result is empty/no data
if val, ok := config.TriggerMetadata[noDataErr]; ok {
noDataErr, err := strconv.ParseBool(val)
if err != nil {
return nil, fmt.Errorf("noDataErr has invalid value")
}
meta.noDataErr = noDataErr
} else {
meta.noDataErr = false
}
zroubalik marked this conversation as resolved.
Show resolved Hide resolved
meta.scalerIndex = config.ScalerIndex
return &meta, nil
}

func (s *newrelicScaler) IsActive(ctx context.Context) (bool, error) {
val, err := s.ExecuteNewRelicQuery(ctx)
if err != nil {
newrelicLog.Error(err, "error executing newrelic query")
return false, err
}
return val > 0, nil
}

func (s *newrelicScaler) Close(context.Context) error {
return nil
}

func (s *newrelicScaler) ExecuteNewRelicQuery(ctx context.Context) (float64, error) {
nrdbQuery := nrdb.NRQL(s.metadata.nrql)
resp, err := s.nrClient.Nrdb.QueryWithContext(ctx, s.metadata.account, nrdbQuery)
if err != nil {
return 0, fmt.Errorf("error running NerdGraph query %s (%s)", s.metadata.nrql, err.Error())
}
// Only use the first result from the query, as the query should not be multi row
for _, v := range resp.Results[0] {
val, ok := v.(float64)
if ok {
return val, nil
}
}
if s.metadata.noDataErr {
return 0, fmt.Errorf("query return no results %s", s.metadata.nrql)
}
return 0, nil
}

func (s *newrelicScaler) GetMetrics(ctx context.Context, metricName string, metricSelector labels.Selector) ([]external_metrics.ExternalMetricValue, error) {
val, err := s.ExecuteNewRelicQuery(ctx)
if err != nil {
newrelicLog.Error(err, "error executing New Relic query")
return []external_metrics.ExternalMetricValue{}, err
}

metric := external_metrics.ExternalMetricValue{
MetricName: metricName,
Value: *resource.NewQuantity(int64(val), resource.DecimalSI),
Timestamp: metav1.Now(),
}

return append([]external_metrics.ExternalMetricValue{}, metric), nil
}

func (s *newrelicScaler) GetMetricSpecForScaling(context.Context) []v2beta2.MetricSpec {
targetMetricValue := resource.NewQuantity(int64(s.metadata.threshold), resource.DecimalSI)
var metricName string
if s.metadata.metricName == "" {
// TODO: Find a better way to generate a small string based on the nrql string, or maybe just use UUID?
hash := md5.Sum([]byte(s.metadata.nrql))
metricName = kedautil.NormalizeString(fmt.Sprintf("new-relic-%s", hex.EncodeToString(hash[:])))
} else {
metricName = kedautil.NormalizeString(fmt.Sprintf("new-relic-%s", s.metadata.metricName))
}
marcelobartsch-jt marked this conversation as resolved.
Show resolved Hide resolved
externalMetric := &v2beta2.ExternalMetricSource{
Metric: v2beta2.MetricIdentifier{
Name: GenerateMetricNameWithIndex(s.metadata.scalerIndex, metricName),
},
Target: v2beta2.MetricTarget{
Type: v2beta2.AverageValueMetricType,
AverageValue: targetMetricValue,
},
}
metricSpec := v2beta2.MetricSpec{
External: externalMetric, Type: externalMetricType,
}
return []v2beta2.MetricSpec{metricSpec}
}
82 changes: 82 additions & 0 deletions pkg/scalers/newrelic_scaler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package scalers

import (
"context"
"fmt"
"testing"
)

type parseNewRelicMetadataTestData struct {
metadata map[string]string
isError bool
}

type newrelicMetricIdentifier struct {
metadataTestData *parseNewRelicMetadataTestData
scalerIndex int
name string
}

var testNewRelicMetadata = []parseNewRelicMetadataTestData{
{map[string]string{}, true},
// all properly formed
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
// all properly formed
{map[string]string{"account": "0", "region": "EU", "metricName": "results", "threshold": "100", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
// account as String
{map[string]string{"account": "ABC", "metricName": "results", "threshold": "100", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, true},
marcelobartsch-jt marked this conversation as resolved.
Show resolved Hide resolved
// missing account
{map[string]string{"metricName": "results", "threshold": "100", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, true},
// missing metricName
{map[string]string{"account": "0", "threshold": "100", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
// malformed threshold
{map[string]string{"account": "0", "metricName": "results", "threshold": "one", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, true},
// missing threshold
{map[string]string{"account": "0", "metricName": "results", "queryKey": "somekey", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, true},
// missing query
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey"}, true},
// noDataErr invalid value
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "noDataErr": "invalid", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, true},
// noDataErr valid value
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "noDataErr": "true", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "noDataErr": "false", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "noDataErr": "0", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
{map[string]string{"account": "0", "metricName": "results", "threshold": "100", "queryKey": "somekey", "noDataErr": "1", "nrql": "SELECT average(cpuUsedCores) as result FROM K8sContainerSample WHERE containerName='coredns'"}, false},
}

var newrelicMetricIdentifiers = []newrelicMetricIdentifier{
{&testNewRelicMetadata[1], 0, "s0-new-relic-results"},
{&testNewRelicMetadata[1], 1, "s1-new-relic-results"},
}

func TestNewRelicParseMetadata(t *testing.T) {
for _, testData := range testNewRelicMetadata {
_, err := parseNewRelicMetadata(&ScalerConfig{TriggerMetadata: testData.metadata})
if err != nil && !testData.isError {
fmt.Printf("X: %s", testData.metadata)
t.Error("Expected success but got error", err)
}
if testData.isError && err == nil {
fmt.Printf("X: %s", testData.metadata)
t.Error("Expected error but got success")
}
}
}
func TestNewRelicGetMetricSpecForScaling(t *testing.T) {
for _, testData := range newrelicMetricIdentifiers {
meta, err := parseNewRelicMetadata(&ScalerConfig{TriggerMetadata: testData.metadataTestData.metadata, ScalerIndex: testData.scalerIndex})
if err != nil {
t.Fatal("Could not parse metadata:", err)
}
mockNewRelicScaler := newrelicScaler{
metadata: meta,
nrClient: nil,
}

metricSpec := mockNewRelicScaler.GetMetricSpecForScaling(context.Background())
metricName := metricSpec[0].External.Metric.Name
if metricName != testData.name {
t.Error("Wrong External metric source name:", metricName)
}
}
}
2 changes: 2 additions & 0 deletions pkg/scaling/scale_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ func buildScaler(ctx context.Context, client client.Client, triggerType string,
return scalers.NewMSSQLScaler(config)
case "mysql":
return scalers.NewMySQLScaler(config)
case "new-relic":
return scalers.NewNewRelicScaler(config)
case "openstack-metric":
return scalers.NewOpenstackMetricScaler(ctx, config)
case "openstack-swift":
Expand Down
Loading