MSSQL: Minor refactor (#113976)

* Moving things around

* Copy parseURL function to where it's used

* Update test

* Remove experimental-strip-types

* Revert "Remove experimental-strip-types"

This reverts commit 70fbc1c0cd.

* Trigger build
This commit is contained in:
Andreas Christou
2025-11-20 11:09:09 +00:00
committed by GitHub
parent 0efffd9ec8
commit 3c777399d5
13 changed files with 718 additions and 648 deletions
+26 -2
View File
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/tsdb/mssql"
)
var logger = log.New("datasource")
@@ -71,7 +70,7 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
var err error
switch strings.ToLower(typeName) {
case "mssql":
u, err = mssql.ParseURL(urlStr, logger)
u, err = parseURL(urlStr, logger)
default:
logger.Debug("Applying default URL parsing for this data source type", "type", typeName, "url", urlStr)
@@ -90,3 +89,28 @@ func ValidateURL(typeName, urlStr string) (*url.URL, error) {
return u, nil
}
type DebugOnlyLogger interface {
Debug(msg string, args ...interface{})
}
// ParseURL tries to parse an URL string into a URL object.
func parseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
logger.Debug("Parsing URL", "url", u)
// Recognize ODBC connection strings like host\instance:1234
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
var host string
switch {
case reODBC.MatchString(u):
logger.Debug("Recognized as ODBC URL format", "url", u)
host = u
default:
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
return nil, fmt.Errorf("unrecognized URL format: %q", u)
}
return &url.URL{
Scheme: "sqlserver",
Host: host,
}, nil
}
+1 -1
View File
@@ -886,7 +886,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) {
description: "Invalid ODBC URL",
url: `localhost\instance::1433`,
err: datasource.URLValidationError{
Err: errors.New(`unrecognized MSSQL URL format: "localhost\\instance::1433"`),
Err: errors.New(`unrecognized URL format: "localhost\\instance::1433"`),
URL: `localhost\instance::1433`,
},
},
+40
View File
@@ -0,0 +1,40 @@
package azure
import (
"fmt"
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
)
func GetAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
connStr := ""
switch c := azureCredentials.(type) {
case *azcredentials.AzureManagedIdentityCredentials:
if azureManagedIdentityClientId != "" {
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
}
connStr += fmt.Sprintf("fedauth=%s;",
"ActiveDirectoryManagedIdentity")
case *azcredentials.AzureClientSecretCredentials:
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
c.ClientId,
c.TenantId,
c.ClientSecret,
"ActiveDirectoryApplication",
)
case *azcredentials.AzureEntraPasswordCredentials:
if azureEntraPasswordCredentialsEnabled {
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
c.UserId,
c.Password,
c.ClientId,
"ActiveDirectoryPassword",
)
} else {
return "", fmt.Errorf("azure entra password authentication is not enabled")
}
default:
return "", fmt.Errorf("unsupported azure authentication type")
}
return connStr, nil
}
+10 -358
View File
@@ -2,32 +2,18 @@ package mssql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
mssql "github.com/microsoft/go-mssqldb"
"github.com/microsoft/go-mssqldb/azuread"
_ "github.com/microsoft/go-mssqldb/integratedauth/krb5"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
"github.com/grafana/grafana/pkg/util"
)
type Service struct {
@@ -35,16 +21,6 @@ type Service struct {
logger log.Logger
}
const (
azureAuthentication = "Azure AD Authentication"
windowsAuthentication = "Windows Authentication"
sqlServerAuthentication = "SQL Server Authentication"
kerberosRaw = "Windows AD: Username + password"
kerberosKeytab = "Windows AD: Keytab"
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
)
func ProvideService(cfg *setting.Cfg) *Service {
logger := backend.NewLoggerWith("logger", "tsdb.mssql")
return &Service{
@@ -70,72 +46,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return dsHandler.QueryData(ctx, req)
}
func newMSSQL(ctx context.Context, driverName string, userFacingDefaultError string, rowLimit int64, dsInfo sqleng.DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, *sqleng.DataSourceHandler, error) {
var connector *mssql.Connector
var err error
if driverName == "azuresql" {
connector, err = azuread.NewConnector(cnnstr)
} else {
connector, err = mssql.NewConnector(cnnstr)
}
if err != nil {
logger.Error("mssql connector creation failed", "error", err)
return nil, nil, fmt.Errorf("mssql connector creation failed")
}
proxyClient, err := settings.ProxyClient(ctx)
if err != nil {
logger.Error("mssql proxy creation failed", "error", err)
return nil, nil, fmt.Errorf("mssql proxy creation failed")
}
if proxyClient.SecureSocksProxyEnabled() {
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
if err != nil {
logger.Error("mssql proxy creation failed", "error", err)
return nil, nil, fmt.Errorf("mssql proxy creation failed")
}
URL, err := ParseURL(dsInfo.URL, logger)
if err != nil {
return nil, nil, err
}
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
if err != nil {
return nil, nil, err
}
// update the mssql dialer with the proxy dialer
connector.Dialer = (mssqlDialer)
}
config := sqleng.DataPluginConfiguration{
DSInfo: dsInfo,
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
RowLimit: rowLimit,
}
queryResultTransformer := mssqlQueryResultTransformer{
userError: userFacingDefaultError,
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns)
db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second)
handler, err := sqleng.NewQueryDataHandler(userFacingDefaultError, db, config, &queryResultTransformer, newMssqlMacroEngine(),
logger)
if err != nil {
logger.Error("Failed connecting to Postgres", "err", err)
return nil, nil, err
}
logger.Debug("Successfully connected to Postgres")
return db, handler, nil
}
func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc {
return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
grafCfg := backend.GrafanaConfigFromContext(ctx)
@@ -151,14 +61,11 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
ConnectionTimeout: 0,
SecureDSProxy: false,
}
azureCredentials, err := utils.GetAzureCredentials(settings)
if err != nil {
return nil, fmt.Errorf("error reading azure credentials")
}
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
azureSettings, err := azsettings.ReadSettings(ctx)
if err != nil {
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
logger.Error("failed to read Azure settings from Grafana", "error", err.Error())
return nil, err
}
err = json.Unmarshal(settings.JSONData, &jsonData)
@@ -181,23 +88,18 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
UID: settings.UID,
DecryptedSecureJSONData: settings.DecryptedSecureJSONData,
}
cnnstr, err := generateConnectionString(dsInfo, cfg.Azure.ManagedIdentityClientId, cfg.Azure.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, logger)
if err != nil {
return nil, err
}
driverName := "mssql"
if jsonData.AuthenticationType == azureAuthentication {
driverName = "azuresql"
}
userFacingDefaultError, err := grafCfg.UserFacingDefaultError()
if err != nil {
return nil, err
}
_, handler, err := newMSSQL(ctx, driverName, userFacingDefaultError, sqlCfg.RowLimit, dsInfo, cnnstr, logger, settings)
config := sqleng.DataPluginConfiguration{
DSInfo: dsInfo,
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
RowLimit: sqlCfg.RowLimit,
}
handler, err := sqleng.NewQueryDataHandler(ctx, settings, userFacingDefaultError, config, logger, azureSettings)
if err != nil {
logger.Error("Failed connecting to MSSQL", "err", err)
return nil, err
@@ -208,159 +110,6 @@ func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.Instanc
}
}
// ParseURL is called also from pkg/api/datasource/validation.go,
// which uses a different logging interface,
// so we have a special minimal interface that is fulfilled by
// both places.
type DebugOnlyLogger interface {
Debug(msg string, args ...interface{})
}
// ParseURL tries to parse an MSSQL URL string into a URL object.
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
logger.Debug("Parsing MSSQL URL", "url", u)
// Recognize ODBC connection strings like host\instance:1234
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
var host string
switch {
case reODBC.MatchString(u):
logger.Debug("Recognized as ODBC URL format", "url", u)
host = u
default:
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
}
return &url.URL{
Scheme: "sqlserver",
Host: host,
}, nil
}
func generateConnectionString(dsInfo sqleng.DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
const dfltPort = "0"
var addr util.NetworkAddress
if dsInfo.URL != "" {
u, err := ParseURL(dsInfo.URL, logger)
if err != nil {
return "", err
}
addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort)
if err != nil {
return "", err
}
} else {
addr = util.NetworkAddress{
Host: "localhost",
Port: dfltPort,
}
}
args := []any{
"url", dsInfo.URL, "host", addr.Host,
}
if addr.Port != "0" {
args = append(args, "port", addr.Port)
}
logger.Debug("Generating connection string", args...)
encrypt := dsInfo.JsonData.Encrypt
tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify
hostNameInCertificate := dsInfo.JsonData.Servername
certificate := dsInfo.JsonData.RootCertFile
connStr := fmt.Sprintf("server=%s;database=%s;",
addr.Host,
dsInfo.Database,
)
switch dsInfo.JsonData.AuthenticationType {
case azureAuthentication:
azureCredentialDSNFragment, err := getAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
if err != nil {
return "", err
}
connStr += azureCredentialDSNFragment
case windowsAuthentication:
// No user id or password. We're using windows single sign on.
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
default:
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
}
// Port number 0 means to determine the port automatically, so we can let the driver choose
if addr.Port != "0" {
connStr += fmt.Sprintf("port=%s;", addr.Port)
}
switch encrypt {
case "true":
connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify)
if hostNameInCertificate != "" {
connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate)
}
if certificate != "" {
connStr += fmt.Sprintf("certificate=%s;", certificate)
}
case "disable":
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
}
if dsInfo.JsonData.ConnectionTimeout != 0 {
connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout)
}
return connStr, nil
}
func getAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentials, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool) (string, error) {
connStr := ""
switch c := azureCredentials.(type) {
case *azcredentials.AzureManagedIdentityCredentials:
if azureManagedIdentityClientId != "" {
connStr += fmt.Sprintf("user id=%s;", azureManagedIdentityClientId)
}
connStr += fmt.Sprintf("fedauth=%s;",
"ActiveDirectoryManagedIdentity")
case *azcredentials.AzureClientSecretCredentials:
connStr += fmt.Sprintf("user id=%s@%s;password=%s;fedauth=%s;",
c.ClientId,
c.TenantId,
c.ClientSecret,
"ActiveDirectoryApplication",
)
case *azcredentials.AzureEntraPasswordCredentials:
if azureEntraPasswordCredentialsEnabled {
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
c.UserId,
c.Password,
c.ClientId,
"ActiveDirectoryPassword",
)
} else {
return "", fmt.Errorf("azure entra password authentication is not enabled")
}
default:
return "", fmt.Errorf("unsupported azure authentication type")
}
return connStr, nil
}
type mssqlQueryResultTransformer struct {
userError string
}
func (t *mssqlQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
// go-mssql overrides source error, so we currently match on string
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
logger.Error("Query error", "error", err)
return fmt.Errorf("failed to connect to server - %s", t.userError)
}
return err
}
// CheckHealth pings the connected SQL database
func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
dsHandler, err := s.getDataSourceHandler(ctx, req.PluginContext)
@@ -370,100 +119,3 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
return dsHandler.CheckHealth(ctx, req)
}
func (t *mssqlQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
return []sqlutil.StringConverter{
{
Name: "handle MONEY",
InputScanKind: reflect.Slice,
InputTypeName: "MONEY",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle SMALLMONEY",
InputScanKind: reflect.Slice,
InputTypeName: "SMALLMONEY",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle DECIMAL",
InputScanKind: reflect.Slice,
InputTypeName: "DECIMAL",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle UNIQUEIDENTIFIER",
InputScanKind: reflect.Slice,
InputTypeName: "UNIQUEIDENTIFIER",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableString,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
uuid := &mssql.UniqueIdentifier{}
if err := uuid.Scan([]byte(*in)); err != nil {
return nil, err
}
v := uuid.String()
return &v, nil
},
},
},
{
Name: "handle SQL_VARIANT",
InputScanKind: reflect.Pointer,
InputTypeName: "SQL_VARIANT",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableString,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
return in, nil
},
},
},
}
}
+14 -273
View File
@@ -3,21 +3,21 @@ package mssql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math/rand"
"os"
"testing"
"time"
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
)
// To run this test, set runMssqlTests=true
@@ -39,7 +39,6 @@ func TestMSSQL(t *testing.T) {
t.Skip()
}
queryResultTransformer := mssqlQueryResultTransformer{}
dsInfo := sqleng.DataSourceInfo{}
config := sqleng.DataPluginConfiguration{
DSInfo: dsInfo,
@@ -50,8 +49,10 @@ func TestMSSQL(t *testing.T) {
logger := backend.NewLoggerWith("logger", "mssql.test")
db := initMSSQLTestDB(t, config.DSInfo.JsonData)
ctx := context.Background()
settings := backend.DataSourceInstanceSettings{}
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
require.NoError(t, err)
fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
@@ -799,14 +800,16 @@ func TestMSSQL(t *testing.T) {
require.NoError(t, err)
t.Run("When doing a metric query using stored procedure should return correct result", func(t *testing.T) {
queryResultTransformer := mssqlQueryResultTransformer{}
dsInfo := sqleng.DataSourceInfo{}
config := sqleng.DataPluginConfiguration{
DSInfo: dsInfo,
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
RowLimit: 1000000,
}
endpoint, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
ctx := context.Background()
settings := backend.DataSourceInstanceSettings{}
endpoint, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
require.NoError(t, err)
query := &backend.QueryDataRequest{
Queries: []backend.DataQuery{
@@ -1202,7 +1205,6 @@ func TestMSSQL(t *testing.T) {
})
t.Run("When row limit set to 1", func(t *testing.T) {
queryResultTransformer := mssqlQueryResultTransformer{}
dsInfo := sqleng.DataSourceInfo{}
config := sqleng.DataPluginConfiguration{
DSInfo: dsInfo,
@@ -1210,7 +1212,10 @@ func TestMSSQL(t *testing.T) {
RowLimit: 1,
}
handler, err := sqleng.NewQueryDataHandler("", db, config, &queryResultTransformer, newMssqlMacroEngine(), logger)
ctx := context.Background()
settings := backend.DataSourceInstanceSettings{}
handler, err := sqleng.NewQueryDataHandler(ctx, settings, "", config, logger, &azsettings.AzureSettings{})
require.NoError(t, err)
t.Run("When doing a table query that returns 2 rows should limit the result to 1 row", func(t *testing.T) {
@@ -1313,7 +1318,7 @@ func TestMSSQL(t *testing.T) {
}
func TestTransformQueryError(t *testing.T) {
transformer := &mssqlQueryResultTransformer{}
transformer := &utils.MSSQLQueryResultTransformer{}
logger := backend.NewLoggerWith("logger", "mssql.test")
@@ -1334,252 +1339,6 @@ func TestTransformQueryError(t *testing.T) {
})
}
func TestGenerateConnectionString(t *testing.T) {
kerberosLookup := []kerberos.KerberosLookup{
{
Address: "example.host",
DBName: "testDB",
User: "testUser",
CredentialCacheFilename: "/tmp/cache",
},
}
tmpFile := genTempCacheFile(t, kerberosLookup)
defer func() {
err := os.Remove(tmpFile)
if err != nil {
t.Log(err)
}
}()
testCases := []struct {
desc string
kerberosCfg kerberos.KerberosAuth
dataSource sqleng.DataSourceInfo
expConnStr string
}{
{
desc: "Use Kerberos Credential Cache",
kerberosCfg: kerberos.KerberosAuth{
CredentialCache: "/tmp/krb5cc_1000",
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Credential cache",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
},
{
desc: "Use Kerberos Credential Cache File path",
kerberosCfg: kerberos.KerberosAuth{
CredentialCacheLookupFile: tmpFile,
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: sqleng.DataSourceInfo{
URL: "example.host",
Database: "testDB",
User: "testUser",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Credential cache file",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
},
{
desc: "Use Kerberos Keytab",
kerberosCfg: kerberos.KerberosAuth{
KeytabFilePath: "/foo/bar.keytab",
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Keytab",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
},
{
desc: "Use Kerberos Username and Password",
kerberosCfg: kerberos.KerberosAuth{
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
DecryptedSecureJSONData: map[string]string{
"password": "foo",
},
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Username + password",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
},
{
desc: "Use non-default UDP connection limit",
kerberosCfg: kerberos.KerberosAuth{
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 0,
},
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
DecryptedSecureJSONData: map[string]string{
"password": "foo",
},
JsonData: sqleng.JsonData{
AuthenticationType: "Windows AD: Username + password",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;",
},
{
desc: "From URL w/ port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost:1001",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;",
},
// When no port is specified, the driver should be allowed to choose
{
desc: "From URL w/o port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
// Port 0 should be equivalent to not specifying a port, i.e. let the driver choose
{
desc: "From URL w port 0",
dataSource: sqleng.DataSourceInfo{
URL: "localhost:0",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
{
desc: "With instance name",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
},
{
desc: "With instance name and port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance:333",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
},
{
desc: "With instance name and ApplicationIntent",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
},
{
desc: "With ApplicationIntent instance name and port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
},
{
desc: "With instance name",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
},
{
desc: "With instance name and port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance:333",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
},
{
desc: "With instance name and ApplicationIntent",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
},
{
desc: "With ApplicationIntent instance name and port",
dataSource: sqleng.DataSourceInfo{
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
},
{
desc: "Defaults",
dataSource: sqleng.DataSourceInfo{
Database: "database",
User: "user",
JsonData: sqleng.JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
}
logger := backend.NewLoggerWith("logger", "mssql.test")
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
require.NoError(t, err)
assert.Equal(t, tc.expConnStr, connStr)
})
}
}
func initMSSQLTestDB(t *testing.T, jsonData sqleng.JsonData) *sql.DB {
t.Helper()
@@ -1614,21 +1373,3 @@ func genTimeRangeByInterval(from time.Time, duration time.Duration, interval tim
return timeRange
}
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
content, err := json.Marshal(lookups)
if err != nil {
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
}
tmpFile, err := os.CreateTemp("", "lookup*.json")
if err != nil {
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
}
if _, err := tmpFile.Write(content); err != nil {
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
}
return tmpFile.Name()
}
+158
View File
@@ -0,0 +1,158 @@
package sqleng
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/tsdb/mssql/azure"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
"github.com/grafana/grafana/pkg/util"
mssql "github.com/microsoft/go-mssqldb"
"github.com/microsoft/go-mssqldb/azuread"
)
func newMSSQL(ctx context.Context, driverName string, rowLimit int64, dsInfo DataSourceInfo, cnnstr string, logger log.Logger, settings backend.DataSourceInstanceSettings) (*sql.DB, error) {
var connector *mssql.Connector
var err error
if driverName == "azuresql" {
connector, err = azuread.NewConnector(cnnstr)
} else {
connector, err = mssql.NewConnector(cnnstr)
}
if err != nil {
logger.Error("mssql connector creation failed", "error", err)
return nil, fmt.Errorf("mssql connector creation failed")
}
proxyClient, err := settings.ProxyClient(ctx)
if err != nil {
logger.Error("mssql proxy creation failed", "error", err)
return nil, fmt.Errorf("mssql proxy creation failed")
}
if proxyClient.SecureSocksProxyEnabled() {
dialer, err := proxyClient.NewSecureSocksProxyContextDialer()
if err != nil {
logger.Error("mssql proxy creation failed", "error", err)
return nil, fmt.Errorf("mssql proxy creation failed")
}
URL, err := utils.ParseURL(dsInfo.URL, logger)
if err != nil {
return nil, err
}
mssqlDialer, err := newMSSQLProxyDialer(URL.Hostname(), dialer)
if err != nil {
return nil, err
}
// update the mssql dialer with the proxy dialer
connector.Dialer = (mssqlDialer)
}
config := DataPluginConfiguration{
DSInfo: dsInfo,
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
RowLimit: rowLimit,
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(config.DSInfo.JsonData.MaxOpenConns)
db.SetMaxIdleConns(config.DSInfo.JsonData.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(config.DSInfo.JsonData.ConnMaxLifetime) * time.Second)
return db, nil
}
const (
azureAuthentication = "Azure AD Authentication"
windowsAuthentication = "Windows Authentication"
sqlServerAuthentication = "SQL Server Authentication"
kerberosRaw = "Windows AD: Username + password"
kerberosKeytab = "Windows AD: Keytab"
kerberosCredentialCache = "Windows AD: Credential cache" // #nosec G101
kerberosCredentialCacheFile = "Windows AD: Credential cache file" // #nosec G101
)
func generateConnectionString(dsInfo DataSourceInfo, azureManagedIdentityClientId string, azureEntraPasswordCredentialsEnabled bool, azureCredentials azcredentials.AzureCredentials, kerberosAuth kerberos.KerberosAuth, logger log.Logger) (string, error) {
const dfltPort = "0"
var addr util.NetworkAddress
if dsInfo.URL != "" {
u, err := utils.ParseURL(dsInfo.URL, logger)
if err != nil {
return "", err
}
addr, err = util.SplitHostPortDefault(u.Host, "localhost", dfltPort)
if err != nil {
return "", err
}
} else {
addr = util.NetworkAddress{
Host: "localhost",
Port: dfltPort,
}
}
args := []any{
"url", dsInfo.URL, "host", addr.Host,
}
if addr.Port != "0" {
args = append(args, "port", addr.Port)
}
logger.Debug("Generating connection string", args...)
encrypt := dsInfo.JsonData.Encrypt
tlsSkipVerify := dsInfo.JsonData.TlsSkipVerify
hostNameInCertificate := dsInfo.JsonData.Servername
certificate := dsInfo.JsonData.RootCertFile
connStr := fmt.Sprintf("server=%s;database=%s;",
addr.Host,
dsInfo.Database,
)
switch dsInfo.JsonData.AuthenticationType {
case azureAuthentication:
azureCredentialDSNFragment, err := azure.GetAzureCredentialDSNFragment(azureCredentials, azureManagedIdentityClientId, azureEntraPasswordCredentialsEnabled)
if err != nil {
return "", err
}
connStr += azureCredentialDSNFragment
case windowsAuthentication:
// No user id or password. We're using windows single sign on.
case kerberosRaw, kerberosKeytab, kerberosCredentialCacheFile, kerberosCredentialCache:
connStr = kerberos.Krb5ParseAuthCredentials(addr.Host, addr.Port, dsInfo.Database, dsInfo.User, dsInfo.DecryptedSecureJSONData["password"], kerberosAuth)
default:
connStr += fmt.Sprintf("user id=%s;password=%s;", dsInfo.User, dsInfo.DecryptedSecureJSONData["password"])
}
// Port number 0 means to determine the port automatically, so we can let the driver choose
if addr.Port != "0" {
connStr += fmt.Sprintf("port=%s;", addr.Port)
}
switch encrypt {
case "true":
connStr += fmt.Sprintf("encrypt=%s;TrustServerCertificate=%t;", encrypt, tlsSkipVerify)
if hostNameInCertificate != "" {
connStr += fmt.Sprintf("hostNameInCertificate=%s;", hostNameInCertificate)
}
if certificate != "" {
connStr += fmt.Sprintf("certificate=%s;", certificate)
}
case "disable":
connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt)
}
if dsInfo.JsonData.ConnectionTimeout != 0 {
connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout)
}
return connStr, nil
}
@@ -1,4 +1,4 @@
package mssql
package sqleng
import (
"fmt"
@@ -8,18 +8,17 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng"
)
const rsIdentifier = `([_a-zA-Z0-9]+)`
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
type msSQLMacroEngine struct {
*sqleng.SQLMacroEngineBase
*SQLMacroEngineBase
}
func newMssqlMacroEngine() sqleng.SQLMacroEngine {
return &msSQLMacroEngine{SQLMacroEngineBase: sqleng.NewSQLMacroEngineBase()}
func newMssqlMacroEngine() SQLMacroEngine {
return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()}
}
func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange,
@@ -79,7 +78,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
return "", fmt.Errorf("error parsing interval %v", args[1])
}
if len(args) == 3 {
err := sqleng.SetupFillmode(query, interval, args[2])
err := SetupFillmode(query, interval, args[2])
if err != nil {
return "", err
}
@@ -114,7 +113,7 @@ func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *bac
return "", fmt.Errorf("error parsing interval %v", args[1])
}
if len(args) == 3 {
err := sqleng.SetupFillmode(query, interval, args[2])
err := SetupFillmode(query, interval, args[2])
if err != nil {
return "", err
}
@@ -1,4 +1,4 @@
package mssql
package sqleng
import (
"fmt"
@@ -1,4 +1,4 @@
package mssql
package sqleng
import (
"context"
@@ -1,4 +1,4 @@
package mssql
package sqleng
import (
"context"
+47 -4
View File
@@ -14,11 +14,16 @@ import (
"sync"
"time"
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
"github.com/grafana/grafana-azure-sdk-go/v2/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/utils"
)
// MetaKeyExecutedQueryString is the key where the executed query should get stored
@@ -88,6 +93,10 @@ type DataSourceHandler struct {
dsInfo DataSourceInfo
rowLimit int64
userError string
azureSettings *azsettings.AzureSettings
azureCredentials azcredentials.AzureCredentials
kerberosAuth kerberos.KerberosAuth
driverName string
}
type QueryJson struct {
@@ -113,16 +122,38 @@ func (e *DataSourceHandler) TransformQueryError(logger log.Logger, err error) er
return e.queryResultTransformer.TransformQueryError(logger, err)
}
func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataPluginConfiguration, queryResultTransformer SqlQueryResultTransformer,
macroEngine SQLMacroEngine, log log.Logger) (*DataSourceHandler, error) {
func NewQueryDataHandler(ctx context.Context, settings backend.DataSourceInstanceSettings, userFacingDefaultError string, config DataPluginConfiguration,
log log.Logger, azureSettings *azsettings.AzureSettings) (*DataSourceHandler, error) {
queryResultTransformer := utils.MSSQLQueryResultTransformer{
UserError: userFacingDefaultError,
}
driverName := "mssql"
if config.DSInfo.JsonData.AuthenticationType == azureAuthentication {
driverName = "azuresql"
}
azureCredentials, err := utils.GetAzureCredentials(settings)
if err != nil {
return nil, fmt.Errorf("error reading azure credentials")
}
kerberosAuth, err := kerberos.GetKerberosSettings(settings)
if err != nil {
return nil, fmt.Errorf("error getting kerberos settings: %w", err)
}
queryDataHandler := DataSourceHandler{
queryResultTransformer: queryResultTransformer,
macroEngine: macroEngine,
queryResultTransformer: &queryResultTransformer,
macroEngine: newMssqlMacroEngine(),
timeColumnNames: []string{"time"},
log: log,
dsInfo: config.DSInfo,
rowLimit: config.RowLimit,
userError: userFacingDefaultError,
azureSettings: azureSettings,
azureCredentials: azureCredentials,
kerberosAuth: kerberosAuth,
driverName: driverName,
}
if len(config.TimeColumnNames) > 0 {
@@ -133,7 +164,19 @@ func NewQueryDataHandler(userFacingDefaultError string, db *sql.DB, config DataP
queryDataHandler.metricColumnTypes = config.MetricColumnTypes
}
cnnstr, err := generateConnectionString(config.DSInfo, azureSettings.ManagedIdentityClientId, azureSettings.AzureEntraPasswordCredentialsEnabled, azureCredentials, kerberosAuth, log)
if err != nil {
return nil, err
}
db, err := newMSSQL(ctx, driverName, config.RowLimit, config.DSInfo, cnnstr, log, settings)
if err != nil {
logger.Error("Failed connecting to MSSQL", "err", err)
return nil, err
}
queryDataHandler.db = db
return &queryDataHandler, nil
}
+267
View File
@@ -1,8 +1,10 @@
package sqleng
import (
"encoding/json"
"fmt"
"net"
"os"
"testing"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/tsdb/mssql/kerberos"
"github.com/grafana/grafana/pkg/tsdb/mssql/sqleng/util"
)
@@ -436,3 +439,267 @@ func (t *testQueryResultTransformer) TransformQueryError(_ log.Logger, err error
func (t *testQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
return nil
}
func genTempCacheFile(t *testing.T, lookups []kerberos.KerberosLookup) string {
content, err := json.Marshal(lookups)
if err != nil {
t.Fatalf("Unable to marshall json for temp lookup: %v", err)
}
tmpFile, err := os.CreateTemp("", "lookup*.json")
if err != nil {
t.Fatalf("Unable to create temporary file for temp lookup: %v", err)
}
if _, err := tmpFile.Write(content); err != nil {
t.Fatalf("Unable to write to temporary file for temp lookup: %v", err)
}
return tmpFile.Name()
}
func TestGenerateConnectionString(t *testing.T) {
kerberosLookup := []kerberos.KerberosLookup{
{
Address: "example.host",
DBName: "testDB",
User: "testUser",
CredentialCacheFilename: "/tmp/cache",
},
}
tmpFile := genTempCacheFile(t, kerberosLookup)
defer func() {
err := os.Remove(tmpFile)
if err != nil {
t.Log(err)
}
}()
testCases := []struct {
desc string
kerberosCfg kerberos.KerberosAuth
dataSource DataSourceInfo
expConnStr string
}{
{
desc: "Use Kerberos Credential Cache",
kerberosCfg: kerberos.KerberosAuth{
CredentialCache: "/tmp/krb5cc_1000",
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: DataSourceInfo{
URL: "localhost",
Database: "database",
JsonData: JsonData{
AuthenticationType: "Windows AD: Credential cache",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;krb5-credcachefile=/tmp/krb5cc_1000;",
},
{
desc: "Use Kerberos Credential Cache File path",
kerberosCfg: kerberos.KerberosAuth{
CredentialCacheLookupFile: tmpFile,
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: DataSourceInfo{
URL: "example.host",
Database: "testDB",
User: "testUser",
JsonData: JsonData{
AuthenticationType: "Windows AD: Credential cache file",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=example.host;database=testDB;krb5-credcachefile=/tmp/cache;",
},
{
desc: "Use Kerberos Keytab",
kerberosCfg: kerberos.KerberosAuth{
KeytabFilePath: "/foo/bar.keytab",
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
JsonData: JsonData{
AuthenticationType: "Windows AD: Keytab",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;krb5-keytabfile=/foo/bar.keytab;",
},
{
desc: "Use Kerberos Username and Password",
kerberosCfg: kerberos.KerberosAuth{
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 1,
},
dataSource: DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
DecryptedSecureJSONData: map[string]string{
"password": "foo",
},
JsonData: JsonData{
AuthenticationType: "Windows AD: Username + password",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;",
},
{
desc: "Use non-default UDP connection limit",
kerberosCfg: kerberos.KerberosAuth{
ConfigFilePath: "/etc/krb5.conf",
UDPConnectionLimit: 0,
},
dataSource: DataSourceInfo{
URL: "localhost",
Database: "database",
User: "foo@test.lab",
DecryptedSecureJSONData: map[string]string{
"password": "foo",
},
JsonData: JsonData{
AuthenticationType: "Windows AD: Username + password",
},
},
expConnStr: "authenticator=krb5;krb5-configfile=/etc/krb5.conf;server=localhost;database=database;user id=foo@test.lab;password=foo;krb5-udppreferencelimit=0;",
},
{
desc: "From URL w/ port",
dataSource: DataSourceInfo{
URL: "localhost:1001",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;port=1001;",
},
// When no port is specified, the driver should be allowed to choose
{
desc: "From URL w/o port",
dataSource: DataSourceInfo{
URL: "localhost",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
// Port 0 should be equivalent to not specifying a port, i.e. let the driver choose
{
desc: "From URL w port 0",
dataSource: DataSourceInfo{
URL: "localhost:0",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
{
desc: "With instance name",
dataSource: DataSourceInfo{
URL: "localhost\\instance",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
},
{
desc: "With instance name and port",
dataSource: DataSourceInfo{
URL: "localhost\\instance:333",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
},
{
desc: "With instance name and ApplicationIntent",
dataSource: DataSourceInfo{
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
},
{
desc: "With ApplicationIntent instance name and port",
dataSource: DataSourceInfo{
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
},
{
desc: "With instance name",
dataSource: DataSourceInfo{
URL: "localhost\\instance",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;",
},
{
desc: "With instance name and port",
dataSource: DataSourceInfo{
URL: "localhost\\instance:333",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;",
},
{
desc: "With instance name and ApplicationIntent",
dataSource: DataSourceInfo{
URL: "localhost\\instance;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;ApplicationIntent=ReadOnly;database=database;user id=user;password=;",
},
{
desc: "With ApplicationIntent instance name and port",
dataSource: DataSourceInfo{
URL: "localhost\\instance:333;ApplicationIntent=ReadOnly",
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost\\instance;database=database;user id=user;password=;port=333;ApplicationIntent=ReadOnly;",
},
{
desc: "Defaults",
dataSource: DataSourceInfo{
Database: "database",
User: "user",
JsonData: JsonData{},
},
expConnStr: "server=localhost;database=database;user id=user;password=;",
},
}
logger := backend.NewLoggerWith("logger", "mssql.test")
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
connStr, err := generateConnectionString(tc.dataSource, "", false, nil, tc.kerberosCfg, logger)
require.NoError(t, err)
assert.Equal(t, tc.expConnStr, connStr)
})
}
}
+146
View File
@@ -3,9 +3,18 @@ package utils
import (
"encoding/json"
"fmt"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/grafana/grafana-azure-sdk-go/v2/azcredentials"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
mssql "github.com/microsoft/go-mssqldb"
)
// GetJsonData just gets the json in easier to work with type. It's used on multiple places which isn't super effective
@@ -26,3 +35,140 @@ func GetAzureCredentials(settings backend.DataSourceInstanceSettings) (azcredent
}
return azcredentials.FromDatasourceData(jsonData, settings.DecryptedSecureJSONData)
}
type DebugOnlyLogger interface {
Debug(msg string, args ...interface{})
}
// ParseURL tries to parse an MSSQL URL string into a URL object.
func ParseURL(u string, logger DebugOnlyLogger) (*url.URL, error) {
logger.Debug("Parsing MSSQL URL", "url", u)
// Recognize ODBC connection strings like host\instance:1234
reODBC := regexp.MustCompile(`^[^\\:]+(?:\\[^:]+)?(?::\d+)?(?:;.+)?$`)
var host string
switch {
case reODBC.MatchString(u):
logger.Debug("Recognized as ODBC URL format", "url", u)
host = u
default:
logger.Debug("Couldn't recognize as valid MSSQL URL", "url", u)
return nil, fmt.Errorf("unrecognized MSSQL URL format: %q", u)
}
return &url.URL{
Scheme: "sqlserver",
Host: host,
}, nil
}
type MSSQLQueryResultTransformer struct {
UserError string
}
func (t *MSSQLQueryResultTransformer) TransformQueryError(logger log.Logger, err error) error {
// go-mssql overrides source error, so we currently match on string
// ref https://github.com/denisenkom/go-mssqldb/blob/045585d74f9069afe2e115b6235eb043c8047043/tds.go#L904
if strings.HasPrefix(strings.ToLower(err.Error()), "unable to open tcp connection with host") {
logger.Error("Query error", "error", err)
return fmt.Errorf("failed to connect to server - %s", t.UserError)
}
return err
}
func (t *MSSQLQueryResultTransformer) GetConverterList() []sqlutil.StringConverter {
return []sqlutil.StringConverter{
{
Name: "handle MONEY",
InputScanKind: reflect.Slice,
InputTypeName: "MONEY",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle SMALLMONEY",
InputScanKind: reflect.Slice,
InputTypeName: "SMALLMONEY",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle DECIMAL",
InputScanKind: reflect.Slice,
InputTypeName: "DECIMAL",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableFloat64,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
v, err := strconv.ParseFloat(*in, 64)
if err != nil {
return nil, err
}
return &v, nil
},
},
},
{
Name: "handle UNIQUEIDENTIFIER",
InputScanKind: reflect.Slice,
InputTypeName: "UNIQUEIDENTIFIER",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableString,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
uuid := &mssql.UniqueIdentifier{}
if err := uuid.Scan([]byte(*in)); err != nil {
return nil, err
}
v := uuid.String()
return &v, nil
},
},
},
{
Name: "handle SQL_VARIANT",
InputScanKind: reflect.Pointer,
InputTypeName: "SQL_VARIANT",
ConversionFunc: func(in *string) (*string, error) { return in, nil },
Replacer: &sqlutil.StringFieldReplacer{
OutputFieldType: data.FieldTypeNullableString,
ReplaceFunc: func(in *string) (any, error) {
if in == nil {
return nil, nil
}
return in, nil
},
},
},
}
}