master
go 506 lines 18.7 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package mssql
4
5 import (
6 "context"
7 "fmt"
8 "testing"
9
10 "github.com/DATA-DOG/go-sqlmock"
11 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
12 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 func TestCollector_Init(t *testing.T) {
18 c := New()
19 c.DSN = "sqlserver://localhost:1433"
20
21 assert.NoError(t, c.Init(context.Background()))
22 }
23
24 func TestCollector_Init_EmptyDSN(t *testing.T) {
25 c := New()
26 c.DSN = ""
27
28 assert.Error(t, c.Init(context.Background()))
29 }
30
31 func TestCollector_Init_InvalidAzureADConfig(t *testing.T) {
32 c := New()
33 c.CloudAuth.Provider = cloudauth.ProviderAzureAD
34 c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
35 Mode: cloudauth.AzureADAuthModeServicePrincipal,
36 ModeServicePrincipal: &cloudauth.AzureADModeServicePrincipalConfig{
37 ClientID: "client-id",
38 TenantID: "tenant-id",
39 },
40 }
41 // Missing client_secret.
42
43 assert.Error(t, c.Init(context.Background()))
44 }
45
46 func TestCollector_openConnection_AzureADRequiresURLDSN(t *testing.T) {
47 c := New()
48 c.DSN = "server=localhost;database=master"
49 c.CloudAuth.Provider = cloudauth.ProviderAzureAD
50 c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
51
52 db, err := c.openConnection()
53 assert.Nil(t, db)
54 assert.ErrorContains(t, err, "error preparing cloud auth SQL Server DSN")
55 }
56
57 func TestCollector_resolveConnectionParams_AzureADRewritesDSNAndDriver(t *testing.T) {
58 c := New()
59 c.DSN = "sqlserver://localhost:1433?database=master"
60 c.CloudAuth.Provider = cloudauth.ProviderAzureAD
61 c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
62
63 driverName, dsn, err := c.resolveConnectionParams()
64 require.NoError(t, err)
65
66 assert.Equal(t, sqladapter.MSSQLAzureDriverName, driverName)
67 assert.Contains(t, dsn, "fedauth=ActiveDirectoryDefault")
68 }
69
70 func TestCollector_Configuration(t *testing.T) {
71 c := New()
72
73 // Verify defaults
74 assert.Equal(t, "sqlserver://localhost:1433", c.DSN)
75 }
76
77 func TestCollector_Charts(t *testing.T) {
78 c := New()
79
80 charts := c.Charts()
81 assert.NotNil(t, charts)
82 assert.NotEmpty(t, *charts)
83 }
84
85 func TestParseMajorVersion(t *testing.T) {
86 tests := []struct {
87 version string
88 expected int
89 }{
90 {"16.0.4175.1", 16},
91 {"15.0.4198.2", 15},
92 {"14.0.3456.2", 14},
93 {"13.0.6300.2", 13},
94 {"12.0.6024.0", 12},
95 {"11.0.7001.0", 11},
96 {"", 0},
97 {"invalid", 0},
98 {"abc.def", 0},
99 }
100
101 for _, tt := range tests {
102 t.Run(tt.version, func(t *testing.T) {
103 assert.Equal(t, tt.expected, parseMajorVersion(tt.version))
104 })
105 }
106 }
107
108 func TestCleanAGName(t *testing.T) {
109 tests := []struct {
110 name string
111 expected string
112 }{
113 {"MyAG", "myag"},
114 {"My AG-1", "my_ag_1"},
115 {"SERVER\\INSTANCE", "server_instance"},
116 {"ag.with.dots", "ag_with_dots"},
117 {"simple", "simple"},
118 }
119
120 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {
122 assert.Equal(t, tt.expected, cleanAGName(tt.name))
123 })
124 }
125 }
126
127 func TestCleanAGReplicaName(t *testing.T) {
128 assert.Equal(t, "myag_server1", cleanAGReplicaName("MyAG", "SERVER1"))
129 assert.Equal(t, "prod_ag_node1_instance", cleanAGReplicaName("Prod-AG", "Node1.Instance"))
130 }
131
132 func TestCleanAGDatabaseReplicaName(t *testing.T) {
133 assert.Equal(t, "myag_server1_testdb", cleanAGDatabaseReplicaName("MyAG", "SERVER1", "TestDB"))
134 }
135
136 func TestAGDatabaseReplicaQuery(t *testing.T) {
137 assert.Equal(t, queryAGDatabaseReplicasPre16, agDatabaseReplicaQuery(11))
138 assert.Equal(t, queryAGDatabaseReplicasPre16, agDatabaseReplicaQuery(12))
139 assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(13))
140 assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(14))
141 assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(15))
142 assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(16))
143 }
144
145 func TestCollector_Collect(t *testing.T) {
146 tests := map[string]struct {
147 majorVersion int // 0 means default (16)
148 prepareMock func(mock sqlmock.Sqlmock)
149 collectFn func(c *Collector, mx map[string]int64) error
150 wantErr bool
151 wantMetrics map[string]int64
152 notWantMetrics []string
153 checkCollector func(t *testing.T, c *Collector)
154 }{
155 "database log counters: complete unordered rows": {
156 prepareMock: func(mock sqlmock.Sqlmock) {
157 mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
158 sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
159 AddRow("AppDB", "Log Shrinks", int64(2)).
160 AddRow("AppDB", "Log File(s) Used Size (KB)", int64(256)).
161 AddRow("AppDB", "Log Truncations", int64(3)).
162 AddRow("AppDB", "Log File(s) Size (KB)", int64(1024)),
163 )
164 },
165 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
166 wantMetrics: map[string]int64{
167 "database_appdb_log_size_used": 256 * 1024,
168 "database_appdb_log_size_free": 768 * 1024,
169 "database_appdb_log_percent_used": 2500,
170 "database_appdb_log_truncations": 3,
171 "database_appdb_log_shrinks": 2,
172 },
173 checkCollector: func(t *testing.T, c *Collector) {
174 assert.True(t, c.seenDatabasesWithLog["AppDB"])
175 },
176 },
177 "database log counters: missing used skips size and percent": {
178 prepareMock: func(mock sqlmock.Sqlmock) {
179 mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
180 sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
181 AddRow("AppDB", "Log File(s) Size (KB)", int64(1024)).
182 AddRow("AppDB", "Log Truncations", int64(3)),
183 )
184 },
185 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
186 wantMetrics: map[string]int64{
187 "database_appdb_log_truncations": 3,
188 },
189 notWantMetrics: []string{
190 "database_appdb_log_size_used",
191 "database_appdb_log_size_free",
192 "database_appdb_log_percent_used",
193 },
194 },
195 "database log counters: used greater than size clamps free": {
196 prepareMock: func(mock sqlmock.Sqlmock) {
197 mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
198 sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
199 AddRow("AppDB", "Log File(s) Size (KB)", int64(100)).
200 AddRow("AppDB", "Log File(s) Used Size (KB)", int64(150)),
201 )
202 },
203 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
204 wantMetrics: map[string]int64{
205 "database_appdb_log_size_used": 150 * 1024,
206 "database_appdb_log_size_free": 0,
207 "database_appdb_log_percent_used": 15000,
208 },
209 },
210 "database log counters: query error": {
211 prepareMock: func(mock sqlmock.Sqlmock) {
212 mock.ExpectQuery(queryDatabaseLogCounters).WillReturnError(fmt.Errorf("access denied"))
213 },
214 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
215 wantErr: true,
216 },
217 "ag health: success": {
218 prepareMock: func(mock sqlmock.Sqlmock) {
219 mock.ExpectQuery(queryAGHealth).WillReturnRows(
220 sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
221 AddRow("TestAG", int64(2), int64(1), int64(-1)),
222 )
223 },
224 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGHealth(mx) },
225 wantMetrics: map[string]int64{
226 "ag_testag_sync_health_not_healthy": 0,
227 "ag_testag_sync_health_partially_healthy": 0,
228 "ag_testag_sync_health_healthy": 1,
229 "ag_testag_primary_recovery_online": 1,
230 "ag_testag_primary_recovery_in_progress": 0,
231 "ag_testag_secondary_recovery_online": 0,
232 "ag_testag_secondary_recovery_in_progress": 0,
233 },
234 checkCollector: func(t *testing.T, c *Collector) {
235 assert.True(t, c.seenAGs["TestAG"])
236 },
237 },
238 "ag health: query error": {
239 prepareMock: func(mock sqlmock.Sqlmock) {
240 mock.ExpectQuery(queryAGHealth).WillReturnError(fmt.Errorf("connection lost"))
241 },
242 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGHealth(mx) },
243 wantErr: true,
244 },
245 "ag replica states: two replicas": {
246 prepareMock: func(mock sqlmock.Sqlmock) {
247 mock.ExpectQuery(queryAGReplicaStates).WillReturnRows(
248 sqlmock.NewRows([]string{"ag_name", "replica_server_name", "availability_mode", "failover_mode", "role", "connected_state", "synchronization_health"}).
249 AddRow("TestAG", "SERVER1", "synchronous_commit", "automatic", int64(1), int64(1), int64(2)).
250 AddRow("TestAG", "SERVER2", "asynchronous_commit", "manual", int64(2), int64(1), int64(2)),
251 )
252 },
253 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGReplicaStates(mx) },
254 wantMetrics: map[string]int64{
255 "ag_replica_testag_server1_role_primary": 1,
256 "ag_replica_testag_server1_role_secondary": 0,
257 "ag_replica_testag_server1_connected": 1,
258 "ag_replica_testag_server1_sync_health_healthy": 1,
259 "ag_replica_testag_server2_role_primary": 0,
260 "ag_replica_testag_server2_role_secondary": 1,
261 "ag_replica_testag_server2_connected": 1,
262 },
263 checkCollector: func(t *testing.T, c *Collector) {
264 assert.True(t, c.seenAGReplicas["TestAG_SERVER1"])
265 assert.True(t, c.seenAGReplicas["TestAG_SERVER2"])
266 },
267 },
268 "ag database replicas: 2016+ with secondary lag": {
269 prepareMock: func(mock sqlmock.Sqlmock) {
270 mock.ExpectQuery(queryAGDatabaseReplicas16).WillReturnRows(
271 sqlmock.NewRows([]string{
272 "ag_name", "replica_server_name", "database_name",
273 "synchronization_state", "is_suspended",
274 "log_send_queue_size", "log_send_rate",
275 "redo_queue_size", "redo_rate", "filestream_send_rate",
276 "secondary_lag_seconds",
277 }).
278 AddRow("TestAG", "SERVER1", "MyDB",
279 int64(2), int64(0),
280 int64(1024), int64(2048),
281 int64(512), int64(4096), int64(0),
282 int64(5)),
283 )
284 },
285 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGDatabaseReplicas(mx) },
286 wantMetrics: map[string]int64{
287 "ag_db_testag_server1_mydb_sync_state_synchronized": 1,
288 "ag_db_testag_server1_mydb_sync_state_synchronizing": 0,
289 "ag_db_testag_server1_mydb_log_send_queue_size": 1024,
290 "ag_db_testag_server1_mydb_log_send_rate": 2048,
291 "ag_db_testag_server1_mydb_redo_queue_size": 512,
292 "ag_db_testag_server1_mydb_redo_rate": 4096,
293 "ag_db_testag_server1_mydb_suspended": 0,
294 "ag_db_testag_server1_mydb_not_suspended": 1,
295 "ag_db_testag_server1_mydb_secondary_lag_seconds": 5,
296 },
297 checkCollector: func(t *testing.T, c *Collector) {
298 assert.True(t, c.seenAGDatabaseReplicas["TestAG_SERVER1_MyDB"])
299 },
300 },
301 "ag database replicas: pre-2016 no secondary lag": {
302 majorVersion: 12,
303 prepareMock: func(mock sqlmock.Sqlmock) {
304 mock.ExpectQuery(queryAGDatabaseReplicasPre16).WillReturnRows(
305 sqlmock.NewRows([]string{
306 "ag_name", "replica_server_name", "database_name",
307 "synchronization_state", "is_suspended",
308 "log_send_queue_size", "log_send_rate",
309 "redo_queue_size", "redo_rate", "filestream_send_rate",
310 "secondary_lag_seconds",
311 }).
312 AddRow("TestAG", "SERVER1", "MyDB",
313 int64(2), int64(0),
314 int64(0), int64(0),
315 int64(0), int64(0), int64(0),
316 int64(-1)),
317 )
318 },
319 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGDatabaseReplicas(mx) },
320 notWantMetrics: []string{"ag_db_testag_server1_mydb_secondary_lag_seconds"},
321 },
322 "ag cluster: quorum normal": {
323 prepareMock: func(mock sqlmock.Sqlmock) {
324 mock.ExpectQuery(queryAGCluster).WillReturnRows(
325 sqlmock.NewRows([]string{"quorum_state"}).AddRow(int64(1)),
326 )
327 },
328 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGCluster(mx) },
329 wantMetrics: map[string]int64{
330 "ag_cluster_quorum_state_unknown": 0,
331 "ag_cluster_quorum_state_normal": 1,
332 "ag_cluster_quorum_state_forced": 0,
333 },
334 checkCollector: func(t *testing.T, c *Collector) {
335 assert.True(t, c.agClusterChartAdded)
336 },
337 },
338 "ag cluster: query error": {
339 prepareMock: func(mock sqlmock.Sqlmock) {
340 mock.ExpectQuery(queryAGCluster).WillReturnError(fmt.Errorf("WSFC not configured"))
341 },
342 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGCluster(mx) },
343 wantErr: true,
344 },
345 "ag cluster members: two nodes": {
346 prepareMock: func(mock sqlmock.Sqlmock) {
347 mock.ExpectQuery(queryAGClusterMembers).WillReturnRows(
348 sqlmock.NewRows([]string{"member_name", "member_state", "number_of_quorum_votes"}).
349 AddRow("NODE1", int64(1), int64(1)).
350 AddRow("NODE2", int64(0), int64(1)),
351 )
352 },
353 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGClusterMembers(mx) },
354 wantMetrics: map[string]int64{
355 "ag_cluster_member_node1_up": 1,
356 "ag_cluster_member_node1_down": 0,
357 "ag_cluster_member_node1_quorum_votes": 1,
358 "ag_cluster_member_node2_up": 0,
359 "ag_cluster_member_node2_down": 1,
360 "ag_cluster_member_node2_quorum_votes": 1,
361 },
362 checkCollector: func(t *testing.T, c *Collector) {
363 assert.True(t, c.seenAGClusterMembers["NODE1"])
364 assert.True(t, c.seenAGClusterMembers["NODE2"])
365 },
366 },
367 "ag failover readiness: ready and joined": {
368 prepareMock: func(mock sqlmock.Sqlmock) {
369 mock.ExpectQuery(queryAGFailoverReadiness).WillReturnRows(
370 sqlmock.NewRows([]string{"ag_name", "replica_server_name", "database_name", "is_failover_ready", "is_database_joined"}).
371 AddRow("TestAG", "SERVER1", "MyDB", int64(1), int64(1)),
372 )
373 },
374 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGFailoverReadiness(mx) },
375 wantMetrics: map[string]int64{
376 "ag_db_testag_server1_mydb_failover_ready": 1,
377 "ag_db_testag_server1_mydb_failover_not_ready": 0,
378 "ag_db_testag_server1_mydb_joined": 1,
379 "ag_db_testag_server1_mydb_not_joined": 0,
380 },
381 },
382 "ag auto page repair: success": {
383 prepareMock: func(mock sqlmock.Sqlmock) {
384 mock.ExpectQuery(queryAGAutoPageRepair).WillReturnRows(
385 sqlmock.NewRows([]string{"database_name", "successful_repairs", "failed_repairs"}).
386 AddRow("MyDB", int64(3), int64(1)),
387 )
388 },
389 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGAutoPageRepair(mx) },
390 wantMetrics: map[string]int64{
391 "ag_page_repair_mydb_successful": 3,
392 "ag_page_repair_mydb_failed": 1,
393 },
394 checkCollector: func(t *testing.T, c *Collector) {
395 assert.True(t, c.seenAGPageRepairDBs["MyDB"])
396 },
397 },
398 "ag auto page repair: empty db name skipped": {
399 prepareMock: func(mock sqlmock.Sqlmock) {
400 mock.ExpectQuery(queryAGAutoPageRepair).WillReturnRows(
401 sqlmock.NewRows([]string{"database_name", "successful_repairs", "failed_repairs"}).
402 AddRow("", int64(1), int64(0)),
403 )
404 },
405 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGAutoPageRepair(mx) },
406 wantMetrics: map[string]int64{},
407 },
408 "ag threads: success": {
409 prepareMock: func(mock sqlmock.Sqlmock) {
410 mock.ExpectQuery(queryAGThreads).WillReturnRows(
411 sqlmock.NewRows([]string{"ag_name", "num_capture_threads", "num_redo_threads", "num_parallel_redo_threads"}).
412 AddRow("TestAG", int64(2), int64(4), int64(8)),
413 )
414 },
415 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGThreads(mx) },
416 wantMetrics: map[string]int64{
417 "ag_testag_capture_threads": 2,
418 "ag_testag_redo_threads": 4,
419 "ag_testag_parallel_redo_threads": 8,
420 },
421 },
422 "ag pipeline: health error stops collection": {
423 prepareMock: func(mock sqlmock.Sqlmock) {
424 mock.ExpectQuery(queryAGHealth).WillReturnError(fmt.Errorf("access denied"))
425 },
426 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
427 wantErr: true,
428 },
429 "ag pipeline: replica states error stops collection": {
430 prepareMock: func(mock sqlmock.Sqlmock) {
431 mock.ExpectQuery(queryAGHealth).WillReturnRows(
432 sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
433 AddRow("TestAG", int64(2), int64(1), int64(-1)),
434 )
435 mock.ExpectQuery(queryAGReplicaStates).WillReturnError(fmt.Errorf("timeout"))
436 },
437 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
438 wantErr: true,
439 },
440 "ag pipeline: cluster error is non-fatal": {
441 majorVersion: 14, // < 15, so AG threads query is skipped
442 prepareMock: func(mock sqlmock.Sqlmock) {
443 mock.ExpectQuery(queryAGHealth).WillReturnRows(
444 sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
445 AddRow("TestAG", int64(2), int64(1), int64(-1)),
446 )
447 mock.ExpectQuery(queryAGReplicaStates).WillReturnRows(
448 sqlmock.NewRows([]string{"ag_name", "replica_server_name", "availability_mode", "failover_mode", "role", "connected_state", "synchronization_health"}).
449 AddRow("TestAG", "SERVER1", "synchronous_commit", "automatic", int64(1), int64(1), int64(2)),
450 )
451 // majorVersion=14 >= 13, so queryAGDatabaseReplicas16 is selected
452 mock.ExpectQuery(queryAGDatabaseReplicas16).WillReturnRows(
453 sqlmock.NewRows([]string{
454 "ag_name", "replica_server_name", "database_name",
455 "synchronization_state", "is_suspended",
456 "log_send_queue_size", "log_send_rate",
457 "redo_queue_size", "redo_rate", "filestream_send_rate",
458 "secondary_lag_seconds",
459 }),
460 )
461 mock.ExpectQuery(queryAGCluster).WillReturnError(fmt.Errorf("WSFC not available"))
462 mock.ExpectQuery(queryAGClusterMembers).WillReturnError(fmt.Errorf("not available"))
463 mock.ExpectQuery(queryAGFailoverReadiness).WillReturnError(fmt.Errorf("not available"))
464 mock.ExpectQuery(queryAGAutoPageRepair).WillReturnError(fmt.Errorf("not available"))
465 },
466 collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
467 },
468 }
469
470 for name, tc := range tests {
471 t.Run(name, func(t *testing.T) {
472 db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
473 require.NoError(t, err)
474 defer func() { _ = db.Close() }()
475
476 c := New()
477 c.db = db
478 c.majorVersion = 16
479 if tc.majorVersion != 0 {
480 c.majorVersion = tc.majorVersion
481 }
482
483 tc.prepareMock(mock)
484
485 mx := make(map[string]int64)
486 err = tc.collectFn(c, mx)
487
488 if tc.wantErr {
489 assert.Error(t, err)
490 } else {
491 assert.NoError(t, err)
492 }
493 for key, want := range tc.wantMetrics {
494 assert.Equalf(t, want, mx[key], "metric %s", key)
495 }
496 for _, key := range tc.notWantMetrics {
497 _, ok := mx[key]
498 assert.Falsef(t, ok, "metric %s should not be present", key)
499 }
500 if tc.checkCollector != nil {
501 tc.checkCollector(t, c)
502 }
503 assert.NoError(t, mock.ExpectationsWereMet())
504 })
505 }
506 }