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
+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
}
+131
View File
@@ -0,0 +1,131 @@
package sqleng
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
)
const rsIdentifier = `([_a-zA-Z0-9]+)`
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
type msSQLMacroEngine struct {
*SQLMacroEngineBase
}
func newMssqlMacroEngine() SQLMacroEngine {
return &msSQLMacroEngine{SQLMacroEngineBase: NewSQLMacroEngineBase()}
}
func (m *msSQLMacroEngine) Interpolate(query *backend.DataQuery, timeRange backend.TimeRange,
sql string) (string, error) {
// TODO: Return any error
rExp, _ := regexp.Compile(sExpr)
var macroError error
sql = m.ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string {
args := strings.Split(groups[2], ",")
for i, arg := range args {
args[i] = strings.Trim(arg, " ")
}
res, err := m.evaluateMacro(timeRange, query, groups[1], args)
if err != nil && macroError == nil {
macroError = err
return "macro_error()"
}
return res
})
if macroError != nil {
return "", macroError
}
return sql, nil
}
func (m *msSQLMacroEngine) evaluateMacro(timeRange backend.TimeRange, query *backend.DataQuery, name string, args []string) (string, error) {
switch name {
case "__time":
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s AS time", args[0]), nil
case "__timeEpoch":
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("DATEDIFF(second, '1970-01-01', %s) AS time", args[0]), nil
case "__timeFilter":
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], timeRange.From.UTC().Format(time.RFC3339), timeRange.To.UTC().Format(time.RFC3339)), nil
case "__timeFrom":
return fmt.Sprintf("'%s'", timeRange.From.UTC().Format(time.RFC3339)), nil
case "__timeTo":
return fmt.Sprintf("'%s'", timeRange.To.UTC().Format(time.RFC3339)), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name)
}
interval, err := gtime.ParseInterval(strings.Trim(args[1], `'"`))
if err != nil {
return "", fmt.Errorf("error parsing interval %v", args[1])
}
if len(args) == 3 {
err := SetupFillmode(query, interval, args[2])
if err != nil {
return "", err
}
}
return fmt.Sprintf("FLOOR(DATEDIFF(second, '1970-01-01', %s)/%.0f)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil
case "__timeGroupAlias":
tg, err := m.evaluateMacro(timeRange, query, "__timeGroup", args)
if err == nil {
return tg + " AS [time]", nil
}
return "", err
case "__unixEpochFilter":
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().Unix(), args[0], timeRange.To.UTC().Unix()), nil
case "__unixEpochNanoFilter":
if len(args) == 0 {
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().UnixNano(), args[0], timeRange.To.UTC().UnixNano()), nil
case "__unixEpochNanoFrom":
return fmt.Sprintf("%d", timeRange.From.UTC().UnixNano()), nil
case "__unixEpochNanoTo":
return fmt.Sprintf("%d", timeRange.To.UTC().UnixNano()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
}
interval, err := gtime.ParseInterval(strings.Trim(args[1], `'`))
if err != nil {
return "", fmt.Errorf("error parsing interval %v", args[1])
}
if len(args) == 3 {
err := SetupFillmode(query, interval, args[2])
if err != nil {
return "", err
}
}
return fmt.Sprintf("FLOOR(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil
case "__unixEpochGroupAlias":
tg, err := m.evaluateMacro(timeRange, query, "__unixEpochGroup", args)
if err == nil {
return tg + " AS [time]", nil
}
return "", err
default:
return "", fmt.Errorf("unknown macro %q", name)
}
}
+258
View File
@@ -0,0 +1,258 @@
package sqleng
import (
"fmt"
"sync"
"testing"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/stretchr/testify/require"
)
func TestMacroEngine(t *testing.T) {
engine := &msSQLMacroEngine{}
query := &backend.DataQuery{
JSON: []byte("{}"),
}
dfltTimeRange := backend.TimeRange{}
t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) {
from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC)
to := from.Add(5 * time.Minute)
timeRange := backend.TimeRange{From: from, To: to}
t.Run("interpolate __time function", func(t *testing.T) {
sql, err := engine.Interpolate(query, dfltTimeRange, "select $__time(time_column)")
require.Nil(t, err)
require.Equal(t, "select time_column AS time", sql)
})
t.Run("interpolate __timeEpoch function", func(t *testing.T) {
sql, err := engine.Interpolate(query, dfltTimeRange, "select $__timeEpoch(time_column)")
require.Nil(t, err)
require.Equal(t, "select DATEDIFF(second, '1970-01-01', time_column) AS time", sql)
})
t.Run("interpolate __timeEpoch function wrapped in aggregation", func(t *testing.T) {
sql, err := engine.Interpolate(query, dfltTimeRange, "select min($__timeEpoch(time_column))")
require.Nil(t, err)
require.Equal(t, "select min(DATEDIFF(second, '1970-01-01', time_column) AS time)", sql)
})
t.Run("interpolate __timeFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
})
t.Run("interpolate __timeFrom function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()")
require.Nil(t, err)
require.Equal(t, "select '2018-04-12T18:00:00Z'", sql)
})
t.Run("interpolate __timeTo function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()")
require.Nil(t, err)
require.Equal(t, "select '2018-04-12T18:05:00Z'", sql)
})
t.Run("interpolate __timeGroup function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
require.Nil(t, err)
sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')")
require.Nil(t, err)
require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql)
require.Equal(t, sql+" AS [time]", sql2)
})
t.Run("interpolate __timeGroup function with spaces around arguments", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')")
require.Nil(t, err)
sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')")
require.Nil(t, err)
require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql)
require.Equal(t, sql+" AS [time]", sql2)
})
t.Run("interpolate __timeGroup function with fill (value = NULL)", func(t *testing.T) {
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)")
require.Nil(t, err)
queryJson, err := query.JSON.MarshalJSON()
require.Nil(t, err)
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"null"}`, string(queryJson))
})
t.Run("interpolate __timeGroup function with fill (value = previous)", func(t *testing.T) {
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', previous)")
require.Nil(t, err)
queryJson, err := query.JSON.MarshalJSON()
require.Nil(t, err)
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"previous"}`, string(queryJson))
})
t.Run("interpolate __timeGroup function with fill (value = float)", func(t *testing.T) {
_, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)")
require.Nil(t, err)
queryJson, err := query.JSON.MarshalJSON()
require.Nil(t, err)
require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"value","fillValue":1.5}`, string(queryJson))
})
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
})
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
})
t.Run("interpolate __unixEpochNanoFrom function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select %d", from.UnixNano()), sql)
})
t.Run("interpolate __unixEpochNanoTo function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select %d", to.UnixNano()), sql)
})
t.Run("interpolate __unixEpochGroup function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
require.Nil(t, err)
sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')")
require.Nil(t, err)
require.Equal(t, "SELECT FLOOR(time_column/300)*300", sql)
require.Equal(t, sql+" AS [time]", sql2)
})
})
t.Run("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func(t *testing.T) {
from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC)
to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC)
timeRange := backend.TimeRange{
From: from,
To: to,
}
t.Run("interpolate __timeFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
})
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
})
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
})
})
t.Run("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func(t *testing.T) {
from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC)
to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC)
timeRange := backend.TimeRange{
From: from,
To: to,
}
t.Run("interpolate __timeFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql)
})
t.Run("interpolate __unixEpochFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql)
})
t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
require.Nil(t, err)
require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql)
})
t.Run("should return unmodified sql if there are no macros present", func(t *testing.T) {
sqls := []string{
"select * from table",
"select count(val) from table",
"select col1, col2,col3, col4 from table where col1 = 'val1' and col2 = 'val2' order by col1 asc",
}
for _, sql := range sqls {
actual, err := engine.Interpolate(query, timeRange, sql)
require.Nil(t, err)
require.Equal(t, sql, actual)
}
})
})
}
func TestMacroEngineConcurrency(t *testing.T) {
engine := newMssqlMacroEngine()
query1 := backend.DataQuery{
JSON: []byte{},
}
query2 := backend.DataQuery{
JSON: []byte{},
}
from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC)
to := from.Add(5 * time.Minute)
timeRange := backend.TimeRange{
From: from,
To: to,
}
var wg sync.WaitGroup
wg.Add(2)
go func(query backend.DataQuery) {
defer wg.Done()
_, err := engine.Interpolate(&query, timeRange, "SELECT $__timeGroup(time_column,'5m')")
require.NoError(t, err)
}(query1)
go func(query backend.DataQuery) {
_, err := engine.Interpolate(&query, timeRange, "SELECT $__timeGroup(time_column,'5m')")
require.NoError(t, err)
defer wg.Done()
}(query2)
wg.Wait()
}
+33
View File
@@ -0,0 +1,33 @@
package sqleng
import (
"context"
"errors"
"net"
mssql "github.com/microsoft/go-mssqldb"
"golang.org/x/net/proxy"
)
type HostTransportDialer struct {
Dialer proxy.ContextDialer
Host string
}
func (m HostTransportDialer) HostName() string {
return m.Host
}
func (m HostTransportDialer) DialContext(ctx context.Context, network string, addr string) (conn net.Conn, err error) {
return m.Dialer.DialContext(ctx, network, addr)
}
// // we wrap the proxy.Dialer to become dialer that the mssql module accepts
func newMSSQLProxyDialer(hostName string, dialer proxy.Dialer) (mssql.Dialer, error) {
contextDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
return nil, errors.New("unable to cast socks proxy dialer to context proxy dialer")
}
return &HostTransportDialer{contextDialer, hostName}, nil
}
+77
View File
@@ -0,0 +1,77 @@
package sqleng
import (
"context"
"database/sql"
"fmt"
"net"
"testing"
mssql "github.com/microsoft/go-mssqldb"
"github.com/stretchr/testify/require"
"golang.org/x/net/proxy"
)
type testDialer struct {
Host string
}
func (d *testDialer) Dial(network, addr string) (c net.Conn, err error) {
return nil, fmt.Errorf("test-dialer: Dial is not functional")
}
func (d *testDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
hostWithPort := d.HostName() + ":1433"
if address != hostWithPort {
return nil, fmt.Errorf("test-dialer: address does not match hostname")
}
return nil, fmt.Errorf("test-dialer: DialContext is not functional")
}
func (d *testDialer) HostName() string {
return d.Host
}
var _ proxy.Dialer = (&testDialer{})
func TestMSSQLProxyDriver(t *testing.T) {
t.Run("Connector should use dialer context that routes through the socks proxy to db", func(t *testing.T) {
host := "127.0.0.1"
cnnstr := fmt.Sprintf("server=%s;port=1433;user id=sa;password=yourStrong(!)Password;database=db", host)
connector, err := mssql.NewConnector(cnnstr)
require.NoError(t, err)
td := testDialer{
Host: host,
}
dialer, err := newMSSQLProxyDialer("%s", &td)
require.NoError(t, err)
connector.Dialer = (dialer)
db := sql.OpenDB(connector)
err = db.Ping()
require.Contains(t, err.Error(), "test-dialer: DialContext is not functional")
})
t.Run("Connector should use hostname rather than attempting to resolve IP", func(t *testing.T) {
host := "www.grafana.com"
cnnstr := fmt.Sprintf("server=%s;port=1433;user id=sa;password=yourStrong(!)Password;database=db", host)
connector, err := mssql.NewConnector(cnnstr)
require.NoError(t, err)
td := testDialer{
Host: host,
}
dialer, err := newMSSQLProxyDialer(host, &td)
require.NoError(t, err)
connector.Dialer = (dialer)
db := sql.OpenDB(connector)
err = db.Ping()
require.Contains(t, err.Error(), "test-dialer: DialContext is not functional")
})
}
+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)
})
}
}