From 67d088dbe42ecd1810b31c132e24a3aa68dbadbe Mon Sep 17 00:00:00 2001 From: Pedro Felipe Dominguite Date: Wed, 16 Dec 2020 11:58:59 -0300 Subject: [PATCH] Add new OpenStack Swift scaler Signed-off-by: Pedro Felipe Dominguite * Add Swift scaler unit tests Signed-off-by: Pedro Felipe Dominguite * Change scaler name to openstack-swift Signed-off-by: Pedro Felipe Dominguite * Change scaler name references to openstackSwift * Add objectPrefix to metricName Signed-off-by: Pedro Felipe Dominguite * Use kedautil for handling http Signed-off-by: Pedro Felipe Dominguite * Add read stream error handler Signed-off-by: Pedro Felipe Dominguite * Update CHANGELOG.md Signed-off-by: Pedro Felipe Dominguite --- CHANGELOG.md | 3 +- .../openstack/keystone_authentication.go | 210 ++++++++++ pkg/scalers/openstack_swift_scaler.go | 382 ++++++++++++++++++ pkg/scalers/openstack_swift_scaler_test.go | 159 ++++++++ pkg/scaling/scale_handler.go | 2 + 5 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 pkg/scalers/openstack/keystone_authentication.go create mode 100644 pkg/scalers/openstack_swift_scaler.go create mode 100644 pkg/scalers/openstack_swift_scaler_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b55da0470..b6f9a151c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ - Can use Pod Identity with Azure Event Hub scaler ([#994](https://github.com/kedacore/keda/issues/994)) - Introducing InfluxDB scaler ([#1239](https://github.com/kedacore/keda/issues/1239)) - Add Redis cluster support for Redis list and Redis streams scalers ([#1437](https://github.com/kedacore/keda/pull/1437)) -- Global authentication credentials can be managed using ClusterTriggerAuthentication objects ([#1452](https://github.com/kedacore/keda/pull/1452),[#1486](https://github.com/kedacore/keda/pull/1486)) +- Global authentication credentials can be managed using ClusterTriggerAuthentication objects ([#1452](https://github.com/kedacore/keda/pull/1452)) +- Introducing OpenStack Swift scaler ([#1342](https://github.com/kedacore/keda/issues/1342)) ### Improvements - Support add ScaledJob's label to its job ([#1311](https://github.com/kedacore/keda/issues/1311)) diff --git a/pkg/scalers/openstack/keystone_authentication.go b/pkg/scalers/openstack/keystone_authentication.go new file mode 100644 index 00000000000..dd798916c13 --- /dev/null +++ b/pkg/scalers/openstack/keystone_authentication.go @@ -0,0 +1,210 @@ +package openstack + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" + "time" + + kedautil "github.com/kedacore/keda/v2/pkg/util" +) + +const tokensEndpoint = "/auth/tokens" + +// KeystoneAuthMetadata contains all the necessary metadata for Keystone authentication +type KeystoneAuthMetadata struct { + AuthURL string `json:"-"` + AuthToken string `json:"-"` + HTTPClient *http.Client `json:"-"` + Properties *authProps `json:"auth"` +} + +type authProps struct { + Identity *identityProps `json:"identity"` + Scope *scopeProps `json:"scope,omitempty"` +} + +type identityProps struct { + Methods []string `json:"methods"` + Password *passwordProps `json:"password,omitempty"` + AppCredential *appCredentialProps `json:"application_credential,omitempty"` +} + +type passwordProps struct { + User *userProps `json:"user"` +} + +type appCredentialProps struct { + ID string `json:"id"` + Secret string `json:"secret"` +} + +type scopeProps struct { + Project *projectProps `json:"project"` +} + +type userProps struct { + ID string `json:"id"` + Password string `json:"password"` +} + +type projectProps struct { + ID string `json:"id"` +} + +// GetToken retrieves a token from Keystone +func (authProps *KeystoneAuthMetadata) GetToken() (string, error) { + jsonBody, jsonError := json.Marshal(authProps) + + if jsonError != nil { + return "", jsonError + } + + body := bytes.NewReader(jsonBody) + + tokenURL, err := url.Parse(authProps.AuthURL) + + if err != nil { + return "", fmt.Errorf("the authURL is invalid: %s", err.Error()) + } + + tokenURL.Path = path.Join(tokenURL.Path, tokensEndpoint) + + getTokenRequest, getTokenRequestError := http.NewRequest("POST", tokenURL.String(), body) + + getTokenRequest.Header.Set("Content-Type", "application/json") + + if getTokenRequestError != nil { + return "", getTokenRequestError + } + + resp, requestError := authProps.HTTPClient.Do(getTokenRequest) + + if requestError != nil { + return "", requestError + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + authProps.AuthToken = resp.Header["X-Subject-Token"][0] + return resp.Header["X-Subject-Token"][0], nil + } + + errBody, readBodyErr := ioutil.ReadAll(resp.Body) + + if readBodyErr != nil { + return "", readBodyErr + } + + return "", fmt.Errorf(string(errBody)) +} + +// IsTokenValid checks if a authentication token is valid +func IsTokenValid(authProps KeystoneAuthMetadata) (bool, error) { + token := authProps.AuthToken + + tokenURL, err := url.Parse(authProps.AuthURL) + + if err != nil { + return false, fmt.Errorf("the authURL is invalid: %s", err.Error()) + } + + tokenURL.Path = path.Join(tokenURL.Path, tokensEndpoint) + + checkTokenRequest, checkRequestError := http.NewRequest("HEAD", tokenURL.String(), nil) + checkTokenRequest.Header.Set("X-Subject-Token", token) + checkTokenRequest.Header.Set("X-Auth-Token", token) + + if checkRequestError != nil { + return false, checkRequestError + } + + checkResp, requestError := authProps.HTTPClient.Do(checkTokenRequest) + + if requestError != nil { + return false, requestError + } + + defer checkResp.Body.Close() + + if checkResp.StatusCode >= 400 { + return false, nil + } + + return true, nil +} + +// NewPasswordAuth creates a struct containing metadata for authentication using password method +func NewPasswordAuth(authURL string, userID string, userPassword string, projectID string, httpTimeout int) (*KeystoneAuthMetadata, error) { + var tokenError error + + passAuth := new(KeystoneAuthMetadata) + + passAuth.Properties = new(authProps) + + passAuth.Properties.Scope = new(scopeProps) + passAuth.Properties.Scope.Project = new(projectProps) + + passAuth.Properties.Identity = new(identityProps) + passAuth.Properties.Identity.Password = new(passwordProps) + passAuth.Properties.Identity.Password.User = new(userProps) + + url, err := url.Parse(authURL) + + if err != nil { + return nil, fmt.Errorf("authURL is invalid: %s", err.Error()) + } + + url.Path = path.Join(url.Path, "") + + passAuth.AuthURL = url.String() + + passAuth.HTTPClient = kedautil.CreateHTTPClient(time.Duration(httpTimeout) * time.Second) + + passAuth.Properties.Identity.Methods = []string{"password"} + passAuth.Properties.Identity.Password.User.ID = userID + passAuth.Properties.Identity.Password.User.Password = userPassword + + passAuth.Properties.Scope.Project.ID = projectID + + passAuth.AuthToken, tokenError = passAuth.GetToken() + + return passAuth, tokenError +} + +// NewAppCredentialsAuth creates a struct containing metadata for authentication using application credentials method +func NewAppCredentialsAuth(authURL string, id string, secret string, httpTimeout int) (*KeystoneAuthMetadata, error) { + var tokenError error + + appAuth := new(KeystoneAuthMetadata) + + appAuth.Properties = new(authProps) + + appAuth.Properties.Identity = new(identityProps) + + url, err := url.Parse(authURL) + + if err != nil { + return nil, fmt.Errorf("authURL is invalid: %s", err.Error()) + } + + url.Path = path.Join(url.Path, "") + + appAuth.AuthURL = url.String() + + appAuth.HTTPClient = kedautil.CreateHTTPClient(time.Duration(httpTimeout) * time.Second) + + appAuth.Properties.Identity.AppCredential = new(appCredentialProps) + appAuth.Properties.Identity.Methods = []string{"application_credential"} + appAuth.Properties.Identity.AppCredential.ID = id + appAuth.Properties.Identity.AppCredential.Secret = secret + + appAuth.AuthToken, tokenError = appAuth.GetToken() + + return appAuth, tokenError +} diff --git a/pkg/scalers/openstack_swift_scaler.go b/pkg/scalers/openstack_swift_scaler.go new file mode 100644 index 00000000000..256838b0f33 --- /dev/null +++ b/pkg/scalers/openstack_swift_scaler.go @@ -0,0 +1,382 @@ +package scalers + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "github.com/kedacore/keda/v2/pkg/scalers/openstack" + kedautil "github.com/kedacore/keda/v2/pkg/util" + v2beta2 "k8s.io/api/autoscaling/v2beta2" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/metrics/pkg/apis/external_metrics" + + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + defaultOnlyFiles = false + defaultObjectCount = 2 + defaultObjectLimit = "" + defaultObjectPrefix = "" + defaultObjectDelimiter = "" + defaultHTTPClientTimeout = 30 +) + +type openstackSwiftMetadata struct { + swiftURL string + containerName string + objectCount int + objectPrefix string + objectDelimiter string + objectLimit string + httpClientTimeout int + onlyFiles bool +} + +type openstackSwiftAuthenticationMetadata struct { + userID string + password string + projectID string + authURL string + appCredentialID string + appCredentialSecret string +} + +type openstackSwiftScaler struct { + metadata *openstackSwiftMetadata + authMetadata *openstack.KeystoneAuthMetadata +} + +var openstackSwiftLog = logf.Log.WithName("openstack_swift_scaler") + +func (s *openstackSwiftScaler) getOpenstackSwiftContainerObjectCount() (int, error) { + var token string + var swiftURL string = s.metadata.swiftURL + var containerName string = s.metadata.containerName + + isValid, validationError := openstack.IsTokenValid(*s.authMetadata) + + if validationError != nil { + openstackSwiftLog.Error(validationError, "scaler could not validate the token for authentication") + return 0, validationError + } + + if !isValid { + var tokenRequestError error + token, tokenRequestError = s.authMetadata.GetToken() + s.authMetadata.AuthToken = token + if tokenRequestError != nil { + openstackSwiftLog.Error(tokenRequestError, "error requesting token for authentication") + return 0, tokenRequestError + } + } + + token = s.authMetadata.AuthToken + + swiftContainerURL, err := url.Parse(swiftURL) + + if err != nil { + openstackSwiftLog.Error(err, fmt.Sprintf("the swiftURL is invalid: %s. You might have forgotten to provide the either 'http' or 'https' in the URL. Check our documentation to see if you missed something", swiftURL)) + return 0, fmt.Errorf("the swiftURL is invalid: %s", err.Error()) + } + + swiftContainerURL.Path = path.Join(swiftContainerURL.Path, containerName) + + swiftRequest, _ := http.NewRequest("GET", swiftContainerURL.String(), nil) + + swiftRequest.Header.Set("X-Auth-Token", token) + + query := swiftRequest.URL.Query() + query.Add("prefix", s.metadata.objectPrefix) + query.Add("delimiter", s.metadata.objectDelimiter) + + // If scaler wants to scale based on only files, we first need to query all objects, then filter files and finally limit the result to the specified query limit + if !s.metadata.onlyFiles { + query.Add("limit", s.metadata.objectLimit) + } + + swiftRequest.URL.RawQuery = query.Encode() + + resp, requestError := s.authMetadata.HTTPClient.Do(swiftRequest) + + if requestError != nil { + openstackSwiftLog.Error(requestError, fmt.Sprintf("error getting metrics for container '%s'. You probably specified the wrong swift URL or the URL is not reachable", containerName)) + return 0, requestError + } + + defer resp.Body.Close() + + body, readError := ioutil.ReadAll(resp.Body) + + if readError != nil { + openstackSwiftLog.Error(readError, "could not read response body from Swift API") + return 0, readError + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + var objectsList = strings.Split(strings.TrimSpace(string(body)), "\n") + + // If onlyFiles is set to "true", return the total amount of files (excluding empty objects/folders) + if s.metadata.onlyFiles { + var count int = 0 + for i := 0; i < len(objectsList); i++ { + if !strings.HasSuffix(objectsList[i], "/") { + count++ + } + } + + if s.metadata.objectLimit != defaultObjectLimit { + objectLimit, conversionError := strconv.Atoi(s.metadata.objectLimit) + + if conversionError != nil { + openstackSwiftLog.Error(err, fmt.Sprintf("the objectLimit value provided is invalid: %v", s.metadata.objectLimit)) + return 0, conversionError + } + + if objectLimit <= count && s.metadata.objectLimit != defaultObjectLimit { + return objectLimit, nil + } + } + + return count, nil + } + + // Otherwise, if either prefix and/or delimiter are provided, return the total amount of objects + if s.metadata.objectPrefix != defaultObjectPrefix || s.metadata.objectDelimiter != defaultObjectDelimiter { + return len(objectsList), nil + } + + // Finally, if nothing is set, return the standard total amount of objects inside the container + objectCount, conversionError := strconv.Atoi(resp.Header["X-Container-Object-Count"][0]) + return objectCount, conversionError + } + + if resp.StatusCode == 401 { + openstackSwiftLog.Error(nil, "the retrieved token is not a valid token. Provide the correct auth credentials so the scaler can retrieve a valid access token (Unauthorized)") + return 0, fmt.Errorf("the retrieved token is not a valid token. Provide the correct auth credentials so the scaler can retrieve a valid access token (Unauthorized)") + } + + if resp.StatusCode == 403 { + openstackSwiftLog.Error(nil, "the retrieved token is a valid token, but it does not have sufficient permission to retrieve Swift and/or container metadata (Forbidden)") + return 0, fmt.Errorf("the retrieved token is a valid token, but it does not have sufficient permission to retrieve Swift and/or container metadata (Forbidden)") + } + + if resp.StatusCode == 404 { + openstackSwiftLog.Error(nil, fmt.Sprintf("the container '%s' does not exist (Not Found)", containerName)) + return 0, fmt.Errorf("the container '%s' does not exist (Not Found)", containerName) + } + + return 0, fmt.Errorf(string(body)) +} + +// NewOpenstackSwiftScaler creates a new swift scaler +func NewOpenstackSwiftScaler(config *ScalerConfig) (Scaler, error) { + var keystoneAuth *openstack.KeystoneAuthMetadata + + openstackSwiftMetadata, err := parseOpenstackSwiftMetadata(config) + + if err != nil { + return nil, fmt.Errorf("error parsing swift metadata: %s", err) + } + + authMetadata, err := parseOpenstackSwiftAuthenticationMetadata(config) + + if err != nil { + return nil, fmt.Errorf("error parsing swift authentication metadata: %s", err) + } + + // User chose the "application_credentials" authentication method + if authMetadata.appCredentialID != "" { + keystoneAuth, err = openstack.NewAppCredentialsAuth(authMetadata.authURL, authMetadata.appCredentialID, authMetadata.appCredentialSecret, openstackSwiftMetadata.httpClientTimeout) + if err != nil { + return nil, fmt.Errorf("error getting openstack credentials for application credentials method: %s", err) + } + } else { + // User chose the "password" authentication method + if authMetadata.userID != "" { + keystoneAuth, err = openstack.NewPasswordAuth(authMetadata.authURL, authMetadata.userID, authMetadata.password, authMetadata.projectID, openstackSwiftMetadata.httpClientTimeout) + if err != nil { + return nil, fmt.Errorf("error getting openstack credentials for password method: %s", err) + } + } else { + return nil, fmt.Errorf("no authentication method was provided for OpenStack") + } + } + + return &openstackSwiftScaler{ + metadata: openstackSwiftMetadata, + authMetadata: keystoneAuth, + }, nil +} + +func parseOpenstackSwiftMetadata(config *ScalerConfig) (*openstackSwiftMetadata, error) { + meta := openstackSwiftMetadata{} + + if val, ok := config.TriggerMetadata["swiftURL"]; ok { + meta.swiftURL = val + } else { + return nil, fmt.Errorf("no swiftURL given") + } + + if val, ok := config.TriggerMetadata["containerName"]; ok { + meta.containerName = val + } else { + return nil, fmt.Errorf("no containerName was provided") + } + + if val, ok := config.TriggerMetadata["objectCount"]; ok { + targetObjectCount, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("objectCount parsing error: %s", err.Error()) + } + meta.objectCount = targetObjectCount + } else { + meta.objectCount = defaultObjectCount + } + + if val, ok := config.TriggerMetadata["objectPrefix"]; ok { + meta.objectPrefix = val + } else { + meta.objectPrefix = defaultObjectPrefix + } + + if val, ok := config.TriggerMetadata["objectDelimiter"]; ok { + meta.objectDelimiter = val + } else { + meta.objectDelimiter = defaultObjectDelimiter + } + + if val, ok := config.TriggerMetadata["timeout"]; ok { + httpClientTimeout, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("httpClientTimeout parsing error: %s", err.Error()) + } + meta.httpClientTimeout = httpClientTimeout + } else { + meta.httpClientTimeout = defaultHTTPClientTimeout + } + + if val, ok := config.TriggerMetadata["onlyFiles"]; ok { + isOnlyFiles, conversionError := strconv.ParseBool(val) + if conversionError != nil { + return nil, fmt.Errorf("onlyFiles parsing error: %s", conversionError.Error()) + } + meta.onlyFiles = isOnlyFiles + } else { + meta.onlyFiles = defaultOnlyFiles + } + + if val, ok := config.TriggerMetadata["objectLimit"]; ok { + meta.objectLimit = val + } else { + meta.objectLimit = defaultObjectLimit + } + + return &meta, nil +} + +func parseOpenstackSwiftAuthenticationMetadata(config *ScalerConfig) (*openstackSwiftAuthenticationMetadata, error) { + authMeta := openstackSwiftAuthenticationMetadata{} + + if config.AuthParams["authURL"] != "" { + authMeta.authURL = config.AuthParams["authURL"] + } else { + return nil, fmt.Errorf("authURL doesn't exist in the authParams") + } + + if config.AuthParams["userID"] != "" { + authMeta.userID = config.AuthParams["userID"] + + if config.AuthParams["password"] != "" { + authMeta.password = config.AuthParams["password"] + } else { + return nil, fmt.Errorf("password doesn't exist in the authParams") + } + + if config.AuthParams["projectID"] != "" { + authMeta.projectID = config.AuthParams["projectID"] + } else { + return nil, fmt.Errorf("projectID doesn't exist in the authParams") + } + } else { + if config.AuthParams["appCredentialID"] != "" { + authMeta.appCredentialID = config.AuthParams["appCredentialID"] + + if config.AuthParams["appCredentialSecret"] != "" { + authMeta.appCredentialSecret = config.AuthParams["appCredentialSecret"] + } else { + return nil, fmt.Errorf("appCredentialSecret doesn't exist in the authParams") + } + } else { + return nil, fmt.Errorf("neither userID or appCredentialID exist in the authParams") + } + } + + return &authMeta, nil +} + +func (s *openstackSwiftScaler) IsActive(ctx context.Context) (bool, error) { + objectCount, err := s.getOpenstackSwiftContainerObjectCount() + + if err != nil { + return false, err + } + + return objectCount > 0, nil +} + +func (s *openstackSwiftScaler) Close() error { + return nil +} + +func (s *openstackSwiftScaler) GetMetrics(ctx context.Context, metricName string, metricSelector labels.Selector) ([]external_metrics.ExternalMetricValue, error) { + objectCount, err := s.getOpenstackSwiftContainerObjectCount() + + if err != nil { + openstackSwiftLog.Error(err, "error getting objectCount") + return []external_metrics.ExternalMetricValue{}, err + } + + metric := external_metrics.ExternalMetricValue{ + MetricName: metricName, + Value: *resource.NewQuantity(int64(objectCount), resource.DecimalSI), + Timestamp: metav1.Now(), + } + + return append([]external_metrics.ExternalMetricValue{}, metric), nil +} + +func (s *openstackSwiftScaler) GetMetricSpecForScaling() []v2beta2.MetricSpec { + targetObjectCount := resource.NewQuantity(int64(s.metadata.objectCount), resource.DecimalSI) + + var metricName string + + if s.metadata.objectPrefix != "" { + metricName = fmt.Sprintf("%s-%s", s.metadata.containerName, s.metadata.objectPrefix) + } else { + metricName = s.metadata.containerName + } + + externalMetric := &v2beta2.ExternalMetricSource{ + Metric: v2beta2.MetricIdentifier{ + Name: kedautil.NormalizeString(fmt.Sprintf("%s-%s", "openstack-swift", metricName)), + }, + Target: v2beta2.MetricTarget{ + Type: v2beta2.AverageValueMetricType, + AverageValue: targetObjectCount, + }, + } + + metricSpec := v2beta2.MetricSpec{ + External: externalMetric, Type: externalMetricType, + } + + return []v2beta2.MetricSpec{metricSpec} +} diff --git a/pkg/scalers/openstack_swift_scaler_test.go b/pkg/scalers/openstack_swift_scaler_test.go new file mode 100644 index 00000000000..2f5b3919bc3 --- /dev/null +++ b/pkg/scalers/openstack_swift_scaler_test.go @@ -0,0 +1,159 @@ +package scalers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type parseOpenstackSwiftMetadataTestData struct { + metadata map[string]string +} + +type parseOpenstackSwiftAuthMetadataTestData struct { + authMetadata map[string]string +} + +type openstackSwiftMetricIdentifier struct { + resolvedEnv map[string]string + metadataTestData *parseOpenstackSwiftMetadataTestData + authMetadataTestData *parseOpenstackSwiftAuthMetadataTestData + name string +} + +var openstackSwiftMetadataTestData = []parseOpenstackSwiftMetadataTestData{ + // Only required parameters + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container"}}, + // Adding objectCount + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "objectCount": "5"}}, + // Adding objectPrefix + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "objectCount": "5", "objectPrefix": "my-prefix"}}, + // Adding objectDelimiter + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "objectCount": "5", "objectDelimiter": "/"}}, + // Adding objectLimit + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "objectCount": "5", "objectLimit": "1000"}}, + // Adding timeout + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "objectCount": "5", "timeout": "2"}}, + // Adding onlyFiles + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "containerName": "my-container", "onlyFiles": "true"}}, +} + +var openstackSwiftAuthMetadataTestData = []parseOpenstackSwiftAuthMetadataTestData{ + {authMetadata: map[string]string{"userID": "my-id", "password": "my-password", "projectID": "my-project-id", "authURL": "http://localhost:5000/v3/"}}, + {authMetadata: map[string]string{"appCredentialID": "my-app-credential-id", "appCredentialSecret": "my-app-credential-secret", "authURL": "http://localhost:5000/v3/"}}, +} + +var invalidOpenstackSwiftMetadataTestData = []parseOpenstackSwiftMetadataTestData{ + // Missing swiftURL + {metadata: map[string]string{"containerName": "my-container", "objectCount": "5"}}, + // Missing containerName + {metadata: map[string]string{"swiftURL": "http://localhost:8080/v1/my-account-id", "objectCount": "5"}}, + // objectCount is not an integer value + {metadata: map[string]string{"containerName": "my-container", "swiftURL": "http://localhost:8080/v1/my-account-id", "objectCount": "5.5"}}, + // timeout is not an integer value + {metadata: map[string]string{"containerName": "my-container", "swiftURL": "http://localhost:8080/v1/my-account-id", "objectCount": "5", "timeout": "2.5"}}, + // onlyFiles is not a boolean value + {metadata: map[string]string{"containerName": "my-container", "swiftURL": "http://localhost:8080/v1/my-account-id", "objectCount": "5", "onlyFiles": "yes"}}, +} + +var invalidOpenstackSwiftAuthMetadataTestData = []parseOpenstackSwiftAuthMetadataTestData{ + // Using Password method: + + // Missing userID + {authMetadata: map[string]string{"password": "my-password", "projectID": "my-project-id", "authURL": "http://localhost:5000/v3/"}}, + // Missing password + {authMetadata: map[string]string{"userID": "my-id", "projectID": "my-project-id", "authURL": "http://localhost:5000/v3/"}}, + // Missing projectID + {authMetadata: map[string]string{"userID": "my-id", "password": "my-password", "authURL": "http://localhost:5000/v3/"}}, + // Missing authURL + {authMetadata: map[string]string{"userID": "my-id", "password": "my-password", "projectID": "my-project-id"}}, + + // Using Application Credentials method: + + // Missing appCredentialID + {authMetadata: map[string]string{"appCredentialSecret": "my-app-credential-secret", "authURL": "http://localhost:5000/v3/"}}, + // Missing appCredentialSecret + {authMetadata: map[string]string{"appCredentialID": "my-app-credential-id", "authURL": "http://localhost:5000/v3/"}}, + // Missing authURL + {authMetadata: map[string]string{"appCredentialID": "my-app-credential-id", "appCredentialSecret": "my-app-credential-secret"}}, +} + +func TestOpenstackSwiftGetMetricSpecForScaling(t *testing.T) { + testCases := []openstackSwiftMetricIdentifier{ + {nil, &openstackSwiftMetadataTestData[0], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[1], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[2], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container-my-prefix"}, + {nil, &openstackSwiftMetadataTestData[3], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[4], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[5], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[6], &openstackSwiftAuthMetadataTestData[0], "openstack-swift-my-container"}, + + {nil, &openstackSwiftMetadataTestData[0], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[1], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[2], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container-my-prefix"}, + {nil, &openstackSwiftMetadataTestData[3], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[4], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[5], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + {nil, &openstackSwiftMetadataTestData[6], &openstackSwiftAuthMetadataTestData[1], "openstack-swift-my-container"}, + } + + for _, testData := range testCases { + testData := testData + meta, err := parseOpenstackSwiftMetadata(&ScalerConfig{ResolvedEnv: testData.resolvedEnv, TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.authMetadataTestData.authMetadata}) + if err != nil { + t.Fatal("Could not parse metadata:", err) + } + _, err = parseOpenstackSwiftAuthenticationMetadata(&ScalerConfig{ResolvedEnv: testData.resolvedEnv, TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.authMetadataTestData.authMetadata}) + if err != nil { + t.Fatal("Could not parse auth metadata:", err) + } + + mockSwiftScaler := openstackSwiftScaler{meta, nil} + + metricSpec := mockSwiftScaler.GetMetricSpecForScaling() + + metricName := metricSpec[0].External.Metric.Name + + if metricName != testData.name { + t.Error("Wrong External metric source name:", metricName) + } + } +} + +func TestParseOpenstackSwiftMetadataForInvalidCases(t *testing.T) { + testCases := []openstackSwiftMetricIdentifier{ + {nil, &invalidOpenstackSwiftMetadataTestData[0], &parseOpenstackSwiftAuthMetadataTestData{}, "missing swiftURL"}, + {nil, &invalidOpenstackSwiftMetadataTestData[1], &parseOpenstackSwiftAuthMetadataTestData{}, "missing containerName"}, + {nil, &invalidOpenstackSwiftMetadataTestData[2], &parseOpenstackSwiftAuthMetadataTestData{}, "objectCount is not an integer value"}, + {nil, &invalidOpenstackSwiftMetadataTestData[3], &parseOpenstackSwiftAuthMetadataTestData{}, "onlyFiles is not a boolean value"}, + {nil, &invalidOpenstackSwiftMetadataTestData[4], &parseOpenstackSwiftAuthMetadataTestData{}, "timeout is not an integer value"}, + } + + for _, testData := range testCases { + testData := testData + t.Run(testData.name, func(pt *testing.T) { + _, err := parseOpenstackSwiftMetadata(&ScalerConfig{ResolvedEnv: testData.resolvedEnv, TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.authMetadataTestData.authMetadata}) + assert.NotNil(t, err) + }) + } +} + +func TestParseOpenstackSwiftAuthenticationMetadataForInvalidCases(t *testing.T) { + testCases := []openstackSwiftMetricIdentifier{ + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[0], "missing userID"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[1], "missing password"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[2], "missing projectID"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[3], "missing authURL for password method"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[4], "missing appCredentialID"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[5], "missing appCredentialSecret"}, + {nil, &parseOpenstackSwiftMetadataTestData{}, &invalidOpenstackSwiftAuthMetadataTestData[6], "missing authURL for application credentials method"}, + } + + for _, testData := range testCases { + testData := testData + t.Run(testData.name, func(pt *testing.T) { + _, err := parseOpenstackSwiftAuthenticationMetadata(&ScalerConfig{ResolvedEnv: testData.resolvedEnv, TriggerMetadata: testData.metadataTestData.metadata, AuthParams: testData.authMetadataTestData.authMetadata}) + assert.NotNil(t, err) + }) + } +} diff --git a/pkg/scaling/scale_handler.go b/pkg/scaling/scale_handler.go index 2eea32ce7ff..8801e8e578c 100644 --- a/pkg/scaling/scale_handler.go +++ b/pkg/scaling/scale_handler.go @@ -476,6 +476,8 @@ func buildScaler(triggerType string, config *scalers.ScalerConfig) (scalers.Scal return scalers.NewMetricsAPIScaler(config) case "mysql": return scalers.NewMySQLScaler(config) + case "openstack-swift": + return scalers.NewOpenstackSwiftScaler(config) case "postgresql": return scalers.NewPostgreSQLScaler(config) case "prometheus":