Skip to content

Commit 296f480

Browse files
authored
Merge pull request #20 from Icinga/support-set-session-vars
MySQL/MariaDB set session vars unittests
2 parents bd2bf65 + 89a88c0 commit 296f480

File tree

4 files changed

+199
-12
lines changed

4 files changed

+199
-12
lines changed

.github/workflows/sql.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: SQL
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request: {}
8+
9+
jobs:
10+
mysql:
11+
name: ${{ matrix.database.name }}
12+
runs-on: ubuntu-latest
13+
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
database:
18+
- {name: MySQL 5.7, image: "mysql:5.7"}
19+
- {name: MySQL 8.0, image: "mysql:8.0"}
20+
- {name: MySQL latest, image: "mysql:latest"}
21+
- {name: MariaDB 10.1, image: "mariadb:10.1"}
22+
- {name: MariaDB 10.2, image: "mariadb:10.2"}
23+
- {name: MariaDB 10.3, image: "mariadb:10.3"}
24+
- {name: MariaDB 10.4, image: "mariadb:10.4"}
25+
- {name: MariaDB 10.5, image: "mariadb:10.5"}
26+
- {name: MariaDB 10.6, image: "mariadb:10.6"}
27+
- {name: MariaDB 10.7, image: "mariadb:10.7"}
28+
- {name: MariaDB 10.11, image: "mariadb:10.11"}
29+
- {name: MariaDB 11.0, image: "mariadb:11.0"}
30+
- {name: MariaDB latest, image: "mariadb:latest"}
31+
32+
env:
33+
ICINGAGOLIBRARY_TESTS_DB_TYPE: mysql
34+
ICINGAGOLIBRARY_TESTS_DB: icinga_unittest
35+
ICINGAGOLIBRARY_TESTS_DB_USER: root
36+
ICINGAGOLIBRARY_TESTS_DB_PASSWORD: password
37+
ICINGAGOLIBRARY_TESTS_DB_HOST: 127.0.0.1
38+
ICINGAGOLIBRARY_TESTS_DB_PORT: 3306
39+
40+
services:
41+
mysql:
42+
image: ${{ matrix.database.image }}
43+
env:
44+
MYSQL_ROOT_PASSWORD: ${{ env.ICINGAGOLIBRARY_TESTS_DB_PASSWORD }}
45+
MYSQL_DATABASE: ${{ env.ICINGAGOLIBRARY_TESTS_DB }}
46+
# Wait for the containers to become ready
47+
options: >-
48+
--health-cmd "${{ (startsWith(matrix.database.image, 'mysql:') || startsWith(matrix.database.image, 'mariadb:10')) && 'mysqladmin ping' || 'healthcheck.sh --connect --innodb_initialized' }}"
49+
--health-interval 10s
50+
--health-timeout 5s
51+
--health-retries 10
52+
ports:
53+
- 3306:3306
54+
55+
steps:
56+
- name: Setup Go
57+
uses: actions/setup-go@v5
58+
with:
59+
go-version: stable
60+
61+
- name: Checkout code
62+
uses: actions/checkout@v4
63+
64+
- name: Download dependencies
65+
run: go get -v -t -d ./...
66+
67+
- name: Run tests
68+
timeout-minutes: 10
69+
run: go test -v -timeout 5m ./...

database/db.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,13 @@ func NewDbFromConfig(c *Config, logger *logging.Logger, connectorCallbacks Retry
141141
}
142142
}
143143

144-
return setGaleraOpts(ctx, conn, int64(c.Options.WsrepSyncWait))
144+
// Set the "wsrep_sync_wait" variable for each session and ensures that causality checks are performed
145+
// before execution and that each statement is executed on a fully synchronized node. Doing so prevents
146+
// foreign key violation when inserting into dependent tables on different MariaDB/MySQL nodes. When using
147+
// MySQL single nodes, the "SET SESSION" command will fail with "Unknown system variable (1193)" and will
148+
// therefore be silently dropped.
149+
// https://mariadb.com/kb/en/galera-cluster-system-variables/#wsrep_sync_wait
150+
return unsafeSetSessionVariableIfExists(ctx, conn, "wsrep_sync_wait", fmt.Sprint(c.Options.WsrepSyncWait))
145151
}
146152

147153
addr = config.Addr

database/utils.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ package database
33
import (
44
"context"
55
"database/sql/driver"
6+
"fmt"
67
"github.com/go-sql-driver/mysql"
78
"github.com/icinga/icinga-go-library/com"
89
"github.com/icinga/icinga-go-library/strcase"
910
"github.com/icinga/icinga-go-library/types"
1011
"github.com/pkg/errors"
11-
"strconv"
1212
)
1313

1414
// CantPerformQuery wraps the given error with the specified query that cannot be executed.
@@ -44,22 +44,22 @@ func SplitOnDupId[T IDer]() com.BulkChunkSplitPolicy[T] {
4444
}
4545
}
4646

47-
// setGaleraOpts sets the "wsrep_sync_wait" variable for each session ensures that causality checks are performed
48-
// before execution and that each statement is executed on a fully synchronized node. Doing so prevents foreign key
49-
// violation when inserting into dependent tables on different MariaDB/MySQL nodes. When using MySQL single nodes,
50-
// the "SET SESSION" command will fail with "Unknown system variable (1193)" and will therefore be silently dropped.
47+
// unsafeSetSessionVariableIfExists sets the given MySQL/MariaDB system variable for the specified database session.
5148
//
52-
// https://mariadb.com/kb/en/galera-cluster-system-variables/#wsrep_sync_wait
53-
func setGaleraOpts(ctx context.Context, conn driver.Conn, wsrepSyncWait int64) error {
54-
galeraOpts := "SET SESSION wsrep_sync_wait=" + strconv.FormatInt(wsrepSyncWait, 10)
49+
// NOTE: It is unsafe to use this function with untrusted/user supplied inputs and poses an SQL injection,
50+
// because it doesn't use a prepared statement, but executes the SQL command directly with the provided inputs.
51+
//
52+
// When the "SET SESSION" command fails with "Unknown system variable (1193)", the error will be silently
53+
// dropped but returns all other database errors.
54+
func unsafeSetSessionVariableIfExists(ctx context.Context, conn driver.Conn, variable, value string) error {
55+
stmt := fmt.Sprintf("SET SESSION %s=%s", variable, value)
5556

56-
_, err := conn.(driver.ExecerContext).ExecContext(ctx, galeraOpts, nil)
57-
if err != nil {
57+
if _, err := conn.(driver.ExecerContext).ExecContext(ctx, stmt, nil); err != nil {
5858
if errors.Is(err, &mysql.MySQLError{Number: 1193}) { // Unknown system variable
5959
return nil
6060
}
6161

62-
return errors.Wrap(err, "cannot execute "+galeraOpts)
62+
return CantPerformQuery(err, stmt)
6363
}
6464

6565
return nil

database/utils_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"database/sql/driver"
6+
"github.com/creasty/defaults"
7+
"github.com/go-sql-driver/mysql"
8+
"github.com/icinga/icinga-go-library/logging"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap/zaptest"
12+
"os"
13+
"strconv"
14+
"strings"
15+
"testing"
16+
"time"
17+
)
18+
19+
func TestSetMysqlSessionVars(t *testing.T) {
20+
vars := map[string][]struct {
21+
name string
22+
value string
23+
expect error
24+
}{
25+
"UnknownVariables": {
26+
// MySQL single nodes do not recognise the "wsrep_sync_wait" system variable, but MariaDB does!
27+
{name: "wsrep_sync_wait", value: "15"}, // MySQL unknown sys var | MariaDB succeeds
28+
{name: "wsrep_sync_wait", value: "7"}, // MySQL unknown sys var | MariaDB succeeds
29+
// Just some random unknown system variables :-)
30+
{name: "Icinga", value: "Icinga"}, // unknown sys var
31+
{name: "IcingaDB", value: "IcingaDB"}, // unknown sys var
32+
},
33+
"VariablesWithCorrectValue": { // Setting system variables known by MySQL/MariaDB to a valid value
34+
{name: "autocommit", value: "true"},
35+
{name: "binlog_format", value: "MIXED"},
36+
{name: "completion_type", value: "1" /** CHAIN */},
37+
{name: "completion_type", value: "CHAIN"},
38+
{name: "default_storage_engine", value: "InnoDB"},
39+
},
40+
"VariablesWithInvalidValues": { // System variables set to an invalid value
41+
{name: "autocommit", value: "SOMETHING", expect: &mysql.MySQLError{Number: 1231}},
42+
{name: "binlog_format", value: "IcingaDB", expect: &mysql.MySQLError{Number: 1231}}, // Invalid val!
43+
{name: "completion_type", value: "-10", expect: &mysql.MySQLError{Number: 1231}}, // Min valid val 0
44+
{name: "default_storage_engine", value: "IcingaDB", expect: &mysql.MySQLError{Number: 1286}}, // Unknown storage Engine!
45+
},
46+
}
47+
48+
ctx := context.Background()
49+
db := GetTestDB(ctx, t, "ICINGAGOLIBRARY")
50+
if db.DriverName() != MySQL {
51+
t.Skipf("skipping set session vars test for %q driver", db.DriverName())
52+
}
53+
54+
for name, vs := range vars {
55+
t.Run(name, func(t *testing.T) {
56+
for _, v := range vs {
57+
conn, err := db.DB.Conn(ctx)
58+
require.NoError(t, err, "connecting to MySQL/MariaDB database should not fail")
59+
60+
err = conn.Raw(func(conn any) error {
61+
return unsafeSetSessionVariableIfExists(ctx, conn.(driver.Conn), v.name, v.value)
62+
})
63+
64+
assert.ErrorIsf(t, err, v.expect, "setting %q variable to '%v' returns unexpected result", v.name, v.value)
65+
assert.NoError(t, conn.Close(), "closing MySQL/MariaDB connection should not fail")
66+
}
67+
})
68+
}
69+
}
70+
71+
// GetTestDB retrieves the database config from env variables, opens a new database and returns it.
72+
// The [envPrefix] argument defines the environment variables prefix to look for e.g. `ICINGAGOLIBRARY`.
73+
//
74+
// The test suite will be skipped if no `envPrefix+"_TESTS_DB_TYPE" environment variable is
75+
// set, otherwise fails fatally when invalid configurations are specified.
76+
func GetTestDB(ctx context.Context, t *testing.T, envPrefix string) *DB {
77+
c := &Config{}
78+
require.NoError(t, defaults.Set(c), "applying config default should not fail")
79+
80+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB_TYPE"); ok {
81+
c.Type = strings.ToLower(v)
82+
} else {
83+
t.Skipf("Environment %q not set, skipping test!", envPrefix+"_TESTS_DB_TYPE")
84+
}
85+
86+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB"); ok {
87+
c.Database = v
88+
}
89+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB_USER"); ok {
90+
c.User = v
91+
}
92+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB_PASSWORD"); ok {
93+
c.Password = v
94+
}
95+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB_HOST"); ok {
96+
c.Host = v
97+
}
98+
if v, ok := os.LookupEnv(envPrefix + "_TESTS_DB_PORT"); ok {
99+
port, err := strconv.Atoi(v)
100+
require.NoError(t, err, "invalid port provided")
101+
102+
c.Port = port
103+
}
104+
105+
require.NoError(t, c.Validate(), "database config validation should not fail")
106+
107+
db, err := NewDbFromConfig(c, logging.NewLogger(zaptest.NewLogger(t).Sugar(), time.Hour), RetryConnectorCallbacks{})
108+
require.NoError(t, err, "connecting to database should not fail")
109+
require.NoError(t, db.PingContext(ctx), "pinging the database should not fail")
110+
111+
return db
112+
}

0 commit comments

Comments
 (0)