-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add first scaler version Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * small refactor for response validation Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Add 'from' property, rename host/token Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Add parsing tests Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * update changelog Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Update CHANGELOG.md Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com> * Update values type to float64 Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com> * Remove unnecessary conversion Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com> * e2e tests Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Update dynatrace_test.go Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Fix bad templating for e2e tests Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Revert unnecessary (?) template variable change Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Apply suggestions from code review Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> * Update tests/scalers/dynatrace/dynatrace_test.go Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> * Do not allow token to be passed in scaledobject trigger Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Remove bad secret, tweak dynakube test config Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Rename property in response parsing Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Update tests/scalers/dynatrace/dynatrace_test.go Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> * use new operator secret, update template variable naming Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * forgotten correct variable definition Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * try default value in query for e2e tests Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * fix missing closing parenthesis, bad indenting Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> * Update e2e test to use custom metrics Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl> * Close the body to fix static checks Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl> * use declarative scaler config Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> --------- Signed-off-by: cyrilico <19289022+cyrilico@users.noreply.github.com> Signed-off-by: damas <19289022+cyrilico@users.noreply.github.com> Signed-off-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> Signed-off-by: Jorge Turrado <jorge.turrado@scrm.lidl> Co-authored-by: Jorge Turrado Ferrero <Jorge_turrado@hotmail.es> Co-authored-by: Jorge Turrado <jorge.turrado@scrm.lidl>
- Loading branch information
1 parent
03b6b83
commit 12a529d
Showing
5 changed files
with
503 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
package scalers | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
neturl "net/url" | ||
"strings" | ||
|
||
"github.com/go-logr/logr" | ||
v2 "k8s.io/api/autoscaling/v2" | ||
"k8s.io/metrics/pkg/apis/external_metrics" | ||
|
||
"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig" | ||
kedautil "github.com/kedacore/keda/v2/pkg/util" | ||
) | ||
|
||
const ( | ||
dynatraceMetricDataPointsAPI = "api/v2/metrics/query" | ||
) | ||
|
||
type dynatraceScaler struct { | ||
metricType v2.MetricTargetType | ||
metadata *dynatraceMetadata | ||
httpClient *http.Client | ||
logger logr.Logger | ||
} | ||
|
||
type dynatraceMetadata struct { | ||
Host string `keda:"name=host, order=triggerMetadata;authParams"` | ||
Token string `keda:"name=token, order=authParams"` | ||
MetricSelector string `keda:"name=metricSelector, order=triggerMetadata"` | ||
FromTimestamp string `keda:"name=from, order=triggerMetadata, default=now-2h, optional"` | ||
Threshold float64 `keda:"name=threshold, order=triggerMetadata"` | ||
ActivationThreshold float64 `keda:"name=activationThreshold, order=triggerMetadata, optional"` | ||
TriggerIndex int | ||
} | ||
|
||
// Model of relevant part of Dynatrace's Metric Data Points API Response | ||
// as per https://docs.dynatrace.com/docs/dynatrace-api/environment-api/metric-v2/get-data-points#definition--MetricData | ||
type dynatraceResponse struct { | ||
Result []struct { | ||
Data []struct { | ||
Values []float64 `json:"values"` | ||
} `json:"data"` | ||
} `json:"result"` | ||
} | ||
|
||
func NewDynatraceScaler(config *scalersconfig.ScalerConfig) (Scaler, error) { | ||
metricType, err := GetMetricTargetType(config) | ||
if err != nil { | ||
return nil, fmt.Errorf("error getting scaler metric type: %w", err) | ||
} | ||
|
||
logger := InitializeLogger(config, "dynatrace_scaler") | ||
|
||
meta, err := parseDynatraceMetadata(config) | ||
if err != nil { | ||
return nil, fmt.Errorf("error parsing dynatrace metadata: %w", err) | ||
} | ||
|
||
httpClient := kedautil.CreateHTTPClient(config.GlobalHTTPTimeout, false) | ||
|
||
logMsg := fmt.Sprintf("Initializing Dynatrace Scaler (Host: %s)", meta.Host) | ||
|
||
logger.Info(logMsg) | ||
|
||
return &dynatraceScaler{ | ||
metricType: metricType, | ||
metadata: meta, | ||
httpClient: httpClient, | ||
logger: logger}, nil | ||
} | ||
|
||
func parseDynatraceMetadata(config *scalersconfig.ScalerConfig) (*dynatraceMetadata, error) { | ||
meta := dynatraceMetadata{} | ||
|
||
meta.TriggerIndex = config.TriggerIndex | ||
if err := config.TypedConfig(&meta); err != nil { | ||
return nil, fmt.Errorf("error parsing dynatrace metadata: %w", err) | ||
} | ||
return &meta, nil | ||
} | ||
|
||
func (s *dynatraceScaler) Close(context.Context) error { | ||
if s.httpClient != nil { | ||
s.httpClient.CloseIdleConnections() | ||
} | ||
return nil | ||
} | ||
|
||
// Validate that response object contains the minimum expected structure | ||
// as per https://docs.dynatrace.com/docs/dynatrace-api/environment-api/metric-v2/get-data-points#definition--MetricData | ||
func validateDynatraceResponse(response *dynatraceResponse) error { | ||
if len(response.Result) == 0 { | ||
return errors.New("dynatrace response does not contain any results") | ||
} | ||
if len(response.Result[0].Data) == 0 { | ||
return errors.New("dynatrace response does not contain any metric series") | ||
} | ||
if len(response.Result[0].Data[0].Values) == 0 { | ||
return errors.New("dynatrace response does not contain any values for the metric series") | ||
} | ||
return nil | ||
} | ||
|
||
func (s *dynatraceScaler) GetMetricValue(ctx context.Context) (float64, error) { | ||
/* | ||
* Build request | ||
*/ | ||
var req *http.Request | ||
var err error | ||
|
||
// Append host information to appropriate API endpoint | ||
// Trailing slashes are removed from provided host information to avoid double slashes in the URL | ||
dynatraceAPIURL := fmt.Sprintf("%s/%s", strings.TrimRight(s.metadata.Host, "/"), dynatraceMetricDataPointsAPI) | ||
|
||
// Add query parameters to the URL | ||
url, _ := neturl.Parse(dynatraceAPIURL) | ||
queryString := url.Query() | ||
queryString.Set("metricSelector", s.metadata.MetricSelector) | ||
queryString.Set("from", s.metadata.FromTimestamp) | ||
url.RawQuery = queryString.Encode() | ||
|
||
req, err = http.NewRequestWithContext(ctx, "GET", url.String(), nil) | ||
if err != nil { | ||
return 0, err | ||
} | ||
|
||
// Authentication header as per https://docs.dynatrace.com/docs/dynatrace-api/basics/dynatrace-api-authentication#authenticate | ||
req.Header.Add("Authorization", fmt.Sprintf("Api-Token %s", s.metadata.Token)) | ||
|
||
/* | ||
* Execute request | ||
*/ | ||
r, err := s.httpClient.Do(req) | ||
if err != nil { | ||
return 0, err | ||
} | ||
defer r.Body.Close() | ||
|
||
if r.StatusCode != http.StatusOK { | ||
msg := fmt.Sprintf("%s: api returned %d", r.Request.URL.Path, r.StatusCode) | ||
return 0, errors.New(msg) | ||
} | ||
|
||
/* | ||
* Parse response | ||
*/ | ||
b, err := io.ReadAll(r.Body) | ||
if err != nil { | ||
return 0, err | ||
} | ||
var dynatraceResponse *dynatraceResponse | ||
err = json.Unmarshal(b, &dynatraceResponse) | ||
if err != nil { | ||
return -1, fmt.Errorf("unable to parse Dynatrace Metric Data Points API response: %w", err) | ||
} | ||
|
||
err = validateDynatraceResponse(dynatraceResponse) | ||
if err != nil { | ||
return 0, err | ||
} | ||
|
||
return dynatraceResponse.Result[0].Data[0].Values[0], nil | ||
} | ||
|
||
func (s *dynatraceScaler) GetMetricsAndActivity(ctx context.Context, metricName string) ([]external_metrics.ExternalMetricValue, bool, error) { | ||
val, err := s.GetMetricValue(ctx) | ||
|
||
if err != nil { | ||
s.logger.Error(err, "error executing Dynatrace query") | ||
return []external_metrics.ExternalMetricValue{}, false, err | ||
} | ||
|
||
metric := GenerateMetricInMili(metricName, val) | ||
|
||
return []external_metrics.ExternalMetricValue{metric}, val > s.metadata.ActivationThreshold, nil | ||
} | ||
|
||
func (s *dynatraceScaler) GetMetricSpecForScaling(context.Context) []v2.MetricSpec { | ||
externalMetric := &v2.ExternalMetricSource{ | ||
Metric: v2.MetricIdentifier{ | ||
Name: GenerateMetricNameWithIndex(s.metadata.TriggerIndex, kedautil.NormalizeString("dynatrace")), | ||
}, | ||
Target: GetMetricTargetMili(s.metricType, s.metadata.Threshold), | ||
} | ||
metricSpec := v2.MetricSpec{ | ||
External: externalMetric, Type: externalMetricType, | ||
} | ||
return []v2.MetricSpec{metricSpec} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
package scalers | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig" | ||
) | ||
|
||
type dynatraceMetadataTestData struct { | ||
metadata map[string]string | ||
authParams map[string]string | ||
errorCase bool | ||
} | ||
|
||
type dynatraceMetricIdentifier struct { | ||
metadataTestData *dynatraceMetadataTestData | ||
triggerIndex int | ||
name string | ||
} | ||
|
||
var testDynatraceMetadata = []dynatraceMetadataTestData{ | ||
{map[string]string{}, map[string]string{}, true}, | ||
// all properly formed | ||
{map[string]string{"threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, false}, | ||
// malformed threshold | ||
{map[string]string{"threshold": "abc", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true}, | ||
// malformed activationThreshold | ||
{map[string]string{"activationThreshold": "abc", "threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true}, | ||
// missing threshold | ||
{map[string]string{"metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true}, | ||
// missing metricsSelector | ||
{map[string]string{"threshold": "100"}, map[string]string{"host": "http://dummy:1234", "token": "dummy"}, true}, | ||
// missing token (must come from auth params) | ||
{map[string]string{"token": "foo", "threshold": "100", "from": "now-3d", "metricSelector": "MyCustomEvent:filter(eq(\"someProperty\",\"someValue\")):count:splitBy(\"dt.entity.process_group\"):fold"}, map[string]string{"host": "http://dummy:1234"}, true}, | ||
} | ||
|
||
var dynatraceMetricIdentifiers = []dynatraceMetricIdentifier{ | ||
{&testDynatraceMetadata[1], 0, "s0-dynatrace"}, | ||
{&testDynatraceMetadata[1], 1, "s1-dynatrace"}, | ||
} | ||
|
||
func TestDynatraceParseMetadata(t *testing.T) { | ||
for _, testData := range testDynatraceMetadata { | ||
_, err := parseDynatraceMetadata(&scalersconfig.ScalerConfig{TriggerMetadata: testData.metadata, AuthParams: testData.authParams}) | ||
if err != nil && !testData.errorCase { | ||
fmt.Printf("X: %s", testData.metadata) | ||
t.Error("Expected success but got error", err) | ||
} | ||
if testData.errorCase && err == nil { | ||
fmt.Printf("X: %s", testData.metadata) | ||
t.Error("Expected error but got success") | ||
} | ||
} | ||
} | ||
func TestDynatraceGetMetricSpecForScaling(t *testing.T) { | ||
for _, testData := range dynatraceMetricIdentifiers { | ||
meta, err := parseDynatraceMetadata(&scalersconfig.ScalerConfig{TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.metadataTestData.authParams, TriggerIndex: testData.triggerIndex}) | ||
if err != nil { | ||
t.Fatal("Could not parse metadata:", err) | ||
} | ||
mockNewRelicScaler := dynatraceScaler{ | ||
metadata: meta, | ||
httpClient: nil, | ||
} | ||
|
||
metricSpec := mockNewRelicScaler.GetMetricSpecForScaling(context.Background()) | ||
metricName := metricSpec[0].External.Metric.Name | ||
if metricName != testData.name { | ||
t.Error("Wrong External metric source name:", metricName) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.