diff --git a/operator/controllers/common/providers/common.go b/operator/controllers/common/providers/common.go index 40fa31f729..e617cfe69f 100644 --- a/operator/controllers/common/providers/common.go +++ b/operator/controllers/common/providers/common.go @@ -6,6 +6,7 @@ import ( ) const DynatraceProviderName = "dynatrace" +const DynatraceDQLProviderName = "dql" const PrometheusProviderName = "prometheus" const KeptnMetricProviderName = "keptn-metric" diff --git a/operator/controllers/common/providers/dynatrace/client/client.go b/operator/controllers/common/providers/dynatrace/client/client.go new file mode 100644 index 0000000000..663b9e0892 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/client.go @@ -0,0 +1,135 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-logr/logr" + "k8s.io/klog/v2" +) + +//go:generate moq -pkg fake --skip-ensure -out ./fake/dt_client_mock.go . DTAPIClient +type DTAPIClient interface { + Do(ctx context.Context, path, method string, payload []byte) ([]byte, error) +} + +type apiClient struct { + Log logr.Logger + httpClient http.Client + config apiConfig +} + +type APIClientOption func(client *apiClient) + +// WithLogger injects the given logger into an APIClient +func WithLogger(logger logr.Logger) APIClientOption { + return func(client *apiClient) { + client.Log = logger + } +} + +// WithHTTPClient injects the given HTTP client into an APIClient +func WithHTTPClient(httpClient http.Client) APIClientOption { + return func(client *apiClient) { + client.httpClient = httpClient + } +} + +// NewAPIClient creates and returns a new APIClient +func NewAPIClient(config apiConfig, options ...APIClientOption) *apiClient { + client := &apiClient{ + Log: logr.New(klog.NewKlogr().GetSink()), + httpClient: http.Client{}, + config: config, + } + + for _, o := range options { + o(client) + } + + return client +} + +// Do sends and API request to the Dynatrace API and returns its result as a string containing the raw response payload +func (client *apiClient) Do(ctx context.Context, path, method string, payload []byte) ([]byte, error) { + if err := client.auth(ctx); err != nil { + return nil, err + } + api := fmt.Sprintf("%s%s", client.config.serverURL, path) + req, err := http.NewRequestWithContext(ctx, method, api, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.config.oAuthCredentials.accessToken)) + + res, err := client.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + err := res.Body.Close() + if err != nil { + client.Log.Error(err, "Could not close request body") + } + }() + if isErrorStatus(res.StatusCode) { + return nil, ErrRequestFailed + } + b, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + return b, nil +} + +func (client *apiClient) auth(ctx context.Context) error { + // return if we already have a token + if client.config.oAuthCredentials.accessToken != "" { + return nil + } + client.Log.V(10).Info("OAuth login") + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + values := client.config.oAuthCredentials.urlValues() + body := []byte(values.Encode()) + + req, err := http.NewRequestWithContext(ctx, "POST", client.config.authURL, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + res, err := client.httpClient.Do(req) + if err != nil { + return err + } + defer func() { + err := res.Body.Close() + if err != nil { + client.Log.Error(err, "Could not close request body") + } + }() + if isErrorStatus(res.StatusCode) { + return ErrRequestFailed + } + // we ignore the error here because we fail later while unmarshalling + oauthResponse := OAuthResponse{} + b, _ := io.ReadAll(res.Body) + err = json.Unmarshal(b, &oauthResponse) + if err != nil { + return err + } + + if oauthResponse.AccessToken == "" { + return ErrAuthenticationFailed + } + + client.config.oAuthCredentials.accessToken = oauthResponse.AccessToken + return nil +} diff --git a/operator/controllers/common/providers/dynatrace/client/client_test.go b/operator/controllers/common/providers/dynatrace/client/client_test.go new file mode 100644 index 0000000000..a7e350ad47 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/client_test.go @@ -0,0 +1,170 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + "k8s.io/klog/v2" +) + +const mockSecret = "dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +func TestNewConfigInvalidSecretFormat(t *testing.T) { + + config, err := NewAPIConfig("", "my-secret") + + require.ErrorIs(t, err, ErrClientSecretInvalid) + require.Nil(t, config) +} + +func TestAPIClient(t *testing.T) { + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/auth" { + _, _ = writer.Write([]byte(`{"access_token": "my-token"}`)) + return + } + _, _ = writer.Write([]byte("success")) + })) + + defer server.Close() + + config, err := NewAPIConfig( + server.URL, + mockSecret, + WithScopes([]OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}), + WithAuthURL(server.URL+"/auth"), + ) + + require.Nil(t, err) + require.NotNil(t, config) + + apiClient := NewAPIClient( + *config, + WithHTTPClient(http.Client{}), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + require.NotNil(t, apiClient) + + resp, err := apiClient.Do(context.TODO(), "/query", http.MethodPost, nil) + + require.Nil(t, err) + require.Equal(t, "success", string(resp)) + + require.Equal(t, "my-token", apiClient.config.oAuthCredentials.accessToken) +} + +func TestAPIClientAuthError(t *testing.T) { + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + })) + + defer server.Close() + + mockSecret := "dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + config, err := NewAPIConfig( + server.URL, + mockSecret, + WithAuthURL(server.URL+"/auth"), + ) + + require.Nil(t, err) + require.NotNil(t, config) + + apiClient := NewAPIClient( + *config, + WithHTTPClient(http.Client{}), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + require.NotNil(t, apiClient) + + resp, err := apiClient.Do(context.TODO(), "/query", http.MethodPost, nil) + + require.ErrorIs(t, err, ErrRequestFailed) + require.Empty(t, resp) +} + +func TestAPIClientAuthNoToken(t *testing.T) { + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/auth" { + _, _ = writer.Write([]byte(`{"something": "else"}`)) + return + } + _, _ = writer.Write([]byte("success")) + })) + + defer server.Close() + + mockSecret := "dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + config, err := NewAPIConfig( + server.URL, + mockSecret, + WithAuthURL(server.URL+"/auth"), + ) + + require.Nil(t, err) + require.NotNil(t, config) + + apiClient := NewAPIClient( + *config, + WithHTTPClient(http.Client{}), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + require.NotNil(t, apiClient) + + resp, err := apiClient.Do(context.TODO(), "/query", http.MethodPost, nil) + + require.ErrorIs(t, err, ErrAuthenticationFailed) + require.Empty(t, resp) +} + +func TestAPIClientRequestError(t *testing.T) { + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/auth" { + _, _ = writer.Write([]byte(`{"access_token": "my-token"}`)) + return + } + writer.WriteHeader(http.StatusInternalServerError) + })) + + defer server.Close() + + mockSecret := "dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + config, err := NewAPIConfig( + server.URL, + mockSecret, + WithAuthURL(server.URL+"/auth"), + ) + + require.Nil(t, err) + require.NotNil(t, config) + + apiClient := NewAPIClient( + *config, + WithHTTPClient(http.Client{}), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + require.NotNil(t, apiClient) + + resp, err := apiClient.Do(context.TODO(), "/query", http.MethodPost, nil) + + // authentication should have worked + require.Equal(t, "my-token", apiClient.config.oAuthCredentials.accessToken) + + require.ErrorIs(t, err, ErrRequestFailed) + require.Empty(t, resp) +} diff --git a/operator/controllers/common/providers/dynatrace/client/common.go b/operator/controllers/common/providers/dynatrace/client/common.go new file mode 100644 index 0000000000..d734c341cf --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/common.go @@ -0,0 +1,45 @@ +package client + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" +) + +var ErrClientSecretInvalid = errors.New("the Dynatrace token has an invalid format") +var ErrRequestFailed = errors.New("the API returned a response with a status outside of the 2xx range") +var ErrAuthenticationFailed = errors.New("could not retrieve an OAuth token from the API") + +const ( + defaultAuthURL = "https://sso-dev.dynatracelabs.com/sso/oauth2/token" + oAuthGrantType = "grant_type" + oAuthGrantTypeClientCredentials = "client_credentials" + oAuthScope = "scope" + oAuthClientID = "client_id" + oAuthClientSecret = "client_secret" +) + +const dtTokenPrefix = "dt0s08" + +func validateOAuthSecret(token string) error { + // must start with dt0s08 + // must have 2 dots + // third part (split by dot) must be 64 chars + if !strings.HasPrefix(token, dtTokenPrefix) { + return fmt.Errorf("secret does not start with required prefix %s: %w", dtTokenPrefix, ErrClientSecretInvalid) + } + split := strings.Split(token, ".") + if len(split) != 3 { + return fmt.Errorf("secret does not contain three components: %w", ErrClientSecretInvalid) + } + secret := split[2] + if secretLen := len(secret); secretLen != 64 { + return fmt.Errorf("length of secret is not equal to 64: %w", ErrClientSecretInvalid) + } + return nil +} + +func isErrorStatus(statusCode int) bool { + return statusCode < 200 || statusCode >= 300 +} diff --git a/operator/controllers/common/providers/dynatrace/client/common_test.go b/operator/controllers/common/providers/dynatrace/client/common_test.go new file mode 100644 index 0000000000..be8a6ca86d --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/common_test.go @@ -0,0 +1,79 @@ +package client + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_validateOAuthSecret(t *testing.T) { + tests := []struct { + name string + input string + result error + }{ + { + name: "good token", + input: "dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + result: nil, + }, + { + name: "wrong prefix", + input: "", + result: ErrClientSecretInvalid, + }, + { + name: "wrong format", + input: "", + result: ErrClientSecretInvalid, + }, + { + name: "wrong secret part", + input: "", + result: ErrClientSecretInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOAuthSecret(tt.input) + + require.ErrorIs(t, err, tt.result) + }) + + } +} + +func Test_isErrorStatus(t *testing.T) { + type args struct { + statusCode int + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "is not an error", + args: args{ + statusCode: http.StatusOK, + }, + want: false, + }, + { + name: "is an error", + args: args{ + statusCode: http.StatusInternalServerError, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isErrorStatus(tt.args.statusCode); got != tt.want { + t.Errorf("isErrorStatus() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/operator/controllers/common/providers/dynatrace/client/config.go b/operator/controllers/common/providers/dynatrace/client/config.go new file mode 100644 index 0000000000..8ff7f50797 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/config.go @@ -0,0 +1,54 @@ +package client + +import ( + "fmt" + "strings" +) + +type apiConfig struct { + serverURL string + authURL string + oAuthCredentials oAuthCredentials +} + +type APIConfigOption func(config *apiConfig) + +func WithAuthURL(authURL string) APIConfigOption { + return func(config *apiConfig) { + config.authURL = authURL + } +} + +// WithScopes passes the given scopes to the client config +func WithScopes(scopes []OAuthScope) APIConfigOption { + return func(config *apiConfig) { + config.oAuthCredentials.scopes = scopes + } +} + +// NewAPIConfig returns a new apiConfig that can be used for initializing a DTAPIClient with the NewAPIClient function +func NewAPIConfig(serverURL string, secret string, opts ...APIConfigOption) (*apiConfig, error) { + if err := validateOAuthSecret(secret); err != nil { + return nil, err + } + + secretParts := strings.Split(secret, ".") + clientId := fmt.Sprintf("%s.%s", secretParts[0], secretParts[1]) + clientSecret := fmt.Sprintf("%s.%s", clientId, secretParts[2]) + + cfg := &apiConfig{ + serverURL: serverURL, + authURL: defaultAuthURL, + oAuthCredentials: oAuthCredentials{ + clientID: clientId, + clientSecret: clientSecret, + scopes: []OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}, + }, + } + + for _, o := range opts { + o(cfg) + } + + return cfg, nil +} diff --git a/operator/controllers/common/providers/dynatrace/client/config_test.go b/operator/controllers/common/providers/dynatrace/client/config_test.go new file mode 100644 index 0000000000..c704d95b30 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/config_test.go @@ -0,0 +1,30 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewAPIConfig(t *testing.T) { + config, err := NewAPIConfig( + "my-url", + mockSecret, + WithScopes([]OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}), + WithAuthURL("my-url/auth"), + ) + + require.Nil(t, err) + require.NotNil(t, config) + + expectedApiConfig := apiConfig{ + serverURL: "my-url", + authURL: "my-url/auth", + oAuthCredentials: oAuthCredentials{ + clientID: "dt0s08.XX", + clientSecret: mockSecret, + scopes: []OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}, + }, + } + require.Equal(t, expectedApiConfig, *config) +} diff --git a/operator/controllers/common/providers/dynatrace/client/fake/dt_client_mock.go b/operator/controllers/common/providers/dynatrace/client/fake/dt_client_mock.go new file mode 100644 index 0000000000..cc8e0fdb5f --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/fake/dt_client_mock.go @@ -0,0 +1,89 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package fake + +import ( + "context" + "sync" +) + +// DTAPIClientMock is a mock implementation of dynatrace.DTAPIClient. +// +// func TestSomethingThatUsesDTAPIClient(t *testing.T) { +// +// // make and configure a mocked dynatrace.DTAPIClient +// mockedDTAPIClient := &DTAPIClientMock{ +// DoFunc: func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { +// panic("mock out the Do method") +// }, +// } +// +// // use mockedDTAPIClient in code that requires dynatrace.DTAPIClient +// // and then make assertions. +// +// } +type DTAPIClientMock struct { + // DoFunc mocks the Do method. + DoFunc func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) + + // calls tracks calls to the methods. + calls struct { + // Do holds details about calls to the Do method. + Do []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Path is the path argument value. + Path string + // Method is the method argument value. + Method string + // Payload is the payload argument value. + Payload []byte + } + } + lockDo sync.RWMutex +} + +// Do calls DoFunc. +func (mock *DTAPIClientMock) Do(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { + if mock.DoFunc == nil { + panic("DTAPIClientMock.DoFunc: method is nil but DTAPIClient.Do was just called") + } + callInfo := struct { + Ctx context.Context + Path string + Method string + Payload []byte + }{ + Ctx: ctx, + Path: path, + Method: method, + Payload: payload, + } + mock.lockDo.Lock() + mock.calls.Do = append(mock.calls.Do, callInfo) + mock.lockDo.Unlock() + return mock.DoFunc(ctx, path, method, payload) +} + +// DoCalls gets all the calls that were made to Do. +// Check the length with: +// +// len(mockedDTAPIClient.DoCalls()) +func (mock *DTAPIClientMock) DoCalls() []struct { + Ctx context.Context + Path string + Method string + Payload []byte +} { + var calls []struct { + Ctx context.Context + Path string + Method string + Payload []byte + } + mock.lockDo.RLock() + calls = mock.calls.Do + mock.lockDo.RUnlock() + return calls +} diff --git a/operator/controllers/common/providers/dynatrace/client/oauth.go b/operator/controllers/common/providers/dynatrace/client/oauth.go new file mode 100644 index 0000000000..57d8a62137 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/oauth.go @@ -0,0 +1,47 @@ +package client + +import "net/url" + +// OAuthScope represents a scope provided for the registered OAuth client interacting with the DT API +type OAuthScope string + +// These constants define the scopes that we currently need for the DQL metric functionality. This list might extend as new features will be added. +// For now, we keep this at the minimum set of scopes required, as these are currently likely to change +const ( + OAuthScopeStorageMetricsRead OAuthScope = "storage:metrics:read" + OAuthScopeEnvironmentRoleViewer OAuthScope = "environment:roles:viewer" +) + +type oAuthCredentials struct { + clientID string + clientSecret string + scopes []OAuthScope + accessToken string +} + +func (oac oAuthCredentials) urlValues() url.Values { + values := url.Values{} + values.Add(oAuthGrantType, oAuthGrantTypeClientCredentials) + values.Add(oAuthScope, oac.getScopesAsString()) + values.Add(oAuthClientID, oac.clientID) + values.Add(oAuthClientSecret, oac.clientSecret) + + return values +} + +func (oac oAuthCredentials) getScopesAsString() string { + scopeStr := "" + + for i := 0; i < len(oac.scopes); i++ { + if i == 0 { + scopeStr += string(oac.scopes[i]) + } else { + scopeStr += " " + string(oac.scopes[i]) + } + } + return scopeStr +} + +type OAuthResponse struct { + AccessToken string `json:"access_token"` +} diff --git a/operator/controllers/common/providers/dynatrace/client/oauth_test.go b/operator/controllers/common/providers/dynatrace/client/oauth_test.go new file mode 100644 index 0000000000..29745755a0 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/client/oauth_test.go @@ -0,0 +1,36 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_oAuthCredentials_getScopesAsString(t *testing.T) { + oAuth := oAuthCredentials{ + scopes: []OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}, + } + + require.Equal(t, "storage:metrics:read environment:roles:viewer", oAuth.getScopesAsString()) +} + +func Test_oAuthCredentials_getScopesAsStringEmptyScopes(t *testing.T) { + oAuth := oAuthCredentials{} + + require.Equal(t, "", oAuth.getScopesAsString()) +} + +func Test_oAuthCredentials_urlValues(t *testing.T) { + oAuth := oAuthCredentials{ + clientID: "client-id", + clientSecret: "client-secret", + scopes: []OAuthScope{OAuthScopeStorageMetricsRead, OAuthScopeEnvironmentRoleViewer}, + } + + urlValues := oAuth.urlValues() + + require.Equal(t, "client-id", urlValues.Get(oAuthClientID)) + require.Equal(t, "client-secret", urlValues.Get(oAuthClientSecret)) + require.Equal(t, oAuthGrantTypeClientCredentials, urlValues.Get(oAuthGrantType)) + require.Equal(t, "storage:metrics:read environment:roles:viewer", urlValues.Get(oAuthScope)) +} diff --git a/operator/controllers/common/providers/dynatrace/common.go b/operator/controllers/common/providers/dynatrace/common.go new file mode 100644 index 0000000000..d0aa5dd12f --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/common.go @@ -0,0 +1,32 @@ +package dynatrace + +import ( + "context" + "errors" + "fmt" + + klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ErrSecretKeyRefNotDefined = errors.New("the SecretKeyRef property with the Dynatrace token is missing") +var ErrInvalidResult = errors.New("the answer does not contain any data") +var ErrDQLQueryTimeout = errors.New("timed out waiting for result of DQL query") + +func getDTSecret(ctx context.Context, provider klcv1alpha2.KeptnEvaluationProvider, k8sClient client.Client) (string, error) { + if !provider.HasSecretDefined() { + return "", ErrSecretKeyRefNotDefined + } + dtCredsSecret := &corev1.Secret{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: provider.Spec.SecretKeyRef.Name, Namespace: provider.Namespace}, dtCredsSecret); err != nil { + return "", err + } + + token := dtCredsSecret.Data[provider.Spec.SecretKeyRef.Key] + if len(token) == 0 { + return "", fmt.Errorf("secret contains invalid key %s", provider.Spec.SecretKeyRef.Key) + } + return string(token), nil +} diff --git a/operator/controllers/common/providers/dynatrace/common_test.go b/operator/controllers/common/providers/dynatrace/common_test.go new file mode 100644 index 0000000000..8414010435 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/common_test.go @@ -0,0 +1,69 @@ +package dynatrace + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/fake" + "github.com/stretchr/testify/require" +) + +func TestGetSecret_NoKeyDefined(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(dtpayload)) + require.Nil(t, err) + })) + defer svr.Close() + fakeClient := fake.NewClient() + + p := klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{ + TargetServer: svr.URL, + }, + } + r, e := getDTSecret(context.TODO(), p, fakeClient) + require.NotNil(t, e) + require.ErrorIs(t, e, ErrSecretKeyRefNotDefined) + require.Empty(t, r) +} + +func TestGetSecret_NoSecret(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(dtpayload)) + require.Nil(t, err) + })) + defer svr.Close() + fakeClient := fake.NewClient() + + p := klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{ + TargetServer: svr.URL, + }, + } + r, e := getDTSecret(context.TODO(), p, fakeClient) + require.NotNil(t, e) + require.ErrorIs(t, e, ErrSecretKeyRefNotDefined) + require.Empty(t, r) +} + +func TestGetSecret_NoTokenData(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(dtpayload)) + require.Nil(t, err) + })) + defer svr.Close() + fakeClient := fake.NewClient() + + p := klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{ + TargetServer: svr.URL, + }, + } + r, e := getDTSecret(context.TODO(), p, fakeClient) + require.NotNil(t, e) + require.ErrorIs(t, e, ErrSecretKeyRefNotDefined) + require.Empty(t, r) +} diff --git a/operator/controllers/common/providers/dynatrace.go b/operator/controllers/common/providers/dynatrace/dynatrace.go similarity index 72% rename from operator/controllers/common/providers/dynatrace.go rename to operator/controllers/common/providers/dynatrace/dynatrace.go index 11866d43b5..f723f3d1dd 100644 --- a/operator/controllers/common/providers/dynatrace.go +++ b/operator/controllers/common/providers/dynatrace/dynatrace.go @@ -1,9 +1,8 @@ -package providers +package dynatrace import ( "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -11,15 +10,13 @@ import ( "github.com/go-logr/logr" klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" ) type KeptnDynatraceProvider struct { Log logr.Logger - httpClient http.Client - k8sClient client.Client + HttpClient http.Client + K8sClient client.Client } type DynatraceResponse struct { @@ -51,13 +48,13 @@ func (d *KeptnDynatraceProvider) EvaluateQuery(ctx context.Context, objective kl return "", nil, err } - token, err := d.getDTApiToken(ctx, provider) + token, err := getDTSecret(ctx, provider, d.K8sClient) if err != nil { return "", nil, err } req.Header.Set("Authorization", "Api-Token "+token) - res, err := d.httpClient.Do(req) + res, err := d.HttpClient.Do(req) if err != nil { d.Log.Error(err, "Error while creating request") return "", nil, err @@ -101,19 +98,3 @@ func (d *KeptnDynatraceProvider) getSingleValue(result DynatraceResponse) float6 } return sum / float64(count) } - -func (d *KeptnDynatraceProvider) getDTApiToken(ctx context.Context, provider klcv1alpha2.KeptnEvaluationProvider) (string, error) { - if !provider.HasSecretDefined() { - return "", errors.New("the SecretKeyRef property with the DT API token is missing") - } - dtCredsSecret := &corev1.Secret{} - if err := d.k8sClient.Get(ctx, types.NamespacedName{Name: provider.Spec.SecretKeyRef.Name, Namespace: provider.Namespace}, dtCredsSecret); err != nil { - return "", err - } - - apiToken := dtCredsSecret.Data[provider.Spec.SecretKeyRef.Key] - if len(apiToken) == 0 { - return "", fmt.Errorf("secret contains invalid key %s", provider.Spec.SecretKeyRef.Key) - } - return string(apiToken), nil -} diff --git a/operator/controllers/common/providers/dynatrace/dynatrace_dql.go b/operator/controllers/common/providers/dynatrace/dynatrace_dql.go new file mode 100644 index 0000000000..78bd8e9f0e --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/dynatrace_dql.go @@ -0,0 +1,199 @@ +package dynatrace + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/benbjohnson/clock" + "github.com/go-logr/logr" + klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" + dtclient "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/dynatrace/client" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const maxRetries = 5 +const retryFetchInterval = 10 * time.Second + +const dqlQuerySucceeded = "SUCCEEDED" + +type keptnDynatraceDQLProvider struct { + log logr.Logger + k8sClient client.Client + + dtClient dtclient.DTAPIClient + clock clock.Clock +} + +type DynatraceDQLHandler struct { + RequestToken string `json:"requestToken"` +} + +type DynatraceDQLResult struct { + State string `json:"state"` + Result DQLResult `json:"result,omitempty"` +} + +type DQLResult struct { + Records []DQLRecord `json:"records"` +} + +type DQLRecord struct { + Value DQLMetric `json:"value"` +} + +type DQLMetric struct { + Count int64 `json:"count"` + Sum float64 `json:"sum"` + Min float64 `json:"min"` + Avg float64 `json:"avg"` + Max float64 `json:"max"` +} + +type KeptnDynatraceDQLProviderOption func(provider *keptnDynatraceDQLProvider) + +func WithDTAPIClient(dtApiClient dtclient.DTAPIClient) KeptnDynatraceDQLProviderOption { + return func(provider *keptnDynatraceDQLProvider) { + provider.dtClient = dtApiClient + } +} + +func WithLogger(logger logr.Logger) KeptnDynatraceDQLProviderOption { + return func(provider *keptnDynatraceDQLProvider) { + provider.log = logger + } +} + +// NewKeptnDynatraceDQLProvider creates and returns a new KeptnDynatraceDQLProvider +func NewKeptnDynatraceDQLProvider(k8sClient client.Client, opts ...KeptnDynatraceDQLProviderOption) *keptnDynatraceDQLProvider { + provider := &keptnDynatraceDQLProvider{ + log: logr.New(klog.NewKlogr().GetSink()), + k8sClient: k8sClient, + clock: clock.New(), + } + + for _, o := range opts { + o(provider) + } + + return provider +} + +// EvaluateQuery fetches the SLI values from dynatrace provider +func (d *keptnDynatraceDQLProvider) EvaluateQuery(ctx context.Context, objective klcv1alpha2.Objective, provider klcv1alpha2.KeptnEvaluationProvider) (string, []byte, error) { + if err := d.ensureDTClientIsSetUp(ctx, provider); err != nil { + return "", nil, err + } + // submit DQL + dqlHandler, err := d.postDQL(ctx, objective.Query) + if err != nil { + d.log.Error(err, "Error while posting the DQL query", "query", objective.Query) + return "", nil, err + } + // attend result + results, err := d.getDQL(ctx, *dqlHandler) + if err != nil { + d.log.Error(err, "Error while waiting for DQL query", "query", dqlHandler) + return "", nil, err + } + // parse result + if len(results.Records) > 1 { + d.log.Info("More than a single result, the first one will be used") + } + if len(results.Records) == 0 { + return "", nil, ErrInvalidResult + } + r := fmt.Sprintf("%f", results.Records[0].Value.Avg) + b, err := json.Marshal(results) + if err != nil { + d.log.Error(err, "Error marshaling DQL results") + } + return r, b, nil +} + +func (d *keptnDynatraceDQLProvider) ensureDTClientIsSetUp(ctx context.Context, provider klcv1alpha2.KeptnEvaluationProvider) error { + // try to initialize the DT API Client if it has not been set in the options + if d.dtClient == nil { + secret, err := getDTSecret(ctx, provider, d.k8sClient) + if err != nil { + return err + } + config, err := dtclient.NewAPIConfig( + provider.Spec.TargetServer, + secret, + ) + if err != nil { + return err + } + d.dtClient = dtclient.NewAPIClient(*config, dtclient.WithLogger(d.log)) + } + return nil +} + +func (d *keptnDynatraceDQLProvider) postDQL(ctx context.Context, query string) (*DynatraceDQLHandler, error) { + d.log.V(10).Info("posting DQL") + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + values := url.Values{} + values.Add("query", query) + + path := fmt.Sprintf("/platform/storage/query/v0.7/query:execute?%s", values.Encode()) + + b, err := d.dtClient.Do(ctx, path, http.MethodPost, []byte(`{}`)) + if err != nil { + return nil, err + } + + dqlHandler := &DynatraceDQLHandler{} + err = json.Unmarshal(b, &dqlHandler) + if err != nil { + return nil, fmt.Errorf("could not unmarshal response %s: %w", string(b), err) + } + return dqlHandler, nil +} + +func (d *keptnDynatraceDQLProvider) getDQL(ctx context.Context, handler DynatraceDQLHandler) (*DQLResult, error) { + d.log.V(10).Info("posting DQL") + + for i := 0; i < maxRetries; i++ { + r, err := d.retrieveDQLResults(ctx, handler) + if err != nil { + return &DQLResult{}, err + } + if r.State == dqlQuerySucceeded { + return &r.Result, nil + } + d.log.V(10).Info("DQL not finished, got", "state", r.State) + <-d.clock.After(retryFetchInterval) + } + return nil, ErrDQLQueryTimeout +} + +func (d *keptnDynatraceDQLProvider) retrieveDQLResults(ctx context.Context, handler DynatraceDQLHandler) (*DynatraceDQLResult, error) { + d.log.V(10).Info("Getting DQL") + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + values := url.Values{} + values.Add("request-token", handler.RequestToken) + + path := fmt.Sprintf("/platform/storage/query/v0.7/query:poll?%s", values.Encode()) + + b, err := d.dtClient.Do(ctx, path, http.MethodGet, nil) + if err != nil { + return nil, err + } + + result := &DynatraceDQLResult{} + err = json.Unmarshal(b, &result) + if err != nil { + d.log.Error(err, "Error while parsing response") + return result, err + } + return result, nil +} diff --git a/operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go b/operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go new file mode 100644 index 0000000000..8ff0a51352 --- /dev/null +++ b/operator/controllers/common/providers/dynatrace/dynatrace_dql_test.go @@ -0,0 +1,242 @@ +package dynatrace + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/go-logr/logr" + klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/dynatrace/client/fake" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/klog/v2" + k8sfake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const dqlRequestHandler = `{"requestToken": "my-token"}` + +const dqlPayload = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.50,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" +const dqlPayloadNotFinished = "{\"state\":\"\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":36.50,\"min\":36.78336878333334,\"avg\":36.50,\"max\":36.50},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:11:00.000Z\",\"end\":\"2023-01-31T09:12:00.`00Z\"},\"Container\":\"frontend\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"632df64d-474c-4410-968d-666f639ad358\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,1]}]}}" + +const dqlPayloadTooManyItems = "{\"state\":\"SUCCEEDED\",\"result\":{\"records\":[{\"value\":{\"count\":1,\"sum\":6.293549483333334,\"min\":6.293549483333334,\"avg\":6.293549483333334,\"max\":6.293549483333334},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"loginservice\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"easytrade\",\"k8s.pod.uid\":\"fc084e57-11a0-4a95-b8a0-76191c31d839\"},{\"value\":{\"count\":1,\"sum\":1.0421756,\"min\":1.0421756,\"avg\":1.0421756,\"max\":1.0421756},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"frontendreverseproxy\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"easytrade\",\"k8s.pod.uid\":\"41b5d6e0-98fc-4dce-a1b4-bb269a03d72b\"},{\"value\":{\"count\":1,\"sum\":6.3881383000000005,\"min\":6.3881383000000005,\"avg\":6.3881383000000005,\"max\":6.3881383000000005},\"metric.key\":\"dt.containers.cpu.usage_user_milli_cores\",\"timeframe\":{\"start\":\"2023-01-31T09:07:00.000Z\",\"end\":\"2023-01-31T09:08:00.000Z\"},\"Container\":\"shippingservice\",\"host.name\":\"default-pool-349eb8c6-gccf\",\"k8s.namespace.name\":\"hipstershop\",\"k8s.pod.uid\":\"96fcf9d7-748a-47f7-b1b3-ca6427e20edd\"}],\"types\":[{\"mappings\":{\"value\":{\"type\":\"summary_stats\"},\"metric.key\":{\"type\":\"string\"},\"timeframe\":{\"type\":\"timeframe\"},\"Container\":{\"type\":\"string\"},\"host.name\":{\"type\":\"string\"},\"k8s.namespace.name\":{\"type\":\"string\"},\"k8s.pod.uid\":{\"type\":\"string\"}},\"indexRange\":[0,3]}]}}" + +//nolint:dupl +func TestGetDQL(t *testing.T) { + + mockClient := &fake.DTAPIClientMock{} + + mockClient.DoFunc = func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { + if strings.Contains(path, "query:execute") { + return []byte(dqlRequestHandler), nil + } + + if strings.Contains(path, "query:poll") { + return []byte(dqlPayload), nil + } + + return nil, errors.New("unexpected path") + } + + dqlProvider := NewKeptnDynatraceDQLProvider( + nil, + WithDTAPIClient(mockClient), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + result, raw, err := dqlProvider.EvaluateQuery(context.TODO(), klcv1alpha2.Objective{ + Name: "", + Query: "", + EvaluationTarget: "", + }, klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{}, + }) + + require.Nil(t, err) + require.NotEmpty(t, raw) + require.Equal(t, "36.500000", result) + + require.Len(t, mockClient.DoCalls(), 2) + require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") + require.Contains(t, mockClient.DoCalls()[1].Path, "query:poll") +} + +//nolint:dupl +func TestGetDQLMultipleRecords(t *testing.T) { + + mockClient := &fake.DTAPIClientMock{} + + mockClient.DoFunc = func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { + if strings.Contains(path, "query:execute") { + return []byte(dqlRequestHandler), nil + } + + if strings.Contains(path, "query:poll") { + return []byte(dqlPayloadTooManyItems), nil + } + + return nil, errors.New("unexpected path") + } + + dqlProvider := NewKeptnDynatraceDQLProvider( + nil, + WithDTAPIClient(mockClient), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + result, raw, err := dqlProvider.EvaluateQuery(context.TODO(), klcv1alpha2.Objective{ + Name: "", + Query: "", + EvaluationTarget: "", + }, klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{}, + }) + + require.Nil(t, err) + require.NotEmpty(t, raw) + require.Equal(t, "6.293549", result) + + require.Len(t, mockClient.DoCalls(), 2) + require.Contains(t, mockClient.DoCalls()[0].Path, "query:execute") + require.Contains(t, mockClient.DoCalls()[1].Path, "query:poll") +} + +func TestGetDQLTimeout(t *testing.T) { + + mockClient := &fake.DTAPIClientMock{} + + mockClient.DoFunc = func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { + if strings.Contains(path, "query:execute") { + return []byte(dqlRequestHandler), nil + } + + if strings.Contains(path, "query:poll") { + return []byte(dqlPayloadNotFinished), nil + } + + return nil, errors.New("unexpected path") + } + + dqlProvider := NewKeptnDynatraceDQLProvider( + nil, + WithDTAPIClient(mockClient), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + mockClock := clock.NewMock() + dqlProvider.clock = mockClock + + wg := sync.WaitGroup{} + + wg.Add(1) + + go func(wg *sync.WaitGroup) { + defer wg.Done() + result, raw, err := dqlProvider.EvaluateQuery(context.TODO(), klcv1alpha2.Objective{ + Name: "", + Query: "", + EvaluationTarget: "", + }, klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{}, + }) + + require.ErrorIs(t, err, ErrDQLQueryTimeout) + require.Empty(t, raw) + require.Empty(t, result) + }(&wg) + + // wait for the mockClient to be called at least one time before adding to the clock + require.Eventually(t, func() bool { + return len(mockClient.DoCalls()) > 0 + }, 5*time.Second, 100*time.Millisecond) + + mockClock.Add(retryFetchInterval * (maxRetries + 1)) + + require.Len(t, mockClient.DoCalls(), maxRetries+1) + + wg.Wait() +} + +func TestGetDQLCannotPostQuery(t *testing.T) { + + mockClient := &fake.DTAPIClientMock{} + + mockClient.DoFunc = func(ctx context.Context, path string, method string, payload []byte) ([]byte, error) { + if strings.Contains(path, "query:execute") { + return nil, errors.New("oops") + } + + return nil, errors.New("unexpected path") + } + + dqlProvider := NewKeptnDynatraceDQLProvider( + nil, + WithDTAPIClient(mockClient), + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + mockClock := clock.NewMock() + dqlProvider.clock = mockClock + + result, raw, err := dqlProvider.EvaluateQuery(context.TODO(), klcv1alpha2.Objective{ + Name: "", + Query: "", + EvaluationTarget: "", + }, klcv1alpha2.KeptnEvaluationProvider{ + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{}, + }) + + require.NotNil(t, err, err) + require.Empty(t, raw) + require.Empty(t, result) + + require.Len(t, mockClient.DoCalls(), 1) +} + +func TestDQLInitClientWithSecret(t *testing.T) { + + namespace := "keptn-lifecycle-toolkit-system" + + mySecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "my-key": []byte("dt0s08.XX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), + }, + Type: corev1.SecretTypeOpaque, + } + fakeClient := k8sfake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).WithObjects(mySecret).Build() + + dqlProvider := NewKeptnDynatraceDQLProvider( + fakeClient, + WithLogger(logr.New(klog.NewKlogr().GetSink())), + ) + + require.NotNil(t, dqlProvider) + + err := dqlProvider.ensureDTClientIsSetUp(context.TODO(), klcv1alpha2.KeptnEvaluationProvider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dql", + Namespace: namespace, + }, + Spec: klcv1alpha2.KeptnEvaluationProviderSpec{ + SecretKeyRef: corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "my-secret", + }, + Key: "my-key", + }, + }, + }) + + require.Nil(t, err) + require.NotNil(t, dqlProvider.dtClient) +} diff --git a/operator/controllers/common/providers/dynatrace_test.go b/operator/controllers/common/providers/dynatrace/dynatrace_test.go similarity index 95% rename from operator/controllers/common/providers/dynatrace_test.go rename to operator/controllers/common/providers/dynatrace/dynatrace_test.go index 1da95d978c..8dca6afc69 100644 --- a/operator/controllers/common/providers/dynatrace_test.go +++ b/operator/controllers/common/providers/dynatrace/dynatrace_test.go @@ -1,4 +1,4 @@ -package providers +package dynatrace import ( "context" @@ -116,9 +116,9 @@ func TestEvaluateQuery_CorrectHTTP(t *testing.T) { fakeClient := fake.NewClient() kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: query, @@ -160,9 +160,9 @@ func TestEvaluateQuery_WrongPayloadHandling(t *testing.T) { fakeClient := fake.NewClient(apiToken) kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: "myquery", @@ -193,9 +193,9 @@ func TestEvaluateQuery_MissingSecret(t *testing.T) { fakeClient := fake.NewClient() kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: "myquery", @@ -207,7 +207,7 @@ func TestEvaluateQuery_MissingSecret(t *testing.T) { } _, _, e := kdp.EvaluateQuery(context.TODO(), obj, p) require.NotNil(t, e) - require.True(t, strings.Contains(e.Error(), "the SecretKeyRef property with the DT API token is missing")) + require.ErrorIs(t, e, ErrSecretKeyRefNotDefined) } func TestEvaluateQuery_SecretNotFound(t *testing.T) { @@ -219,9 +219,9 @@ func TestEvaluateQuery_SecretNotFound(t *testing.T) { fakeClient := fake.NewClient() kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: "myquery", @@ -261,9 +261,9 @@ func TestEvaluateQuery_RefNotExistingKey(t *testing.T) { fakeClient := fake.NewClient(apiToken) kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: "myquery", @@ -305,9 +305,9 @@ func TestEvaluateQuery_HappyPath(t *testing.T) { fakeClient := fake.NewClient(apiToken) kdp := KeptnDynatraceProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), - k8sClient: fakeClient, + K8sClient: fakeClient, } obj := klcv1alpha2.Objective{ Query: "myquery", diff --git a/operator/controllers/common/providers/keptnmetric.go b/operator/controllers/common/providers/keptnmetric/keptnmetric.go similarity index 90% rename from operator/controllers/common/providers/keptnmetric.go rename to operator/controllers/common/providers/keptnmetric/keptnmetric.go index 747b39852d..6a418ffc2b 100644 --- a/operator/controllers/common/providers/keptnmetric.go +++ b/operator/controllers/common/providers/keptnmetric/keptnmetric.go @@ -1,4 +1,4 @@ -package providers +package keptnmetric import ( "context" @@ -13,13 +13,13 @@ import ( type KeptnMetricProvider struct { Log logr.Logger - k8sClient client.Client + K8sClient client.Client } // EvaluateQuery fetches the SLI values from KeptnMetric resource func (p *KeptnMetricProvider) EvaluateQuery(ctx context.Context, objective klcv1alpha2.Objective, provider klcv1alpha2.KeptnEvaluationProvider) (string, []byte, error) { metric := &metricsv1alpha1.KeptnMetric{} - if err := p.k8sClient.Get(ctx, types.NamespacedName{Name: objective.Name, Namespace: provider.Namespace}, metric); err != nil { + if err := p.K8sClient.Get(ctx, types.NamespacedName{Name: objective.Name, Namespace: provider.Namespace}, metric); err != nil { p.Log.Error(err, "Could not retrieve KeptnMetric") return "", nil, err } diff --git a/operator/controllers/common/providers/keptnmetric_test.go b/operator/controllers/common/providers/keptnmetric/keptnmetric_test.go similarity index 97% rename from operator/controllers/common/providers/keptnmetric_test.go rename to operator/controllers/common/providers/keptnmetric/keptnmetric_test.go index 83a37a096e..13b242c9c4 100644 --- a/operator/controllers/common/providers/keptnmetric_test.go +++ b/operator/controllers/common/providers/keptnmetric/keptnmetric_test.go @@ -1,4 +1,4 @@ -package providers +package keptnmetric import ( "context" @@ -66,7 +66,7 @@ func Test_keptnmetric(t *testing.T) { kmp := KeptnMetricProvider{ Log: ctrl.Log.WithName("testytest"), - k8sClient: client, + K8sClient: client, } obj := klcv1alpha2.Objective{ diff --git a/operator/controllers/common/providers/prometheus.go b/operator/controllers/common/providers/prometheus/prometheus.go similarity index 95% rename from operator/controllers/common/providers/prometheus.go rename to operator/controllers/common/providers/prometheus/prometheus.go index d5702d69cd..dbcdedde7a 100644 --- a/operator/controllers/common/providers/prometheus.go +++ b/operator/controllers/common/providers/prometheus/prometheus.go @@ -1,4 +1,4 @@ -package providers +package prometheus import ( "context" @@ -15,7 +15,7 @@ import ( type KeptnPrometheusProvider struct { Log logr.Logger - httpClient http.Client + HttpClient http.Client } // EvaluateQuery fetches the SLI values from prometheus provider @@ -25,7 +25,7 @@ func (r *KeptnPrometheusProvider) EvaluateQuery(ctx context.Context, objective k queryTime := time.Now().UTC() r.Log.Info("Running query: /api/v1/query?query=" + objective.Query + "&time=" + queryTime.String()) - client, err := promapi.NewClient(promapi.Config{Address: provider.Spec.TargetServer, Client: &r.httpClient}) + client, err := promapi.NewClient(promapi.Config{Address: provider.Spec.TargetServer, Client: &r.HttpClient}) if err != nil { return "", nil, err } diff --git a/operator/controllers/common/providers/prometheus_test.go b/operator/controllers/common/providers/prometheus/prometheus_test.go similarity index 99% rename from operator/controllers/common/providers/prometheus_test.go rename to operator/controllers/common/providers/prometheus/prometheus_test.go index b0d8a7aede..671cf94f48 100644 --- a/operator/controllers/common/providers/prometheus_test.go +++ b/operator/controllers/common/providers/prometheus/prometheus_test.go @@ -1,4 +1,4 @@ -package providers +package prometheus import ( "context" @@ -75,7 +75,7 @@ func Test_prometheus(t *testing.T) { defer svr.Close() kpp := KeptnPrometheusProvider{ - httpClient: http.Client{}, + HttpClient: http.Client{}, Log: ctrl.Log.WithName("testytest"), } obj := klcv1alpha2.Objective{ diff --git a/operator/controllers/common/providers/provider.go b/operator/controllers/common/providers/provider.go index a305811596..bb49fef818 100644 --- a/operator/controllers/common/providers/provider.go +++ b/operator/controllers/common/providers/provider.go @@ -8,6 +8,9 @@ import ( "github.com/go-logr/logr" klcv1alpha2 "github.com/keptn/lifecycle-toolkit/operator/apis/lifecycle/v1alpha2" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/dynatrace" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/keptnmetric" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/prometheus" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -20,20 +23,25 @@ type KeptnSLIProvider interface { func NewProvider(provider string, log logr.Logger, k8sClient client.Client) (KeptnSLIProvider, error) { switch strings.ToLower(provider) { case PrometheusProviderName: - return &KeptnPrometheusProvider{ - httpClient: http.Client{}, + return &prometheus.KeptnPrometheusProvider{ + HttpClient: http.Client{}, Log: log, }, nil case DynatraceProviderName: - return &KeptnDynatraceProvider{ - httpClient: http.Client{}, + return &dynatrace.KeptnDynatraceProvider{ + HttpClient: http.Client{}, Log: log, - k8sClient: k8sClient, + K8sClient: k8sClient, }, nil + case DynatraceDQLProviderName: + return dynatrace.NewKeptnDynatraceDQLProvider( + k8sClient, + dynatrace.WithLogger(log), + ), nil case KeptnMetricProviderName: - return &KeptnMetricProvider{ + return &keptnmetric.KeptnMetricProvider{ Log: log, - k8sClient: k8sClient, + K8sClient: k8sClient, }, nil default: return nil, fmt.Errorf("provider %s not supported", provider) diff --git a/operator/controllers/common/providers/provider_test.go b/operator/controllers/common/providers/provider_test.go index c70d0675a4..c8b2af5d3c 100644 --- a/operator/controllers/common/providers/provider_test.go +++ b/operator/controllers/common/providers/provider_test.go @@ -4,6 +4,9 @@ import ( "testing" "github.com/go-logr/logr" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/dynatrace" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/keptnmetric" + "github.com/keptn/lifecycle-toolkit/operator/controllers/common/providers/prometheus" "github.com/stretchr/testify/require" ) @@ -15,17 +18,17 @@ func TestFactory(t *testing.T) { }{ { name: PrometheusProviderName, - provider: &KeptnPrometheusProvider{}, + provider: &prometheus.KeptnPrometheusProvider{}, err: false, }, { name: DynatraceProviderName, - provider: &KeptnDynatraceProvider{}, + provider: &dynatrace.KeptnDynatraceProvider{}, err: false, }, { name: KeptnMetricProviderName, - provider: &KeptnMetricProvider{}, + provider: &keptnmetric.KeptnMetricProvider{}, err: false, }, { diff --git a/operator/controllers/metrics/keptnmetric_controller.go b/operator/controllers/metrics/keptnmetric_controller.go index d53ff86495..9f62f190c9 100644 --- a/operator/controllers/metrics/keptnmetric_controller.go +++ b/operator/controllers/metrics/keptnmetric_controller.go @@ -89,7 +89,7 @@ func (r *KeptnMetricReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{Requeue: true, RequeueAfter: 10 * time.Second}, nil } r.Log.Error(err, "Failed to retrieve the provider") - return ctrl.Result{}, nil + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } // load the provider provider, err2 := providers.NewProvider(metric.Spec.Provider.Name, r.Log, r.Client)