From b467b198245540e14bf1479cde47835d01f3b9ac Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Wed, 10 Feb 2021 14:00:25 -0800 Subject: [PATCH 1/5] Initial implementation and tests for mssql scaler Signed-off-by: Chris Gillum --- go.mod | 1 + go.sum | 3 + pkg/scalers/mssql_scaler.go | 280 +++++++++++++++++++++++++++++++ pkg/scalers/mssql_scaler_test.go | 141 ++++++++++++++++ pkg/scaling/scale_handler.go | 2 + 5 files changed, 427 insertions(+) create mode 100644 pkg/scalers/mssql_scaler.go create mode 100644 pkg/scalers/mssql_scaler_test.go diff --git a/go.mod b/go.mod index 2261b616400..7625b95d56f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/Huawei/gophercloud v1.0.21 github.com/Shopify/sarama v1.27.2 github.com/aws/aws-sdk-go v1.36.19 + github.com/denisenkom/go-mssqldb v0.9.0 // indirect github.com/go-logr/logr v0.3.0 github.com/go-logr/zapr v0.3.0 // indirect github.com/go-openapi/spec v0.20.0 diff --git a/go.sum b/go.sum index b2a877794fa..f7f17ca2d3e 100644 --- a/go.sum +++ b/go.sum @@ -381,6 +381,8 @@ github.com/deepmap/oapi-codegen v1.3.13/go.mod h1:WAmG5dWY8/PYHt4vKxlt90NsbHMAOC github.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As= github.com/denisenkom/go-mssqldb v0.0.0-20190111225525-2fea367d496d/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/denisenkom/go-mssqldb v0.9.0 h1:RSohk2RsiZqLZ0zCjtfn3S4Gp4exhpBWHyQ7D0yGjAk= +github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= github.com/devigned/tab v0.1.1 h1:3mD6Kb1mUOYeLpJvTVSDwSg5ZsfSxfvxGRTxRsJsITA= github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= @@ -631,6 +633,7 @@ github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5 github.com/gogo/protobuf v1.2.2-0.20190730201129-28a6bbf47e48/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/gddo v0.0.0-20190419222130-af0f2af80721/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= diff --git a/pkg/scalers/mssql_scaler.go b/pkg/scalers/mssql_scaler.go new file mode 100644 index 00000000000..a3cd6fa68a2 --- /dev/null +++ b/pkg/scalers/mssql_scaler.go @@ -0,0 +1,280 @@ +package scalers + +import ( + "context" + "crypto/sha256" + "database/sql" + "fmt" + "net/url" + "strconv" + + 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" +) + +// mssqlScaler exposes a data pointer to mssqlMetadata and sql.DB connection +type mssqlScaler struct { + metadata *mssqlMetadata + connection *sql.DB +} + +// mssqlMetadata defines metadata used by KEDA to query a Microsoft SQL database +type mssqlMetadata struct { + // The connection string used to connect to the MSSQL database. + // Both URL syntax (sqlserver://host?database=dbName) and OLEDB syntax is supported. + // +optional + connectionString string + // The username credential for connecting to the SQL database server, if not specified in the connection string. + // +optional + username string + // The password credential for connecting to the SQL database server, if not specified in the connection string. + // +optional + password string + // The hostname of the database server to connect to query, if not specified in the connection string. + // +optional + host string + // The port number of the database server endpoint to connect to, if not specified in the connection string. + // +optional + port int + // The name of the database to query, if not specified in the connection string. + // +optional + database string + // The T-SQL query to run against the target database - e.g. SELECT COUNT(*) FROM table. + // +required + query string + // The threshold that is used as targetAverageValue in the Horizontal Pod Autoscaler. + // +required + targetValue int + // The name of the metric to use in the Horizontal Pod Autoscaler. This value will be prefixed with "mssql-". + // +optional + metricName string +} + +var mssqlLog = logf.Log.WithName("mssql_scaler") + +// NewMSSQLScaler creates a new mssql scaler +func NewMSSQLScaler(config *ScalerConfig) (Scaler, error) { + meta, err := parseMSSQLMetadata(config) + if err != nil { + return nil, fmt.Errorf("error parsing mssql metadata: %s", err) + } + + conn, err := newMSSQLConnection(meta) + if err != nil { + return nil, fmt.Errorf("error establishing mssql connection: %s", err) + } + + return &mssqlScaler{ + metadata: meta, + connection: conn, + }, nil +} + +// parseMSSQLMetadata takes a ScalerConfig and returns a mssqlMetadata or an error if the config is invalid +func parseMSSQLMetadata(config *ScalerConfig) (*mssqlMetadata, error) { + meta := mssqlMetadata{} + + // Query + if val, ok := config.TriggerMetadata["query"]; ok { + meta.query = val + } else { + return nil, fmt.Errorf("no query given") + } + + // Target query value + if val, ok := config.TriggerMetadata["targetValue"]; ok { + targetValue, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("targetValue parsing error %s", err.Error()) + } + meta.targetValue = targetValue + } else { + return nil, fmt.Errorf("no targetValue given") + } + + // Connection string, which can either be provided explicitly or via the helper fields + switch { + case config.AuthParams["connectionString"] != "": + meta.connectionString = config.AuthParams["connectionString"] + case config.TriggerMetadata["connectionStringFromEnv"] != "": + meta.connectionString = config.ResolvedEnv[config.TriggerMetadata["connectionStringFromEnv"]] + default: + meta.connectionString = "" + if val, ok := config.TriggerMetadata["host"]; ok { + meta.host = val + } else { + return nil, fmt.Errorf("no host given") + } + + if val, ok := config.TriggerMetadata["port"]; ok { + port, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("port parsing error %s", err.Error()) + } + + meta.port = port + } + + if val, ok := config.TriggerMetadata["username"]; ok { + meta.username = val + } + + // database is optional in SQL s + if val, ok := config.TriggerMetadata["database"]; ok { + meta.database = val + } + + if config.AuthParams["password"] != "" { + meta.password = config.AuthParams["password"] + } else if config.TriggerMetadata["passwordFromEnv"] != "" { + meta.password = config.ResolvedEnv[config.TriggerMetadata["passwordFromEnv"]] + } + } + + // get the metricName, which can be explicit or from the (masked) connection string + if val, ok := config.TriggerMetadata["metricName"]; ok { + meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", val)) + } else { + if meta.database != "" { + meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", meta.database)) + } else if meta.host != "" { + meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", meta.host)) + } else if meta.connectionString != "" { + // The mssql provider supports of a variety of connection string formats. Instead of trying to parse + // the connection string and mask out sensitive data, play it safe and just hash the whole thing. + connectionStringHash := sha256.Sum256([]byte(meta.connectionString)) + meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%x", connectionStringHash)) + } else { + meta.metricName = "mssql" + } + } + + return &meta, nil +} + +// newMSSQLConnection returns a new, opened SQL connection for the provided mssqlMetadata +func newMSSQLConnection(meta *mssqlMetadata) (*sql.DB, error) { + connStr := getMSSQLConnectionString(meta) + + db, err := sql.Open("sqlserver", connStr) + if err != nil { + mssqlLog.Error(err, fmt.Sprintf("Found error opening mssql: %s", err)) + return nil, err + } + + err = db.Ping() + if err != nil { + mssqlLog.Error(err, fmt.Sprintf("Found error pinging mssql: %s", err)) + return nil, err + } + + return db, nil +} + +// getMSSQLConnectionString returns a connection string from a mssqlMetadata +func getMSSQLConnectionString(meta *mssqlMetadata) string { + var connStr string + + if meta.connectionString != "" { + connStr = meta.connectionString + } else { + query := url.Values{} + if meta.database != "" { + query.Add("database", meta.database) + } + + connectionUrl := &url.URL{Scheme: "sqlserver", RawQuery: query.Encode()} + if meta.username != "" { + if meta.password != "" { + connectionUrl.User = url.UserPassword(meta.username, meta.password) + } else { + connectionUrl.User = url.User(meta.username) + } + } + + if meta.port > 0 { + connectionUrl.Host = fmt.Sprintf("%s:%d", meta.host, meta.port) + } else { + connectionUrl.Host = meta.host + } + + connStr = connectionUrl.String() + } + + return connStr +} + +// GetMetricSpecForScaling returns the MetricSpec for the Horizontal Pod Autoscaler +func (s *mssqlScaler) GetMetricSpecForScaling() []v2beta2.MetricSpec { + targetQueryValue := resource.NewQuantity(int64(s.metadata.targetValue), resource.DecimalSI) + externalMetric := &v2beta2.ExternalMetricSource{ + Metric: v2beta2.MetricIdentifier{ + Name: s.metadata.metricName, + }, + Target: v2beta2.MetricTarget{ + Type: v2beta2.AverageValueMetricType, + AverageValue: targetQueryValue, + }, + } + + metricSpec := v2beta2.MetricSpec{ + External: externalMetric, Type: externalMetricType, + } + + return []v2beta2.MetricSpec{metricSpec} +} + +// GetMetrics returns a value for a supported metric or an error if there is a problem getting the metric +func (s *mssqlScaler) GetMetrics(ctx context.Context, metricName string, metricSelector labels.Selector) ([]external_metrics.ExternalMetricValue, error) { + num, err := s.getQueryResult() + if err != nil { + return []external_metrics.ExternalMetricValue{}, fmt.Errorf("error inspecting mssql: %s", err) + } + + metric := external_metrics.ExternalMetricValue{ + MetricName: metricName, + Value: *resource.NewQuantity(int64(num), resource.DecimalSI), + Timestamp: metav1.Now(), + } + + return append([]external_metrics.ExternalMetricValue{}, metric), nil +} + +// getQueryResult returns the result of the scaler query +func (s *mssqlScaler) getQueryResult() (int, error) { + var value int + err := s.connection.QueryRow(s.metadata.query).Scan(&value) + if err != nil { + mssqlLog.Error(err, fmt.Sprintf("Could not query mssql database: %s", err)) + return 0, err + } + + return value, nil +} + +// IsActive returns true if there are pending events to be processed +func (s *mssqlScaler) IsActive(ctx context.Context) (bool, error) { + messages, err := s.getQueryResult() + if err != nil { + return false, fmt.Errorf("error inspecting mssql: %s", err) + } + + return messages > 0, nil +} + +// Close closes the mssql database connections +func (s *mssqlScaler) Close() error { + err := s.connection.Close() + if err != nil { + mssqlLog.Error(err, "Error closing mssql connection") + return err + } + + return nil +} diff --git a/pkg/scalers/mssql_scaler_test.go b/pkg/scalers/mssql_scaler_test.go new file mode 100644 index 00000000000..8f5291333a8 --- /dev/null +++ b/pkg/scalers/mssql_scaler_test.go @@ -0,0 +1,141 @@ +package scalers + +import ( + "errors" + "strings" + "testing" +) + +type mssqlTestData struct { + // test inputs + metadata map[string]string + resolvedEnv map[string]string + authParams map[string]string + + // expected outputs + expectedMetricName string + expectedConnectionString string + expectedError error +} + +var testInputs = []mssqlTestData{ + // direct connection string input + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"connectionString": "sqlserver://localhost"}, + expectedConnectionString: "sqlserver://localhost", + }, + // direct connection string input, OLEDB format + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"connectionString": "Server=example.database.windows.net;port=1433;Database=AdventureWorks;Persist Security Info=False;User ID=user1;Password=Password#1;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"}, + expectedConnectionString: "Server=example.database.windows.net;port=1433;Database=AdventureWorks;Persist Security Info=False;User ID=user1;Password=Password#1;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;", + }, + // connection string input via environment variables + explicit metricName + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1", "connectionStringFromEnv": "test_connection_string", "metricName": "myMetric"}, + resolvedEnv: map[string]string{"test_connection_string": "sqlserver://localhost?database=AdventureWorks"}, + authParams: map[string]string{}, + expectedConnectionString: "sqlserver://localhost?database=AdventureWorks", + expectedMetricName: "mssql-myMetric", + }, + // connection string generated from minimal required metadata + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1", "host": "127.0.0.1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{}, + expectedMetricName: "mssql-127-0-0-1", + expectedConnectionString: "sqlserver://127.0.0.1", + }, + // connection string generated from full metadata + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1", "host": "example.database.windows.net", "username": "user1", "passwordFromEnv": "test_password", "port": "1433", "database": "AdventureWorks", "metricName": "myMetric1"}, + resolvedEnv: map[string]string{"test_password": "Password#1"}, + authParams: map[string]string{}, + expectedMetricName: "mssql-myMetric1", + expectedConnectionString: "sqlserver://user1:Password%231@example.database.windows.net:1433?database=AdventureWorks", + }, + // variation of previous: no port, password from authParams, metricName from database name + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1", "host": "example.database.windows.net", "username": "user2", "database": "AdventureWorks"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"password": "Password#2"}, + expectedMetricName: "mssql-AdventureWorks", + expectedConnectionString: "sqlserver://user2:Password%232@example.database.windows.net?database=AdventureWorks", + }, + // variation of previous: no database name, metricName from host + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1", "host": "example.database.windows.net", "username": "user3"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"password": "Password#3"}, + expectedMetricName: "mssql-example-database-windows-net", + expectedConnectionString: "sqlserver://user3:Password%233@example.database.windows.net", + }, + // Error: missing query + { + metadata: map[string]string{"targetValue": "1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"connectionString": "sqlserver://localhost"}, + expectedError: errors.New("no query given"), + }, + // Error: missing targetValue + { + metadata: map[string]string{"query": "SELECT 1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{"connectionString": "sqlserver://localhost"}, + expectedError: errors.New("no targetValue given"), + }, + // Error: missing host + { + metadata: map[string]string{"query": "SELECT 1", "targetValue": "1"}, + resolvedEnv: map[string]string{}, + authParams: map[string]string{}, + expectedError: errors.New("no host given"), + }, +} + +func TestMSSQLMetadataParsing(t *testing.T) { + for _, testData := range testInputs { + var config = ScalerConfig{ + ResolvedEnv: testData.resolvedEnv, + TriggerMetadata: testData.metadata, + AuthParams: testData.authParams, + } + + outputMetadata, err := parseMSSQLMetadata(&config) + if err != nil { + if testData.expectedError == nil { + t.Errorf("Unexpected error parsing input metadata: %v", err) + } else if testData.expectedError.Error() != err.Error() { + t.Errorf("Expected error '%v' but got '%v'", testData.expectedError, err) + } + + continue + } + + expectedQuery := "SELECT 1" + if outputMetadata.query != expectedQuery { + t.Errorf("Wrong query. Expected '%s' but got '%s'", expectedQuery, outputMetadata.query) + } + + expectedTargetValue := 1 + if outputMetadata.targetValue != expectedTargetValue { + t.Errorf("Wrong targetValue. Expected %d but got %d", expectedTargetValue, outputMetadata.targetValue) + } + + outputConnectionString := getMSSQLConnectionString(outputMetadata) + if testData.expectedConnectionString != outputConnectionString { + t.Errorf("Wrong connection string. Expected '%s' but got '%s'", testData.expectedConnectionString, outputConnectionString) + } + + if !strings.HasPrefix(outputMetadata.metricName, "mssql-") { + t.Errorf("Metric name '%s' was expected to start with 'mssql-' but got '%s'", outputMetadata.metricName, testData.expectedMetricName) + } + + if testData.expectedMetricName != "" && testData.expectedMetricName != outputMetadata.metricName { + t.Errorf("Wrong metric name. Expected '%s' but got '%s'", testData.expectedMetricName, outputMetadata.metricName) + } + } +} diff --git a/pkg/scaling/scale_handler.go b/pkg/scaling/scale_handler.go index fc18381f2f6..cbaf02bec2e 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 "mongodb": return scalers.NewMongoDBScaler(config) + case "mssql": + return scalers.NewMSSQLScaler(config) case "mysql": return scalers.NewMySQLScaler(config) case "openstack-swift": From 10bf460ee3092dbf0561f54c678765b678432c1b Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 12 Feb 2021 17:33:36 -0800 Subject: [PATCH 2/5] PR feedback and minor doc updates Signed-off-by: Chris Gillum --- CHANGELOG.md | 2 +- pkg/scalers/mssql_scaler.go | 29 +++++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f9a6160c6..a3dcccfd98c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ ### New -- TODO ([#XXX](https://github.com/kedacore/keda/issues/XXX)) +- Add Microsoft SQL Server (MSSQL) scaler ([#674](https://github.com/kedacore/keda/issues/674) | [docs](https://keda.sh/docs/2.2/scalers/mssql/)) ### Improvements diff --git a/pkg/scalers/mssql_scaler.go b/pkg/scalers/mssql_scaler.go index a3cd6fa68a2..f1c66126076 100644 --- a/pkg/scalers/mssql_scaler.go +++ b/pkg/scalers/mssql_scaler.go @@ -30,16 +30,16 @@ type mssqlMetadata struct { // Both URL syntax (sqlserver://host?database=dbName) and OLEDB syntax is supported. // +optional connectionString string - // The username credential for connecting to the SQL database server, if not specified in the connection string. + // The username credential for connecting to the MSSQL instance, if not specified in the connection string. // +optional username string - // The password credential for connecting to the SQL database server, if not specified in the connection string. + // The password credential for connecting to the MSSQL instance, if not specified in the connection string. // +optional password string - // The hostname of the database server to connect to query, if not specified in the connection string. + // The hostname of the MSSQL instance endpoint, if not specified in the connection string. // +optional host string - // The port number of the database server endpoint to connect to, if not specified in the connection string. + // The port number of the MSSQL instance endpoint, if not specified in the connection string. // +optional port int // The name of the database to query, if not specified in the connection string. @@ -141,16 +141,17 @@ func parseMSSQLMetadata(config *ScalerConfig) (*mssqlMetadata, error) { if val, ok := config.TriggerMetadata["metricName"]; ok { meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", val)) } else { - if meta.database != "" { + switch { + case meta.database != "": meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", meta.database)) - } else if meta.host != "" { + case meta.host != "": meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%s", meta.host)) - } else if meta.connectionString != "" { + case meta.connectionString != "": // The mssql provider supports of a variety of connection string formats. Instead of trying to parse // the connection string and mask out sensitive data, play it safe and just hash the whole thing. connectionStringHash := sha256.Sum256([]byte(meta.connectionString)) meta.metricName = kedautil.NormalizeString(fmt.Sprintf("mssql-%x", connectionStringHash)) - } else { + default: meta.metricName = "mssql" } } @@ -189,22 +190,22 @@ func getMSSQLConnectionString(meta *mssqlMetadata) string { query.Add("database", meta.database) } - connectionUrl := &url.URL{Scheme: "sqlserver", RawQuery: query.Encode()} + connectionURL := &url.URL{Scheme: "sqlserver", RawQuery: query.Encode()} if meta.username != "" { if meta.password != "" { - connectionUrl.User = url.UserPassword(meta.username, meta.password) + connectionURL.User = url.UserPassword(meta.username, meta.password) } else { - connectionUrl.User = url.User(meta.username) + connectionURL.User = url.User(meta.username) } } if meta.port > 0 { - connectionUrl.Host = fmt.Sprintf("%s:%d", meta.host, meta.port) + connectionURL.Host = fmt.Sprintf("%s:%d", meta.host, meta.port) } else { - connectionUrl.Host = meta.host + connectionURL.Host = meta.host } - connStr = connectionUrl.String() + connStr = connectionURL.String() } return connStr From bc493aeb6059e0db2bf1e7a7909ce1f5ff6665a3 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 19 Feb 2021 10:42:25 -0800 Subject: [PATCH 3/5] Fixed mssql driver loading issue Signed-off-by: Chris Gillum --- go.mod | 2 +- pkg/scalers/mssql_scaler.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2ff9db65d20..11c64bab104 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/Huawei/gophercloud v1.0.21 github.com/Shopify/sarama v1.27.2 github.com/aws/aws-sdk-go v1.36.19 - github.com/denisenkom/go-mssqldb v0.9.0 // indirect + github.com/denisenkom/go-mssqldb v0.9.0 github.com/go-logr/logr v0.4.0 github.com/go-logr/zapr v0.3.0 // indirect github.com/go-openapi/spec v0.20.0 diff --git a/pkg/scalers/mssql_scaler.go b/pkg/scalers/mssql_scaler.go index f1c66126076..de31ef4391e 100644 --- a/pkg/scalers/mssql_scaler.go +++ b/pkg/scalers/mssql_scaler.go @@ -8,6 +8,8 @@ import ( "net/url" "strconv" + // mssql driver required for this scaler + _ "github.com/denisenkom/go-mssqldb" kedautil "github.com/kedacore/keda/v2/pkg/util" v2beta2 "k8s.io/api/autoscaling/v2beta2" "k8s.io/apimachinery/pkg/api/resource" From 3db193e12ab650bfe153111fb06bd932b6257e59 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 19 Feb 2021 19:19:25 -0800 Subject: [PATCH 4/5] Added end-to-end test Signed-off-by: Chris Gillum --- tests/scalers/mssql.test.ts | 250 ++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/scalers/mssql.test.ts diff --git a/tests/scalers/mssql.test.ts b/tests/scalers/mssql.test.ts new file mode 100644 index 00000000000..7b1107c0d04 --- /dev/null +++ b/tests/scalers/mssql.test.ts @@ -0,0 +1,250 @@ +import test from 'ava' +import * as fs from 'fs' +import * as sh from 'shelljs' +import * as tmp from 'tmp' + +const mssqlns = "mssql" +const testns = "mssql-app" +const mssqlName = "mssqlinst" +const password = "Pass@word1" +const hostname = `${mssqlName}.${mssqlns}.svc.cluster.local` +const database = "TestDB" +const sqlConnectionString = `Server=${hostname};Database=${database};User ID=sa;Password=${password};` +const appName = "consumer-app" + +const getReplicaCountCommand = `kubectl get deployment.apps/${appName} -n ${testns} -o jsonpath="{.spec.replicas}"` + +test.before(t => { + sh.config.silent = true + + // deploy the mssql container + sh.exec(`kubectl create namespace ${mssqlns}`) + const mssqlDeploymentYamlFile = tmp.fileSync() + fs.writeFileSync(mssqlDeploymentYamlFile.name, mssqlDeploymentYaml) + t.is(0, sh.exec(`kubectl apply -n ${mssqlns} -f ${mssqlDeploymentYamlFile.name}`).code, 'creating the mssql deployment should work.') + + // wait for the mssql container to be ready + let readyReplicaCount = '0' + for (let i = 0; i < 30; i++) { + readyReplicaCount = sh.exec(`kubectl get deploy/mssql-deployment -n ${mssqlns} -o jsonpath='{.status.readyReplicas}'`).stdout + if (readyReplicaCount != '1') { + sh.exec('sleep 2s') + } + } + t.is('1', readyReplicaCount, 'mssql-deployment is not in a ready state!') + + // create the mssql database + const mssqlPod = sh.exec(`kubectl get pods -n ${mssqlns} -o jsonpath='{.items[0].metadata.name}'`).stdout + t.not(mssqlPod, '') + sh.exec(`kubectl exec -n ${mssqlns} ${mssqlPod} -- /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "${password}" -Q "CREATE DATABASE [${database}]"`) + + // create the table that KEDA will monitor for scale decisions + const createTableSQL = "CREATE TABLE tasks ([id] int identity primary key, [status] varchar(10))" + sh.exec(`kubectl exec -n ${mssqlns} ${mssqlPod} -- /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "${password}" -d "${database}" -Q "${createTableSQL}"`) + + // deploy the test app + sh.exec(`kubectl create namespace ${testns}`) + const testAppYamlFile = tmp.fileSync() + fs.writeFileSync(testAppYamlFile.name, testAppDeployYaml) + t.is(0, sh.exec(`kubectl apply -n ${testns} -f ${testAppYamlFile.name}`).code, 'creating the test app deployment should work.') +}) + +test.serial('Deployment should have 0 replicas on start', t => { + const replicaCount = sh.exec(getReplicaCountCommand).stdout + t.is(replicaCount, '0', 'replica count should start out as 0') +}) + +test.serial(`Deployment should scale to 5 (the max) then back to 0`, t => { + const jobYamlFile = tmp.fileSync() + fs.writeFileSync(jobYamlFile.name, insertRecordsJobYaml) + t.is(0, sh.exec(`kubectl apply -f ${jobYamlFile.name} -n ${testns}`).code, 'creating job should work.') + + const maxReplicaCount = '5' + + let replicaCount = '0' + for (let i = 0; i < 30 && replicaCount !== maxReplicaCount; i++) { + replicaCount = sh.exec(getReplicaCountCommand).stdout + if (replicaCount !== maxReplicaCount) { + sh.exec('sleep 2s') + } + } + + t.is(maxReplicaCount, replicaCount, `Replica count should be ${maxReplicaCount} after 60 seconds`) + + for (let i = 0; i < 36 && replicaCount !== '0'; i++) { + replicaCount = sh.exec(getReplicaCountCommand).stdout + if (replicaCount !== '0') { + sh.exec('sleep 5s') + } + } + + t.is('0', replicaCount, 'Replica count should be 0 after 3 minutes') +}) + +test.after.always.cb('clean up deployment artifacts', t => { + // delete all the app and job resources + const resources = [ + 'scaledobject.keda.sh/mssql-scaledobject', + 'triggerauthentication.keda.sh/keda-trigger-auth-mssql-secret', + `deployment.apps/${appName}`, + 'secret/mssql-secrets', + 'job/mssql-producer-job', + ] + + for (const resource of resources) { + sh.exec(`kubectl delete ${resource} -n ${testns}`) + } + + sh.exec(`kubectl delete namespace ${testns}`) + + // uninstall mssql + sh.exec(`kubectl delete -n ${mssqlns} deploy/mssql`) + sh.exec(`kubectl delete namespace ${mssqlns}`) + + t.end() +}) + +const testAppDeployYaml = `apiVersion: v1 +kind: Secret +metadata: + name: mssql-secrets +type: Opaque +stringData: + mssql-sa-password: ${password} + mssql-connection-string: ${sqlConnectionString} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: mssql-consumer-worker + name: ${appName} +spec: + replicas: 0 + selector: + matchLabels: + app: mssql-consumer-worker + template: + metadata: + labels: + app: mssql-consumer-worker + spec: + containers: + - image: docker.io/cgillum/mssqlscalertest:latest + imagePullPolicy: Always + name: mssql-consumer-worker + args: [consumer] + env: + - name: SQL_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: mssql-secrets + key: mssql-connection-string +--- +apiVersion: keda.sh/v1alpha1 +kind: TriggerAuthentication +metadata: + name: keda-trigger-auth-mssql-secret +spec: + secretTargetRef: + - parameter: password + name: mssql-secrets + key: mssql-sa-password +--- +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: mssql-scaledobject +spec: + scaleTargetRef: + name: ${appName} + pollingInterval: 5 + cooldownPeriod: 10 + minReplicaCount: 0 + maxReplicaCount: 5 + triggers: + - type: mssql + metadata: + host: "${hostname}" + port: "1433" + database: "${database}" + username: sa + query: "SELECT COUNT(*) FROM tasks WHERE [status]='running' OR [status]='queued'" + targetValue: "1" # one replica per row + authenticationRef: + name: keda-trigger-auth-mssql-secret` + +const insertRecordsJobYaml = `apiVersion: batch/v1 +kind: Job +metadata: + labels: + app: mssql-producer-job + name: mssql-producer-job +spec: + template: + metadata: + labels: + app: mssql-producer-job + spec: + containers: + - image: docker.io/cgillum/mssqlscalertest:latest + imagePullPolicy: Always + name: mssql-test-producer + args: ["producer"] + env: + - name: SQL_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: mssql-secrets + key: mssql-connection-string + restartPolicy: Never + backoffLimit: 4` + +const mssqlDeploymentYaml = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: mssql-deployment + labels: + app: mssql +spec: + replicas: 1 + selector: + matchLabels: + app: mssql + template: + metadata: + labels: + app: mssql + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: mssql + image: mcr.microsoft.com/mssql/server:2019-latest + ports: + - containerPort: 1433 + env: + - name: MSSQL_PID + value: "Developer" + - name: ACCEPT_EULA + value: "Y" + - name: SA_PASSWORD + value: "${password}" + readinessProbe: + exec: + command: + - /bin/sh + - -c + - "/opt/mssql-tools/bin/sqlcmd -S . -U sa -P '${password}' -Q 'SELECT @@Version'" +--- +apiVersion: v1 +kind: Service +metadata: + name: ${mssqlName} +spec: + selector: + app: mssql + ports: + - protocol: TCP + port: 1433 + targetPort: 1433 + type: ClusterIP` From c7dd2e70e361be172bc9227a420968c193ac2d22 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Sat, 20 Feb 2021 10:15:11 -0800 Subject: [PATCH 5/5] Removing trailing whitespace Signed-off-by: Chris Gillum --- tests/scalers/mssql.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/scalers/mssql.test.ts b/tests/scalers/mssql.test.ts index 7b1107c0d04..886af5c66a1 100644 --- a/tests/scalers/mssql.test.ts +++ b/tests/scalers/mssql.test.ts @@ -16,7 +16,7 @@ const getReplicaCountCommand = `kubectl get deployment.apps/${appName} -n ${test test.before(t => { sh.config.silent = true - + // deploy the mssql container sh.exec(`kubectl create namespace ${mssqlns}`) const mssqlDeploymentYamlFile = tmp.fileSync() @@ -112,7 +112,7 @@ type: Opaque stringData: mssql-sa-password: ${password} mssql-connection-string: ${sqlConnectionString} ---- +--- apiVersion: apps/v1 kind: Deployment metadata: