diff --git a/docs/content/metrics/domains/tls/_index.md b/docs/content/metrics/domains/tls/_index.md index 7c35e2fc..d9259fe5 100644 --- a/docs/content/metrics/domains/tls/_index.md +++ b/docs/content/metrics/domains/tls/_index.md @@ -20,12 +20,12 @@ This domain reports a single derived metric, `enabled`, that should be monitored |**Metric Type**|bool| |**Value Units**|| -True (1) if `have_ssl = YES`, else false (0). +True (1) if the main MySQL connection interface supports encrypted connections, else false (0). Metrics sinks that don't support bool report this metric as a gauge. {{< hint type=note >}} -`have_ssl` is deprecated as of MySQL 8.0.26. -This domain does not currently support the [`tls_channel_status` table](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-tls-channel-status-table.html) but there is an [open issue](https://github.com/cashapp/blip/issues/133) to fix this. +On MySQL 8.0.21 and newer, Blip reads the `mysql_main` channel's `Enabled` property from the [`tls_channel_status` table](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-tls-channel-status-table.html). +For older MySQL versions and compatible distributions that do not have this table, or when the table cannot be read, Blip falls back to `have_ssl` when that variable is available. {{< /hint >}} ## Options @@ -46,7 +46,8 @@ None. ## MySQL Config -None. +On servers without the legacy `have_ssl` variable, including MySQL 8.4 and newer, the Blip database user needs `SELECT` access to `performance_schema.tls_channel_status`. +Earlier versions can use the legacy `have_ssl` fallback when this table is unavailable or inaccessible. ## Changelog diff --git a/metrics/tls/tls.go b/metrics/tls/tls.go index cb8535c5..ea8e60da 100644 --- a/metrics/tls/tls.go +++ b/metrics/tls/tls.go @@ -5,29 +5,42 @@ package tls import ( "context" "database/sql" + "errors" "fmt" + myerr "github.com/go-mysql/errors" + "github.com/cashapp/blip/v2" "github.com/cashapp/blip/v2/sqlutil" ) const ( DOMAIN = "tls" + + tlsChannelStatusQuery = "SELECT VALUE FROM performance_schema.tls_channel_status WHERE CHANNEL = ? AND PROPERTY = ?" + haveSSLQuery = "SELECT @@have_ssl" ) -// have_ssl is deprecated as of MySQL 8.0.26, so: -// @todo https://dev.mysql.com/doc/refman/8.0/en/performance-schema-tls-channel-status-table.html +type rowScanner interface { + Scan(dest ...interface{}) error +} + +type queryRowFunc func(context.Context, string, ...interface{}) rowScanner // TLS collects metrics for the tls domain. type TLS struct { - db *sql.DB + queryRow queryRowFunc + query string + queryArgs []interface{} } var _ blip.Collector = &TLS{} func NewTLS(db *sql.DB) *TLS { return &TLS{ - db: db, + queryRow: func(ctx context.Context, query string, args ...interface{}) rowScanner { + return db.QueryRowContext(ctx, query, args...) + }, } } @@ -44,7 +57,7 @@ func (c *TLS) Help() blip.CollectorHelp { { Name: "enabled", Type: blip.BOOL, - Desc: "True (1) if have_ssl = YES, else false (0)", + Desc: "True (1) if the main MySQL connection interface supports encrypted connections, else false (0)", }, }, } @@ -54,12 +67,14 @@ func (c *TLS) Prepare(ctx context.Context, plan blip.Plan) (func(), error) { // This domain only collects 1 metric (and there are no options), // so we don't have to prepare anything per-level, just check that // the only metric is specified correctly. + configured := false LEVEL: for _, level := range plan.Levels { dom, ok := level.Collect[DOMAIN] if !ok { continue LEVEL // not collected at this level } + configured = true if len(dom.Metrics) == 0 { return nil, fmt.Errorf("metric 'enabled' not specified; metrics to collect must be listed under 'metrics:' for each domain") } @@ -70,16 +85,64 @@ LEVEL: return nil, fmt.Errorf("invalid metric: %s; this domain collects only 1 metric: enabled", dom.Metrics[0]) } } + + if !configured { + return nil, nil + } + + // MySQL 8.0.21 added tls_channel_status, and MySQL 8.4 removed + // @@have_ssl. Probe the replacement table instead of relying on a version + // string because Blip supports multiple MySQL-compatible distributions. + var enabled string + err := c.queryRow(ctx, tlsChannelStatusQuery, "mysql_main", "Enabled").Scan(&enabled) + if err == nil { + c.query = tlsChannelStatusQuery + c.queryArgs = []interface{}{"mysql_main", "Enabled"} + return nil, nil + } + + // A disabled Performance Schema returns no row, and older MySQL versions + // and compatible distributions do not have the table. + // Also preserve the old privilege behavior on MySQL 8.0: @@have_ssl does + // not require SELECT on Performance Schema, so it remains a valid fallback + // when the monitoring user cannot read tls_channel_status. + tlsChannelStatusErr := err + if !errors.Is(err, sql.ErrNoRows) { + switch myerr.MySQLErrorCode(err) { + case 1142, 1146: // SELECT denied, or table does not exist + // Try the legacy source below. + default: + return nil, fmt.Errorf("cannot read TLS status from performance_schema.tls_channel_status: %w", err) + } + } + + err = c.queryRow(ctx, haveSSLQuery).Scan(&enabled) + if err != nil { + if myerr.MySQLErrorCode(tlsChannelStatusErr) == 1142 { + return nil, fmt.Errorf("cannot read TLS status: grant SELECT on performance_schema.tls_channel_status (%v); legacy @@have_ssl is unavailable (%v)", tlsChannelStatusErr, err) + } + return nil, fmt.Errorf("cannot read legacy TLS status from @@have_ssl: %w", err) + } + c.query = haveSSLQuery + c.queryArgs = nil + return nil, nil } func (c *TLS) Collect(ctx context.Context, levelName string) ([]blip.MetricValue, error) { - var haveSSL string - err := c.db.QueryRowContext(ctx, "SELECT @@have_ssl").Scan(&haveSSL) + if c.query == "" { + return nil, fmt.Errorf("tls.enabled failed: collector is not prepared") + } + + var tlsEnabled string + err := c.queryRow(ctx, c.query, c.queryArgs...).Scan(&tlsEnabled) if err != nil { - return nil, fmt.Errorf("tls.enabled failed: %s", err) + return nil, fmt.Errorf("tls.enabled failed: %w", err) + } + enabled, ok := sqlutil.Float64(tlsEnabled) + if !ok { + return nil, fmt.Errorf("tls.enabled failed: cannot convert TLS status %q to a boolean", tlsEnabled) } - enabled, _ := sqlutil.Float64(haveSSL) // MySQL string value -> 1 or 0 metrics := []blip.MetricValue{ { Name: "enabled", diff --git a/metrics/tls/tls_test.go b/metrics/tls/tls_test.go new file mode 100644 index 00000000..2ed1adbf --- /dev/null +++ b/metrics/tls/tls_test.go @@ -0,0 +1,272 @@ +// Copyright 2024 Block, Inc. + +package tls + +import ( + "context" + "database/sql" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" + + "github.com/cashapp/blip/v2" + "github.com/cashapp/blip/v2/test" +) + +type queryResult struct { + query string + args []interface{} + value string + err error +} + +type fakeQueryRows struct { + t *testing.T + results []queryResult + n int +} + +func (f *fakeQueryRows) queryRow(ctx context.Context, query string, args ...interface{}) rowScanner { + f.t.Helper() + if f.n >= len(f.results) { + f.t.Fatalf("unexpected query %q with args %v", query, args) + } + + result := f.results[f.n] + f.n++ + if query != result.query { + f.t.Fatalf("query %d = %q, expected %q", f.n, query, result.query) + } + if !reflect.DeepEqual(args, result.args) { + f.t.Fatalf("query %d args = %v, expected %v", f.n, args, result.args) + } + return fakeRow{value: result.value, err: result.err} +} + +func (f *fakeQueryRows) verify(t *testing.T) { + t.Helper() + if f.n != len(f.results) { + t.Fatalf("executed %d queries, expected %d", f.n, len(f.results)) + } +} + +type fakeRow struct { + value string + err error +} + +func (r fakeRow) Scan(dest ...interface{}) error { + if r.err != nil { + return r.err + } + if len(dest) != 1 { + return fmt.Errorf("scan destinations = %d, expected 1", len(dest)) + } + value, ok := dest[0].(*string) + if !ok { + return fmt.Errorf("scan destination is %T, expected *string", dest[0]) + } + *value = r.value + return nil +} + +func tlsPlan(metrics ...string) blip.Plan { + return blip.Plan{ + Levels: map[string]blip.Level{ + "kpi": { + Name: "kpi", + Collect: map[string]blip.Domain{ + DOMAIN: { + Name: DOMAIN, + Metrics: metrics, + }, + }, + }, + }, + } +} + +func TestCollectFromTLSChannelStatus(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + {query: tlsChannelStatusQuery, args: []interface{}{"mysql_main", "Enabled"}, value: "Yes"}, + {query: tlsChannelStatusQuery, args: []interface{}{"mysql_main", "Enabled"}, value: "No"}, + }, + } + c := &TLS{queryRow: fake.queryRow} + + if _, err := c.Prepare(context.Background(), tlsPlan("enabled")); err != nil { + t.Fatal(err) + } + metrics, err := c.Collect(context.Background(), "kpi") + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Name != "enabled" || metrics[0].Value != 0 { + t.Fatalf("metrics = %+v, expected tls.enabled = 0", metrics) + } + fake.verify(t) +} + +func TestCollectFallsBackWhenTLSChannelStatusDoesNotExist(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + { + query: tlsChannelStatusQuery, + args: []interface{}{"mysql_main", "Enabled"}, + err: &mysql.MySQLError{Number: 1146, Message: "table does not exist"}, + }, + {query: haveSSLQuery, value: "YES"}, + {query: haveSSLQuery, value: "DISABLED"}, + }, + } + c := &TLS{queryRow: fake.queryRow} + + if _, err := c.Prepare(context.Background(), tlsPlan("enabled")); err != nil { + t.Fatal(err) + } + metrics, err := c.Collect(context.Background(), "kpi") + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Name != "enabled" || metrics[0].Value != 0 { + t.Fatalf("metrics = %+v, expected tls.enabled = 0", metrics) + } + fake.verify(t) +} + +func TestCollectFallsBackWhenTLSChannelStatusRowIsAbsent(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + { + query: tlsChannelStatusQuery, + args: []interface{}{"mysql_main", "Enabled"}, + err: sql.ErrNoRows, + }, + {query: haveSSLQuery, value: "YES"}, + {query: haveSSLQuery, value: "YES"}, + }, + } + c := &TLS{queryRow: fake.queryRow} + + if _, err := c.Prepare(context.Background(), tlsPlan("enabled")); err != nil { + t.Fatal(err) + } + metrics, err := c.Collect(context.Background(), "kpi") + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Name != "enabled" || metrics[0].Value != 1 { + t.Fatalf("metrics = %+v, expected tls.enabled = 1", metrics) + } + fake.verify(t) +} + +func TestCollectFallsBackWhenTLSChannelStatusAccessIsDenied(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + { + query: tlsChannelStatusQuery, + args: []interface{}{"mysql_main", "Enabled"}, + err: &mysql.MySQLError{Number: 1142, Message: "SELECT command denied"}, + }, + {query: haveSSLQuery, value: "YES"}, + {query: haveSSLQuery, value: "YES"}, + }, + } + c := &TLS{queryRow: fake.queryRow} + + if _, err := c.Prepare(context.Background(), tlsPlan("enabled")); err != nil { + t.Fatal(err) + } + metrics, err := c.Collect(context.Background(), "kpi") + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Name != "enabled" || metrics[0].Value != 1 { + t.Fatalf("metrics = %+v, expected tls.enabled = 1", metrics) + } + fake.verify(t) +} + +func TestPrepareReportsRequiredTLSChannelStatusAccess(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + { + query: tlsChannelStatusQuery, + args: []interface{}{"mysql_main", "Enabled"}, + err: &mysql.MySQLError{Number: 1142, Message: "SELECT command denied"}, + }, + { + query: haveSSLQuery, + err: &mysql.MySQLError{Number: 1193, Message: "Unknown system variable 'have_ssl'"}, + }, + }, + } + c := &TLS{queryRow: fake.queryRow} + + _, err := c.Prepare(context.Background(), tlsPlan("enabled")) + if err == nil { + t.Fatal("Prepare error = nil, expected access error") + } + if got := err.Error(); !strings.Contains(got, "grant SELECT on performance_schema.tls_channel_status") { + t.Fatalf("Prepare error = %q, expected SELECT requirement", got) + } + fake.verify(t) +} + +func TestPrepareDoesNotHideTLSChannelStatusErrors(t *testing.T) { + fake := &fakeQueryRows{ + t: t, + results: []queryResult{ + { + query: tlsChannelStatusQuery, + args: []interface{}{"mysql_main", "Enabled"}, + err: errors.New("access denied"), + }, + }, + } + c := &TLS{queryRow: fake.queryRow} + + _, err := c.Prepare(context.Background(), tlsPlan("enabled")) + if err == nil { + t.Fatal("Prepare error = nil, expected access error") + } + fake.verify(t) +} + +func TestCollectMySQL80And84(t *testing.T) { + for _, version := range []string{"mysql80", "mysql84"} { + t.Run(version, func(t *testing.T) { + _, db, err := test.Connection(version) + if err != nil { + if test.Build { + t.Skipf("%s not running", version) + } + t.Fatal(err) + } + defer db.Close() + + c := NewTLS(db) + if _, err := c.Prepare(context.Background(), tlsPlan("enabled")); err != nil { + t.Fatal(err) + } + metrics, err := c.Collect(context.Background(), "kpi") + if err != nil { + t.Fatal(err) + } + if len(metrics) != 1 || metrics[0].Name != "enabled" || metrics[0].Value != 1 { + t.Fatalf("metrics = %+v, expected tls.enabled = 1", metrics) + } + }) + } +} diff --git a/test/mysql.go b/test/mysql.go index 0ef174cf..4b875d36 100644 --- a/test/mysql.go +++ b/test/mysql.go @@ -20,6 +20,7 @@ var DefaultMySQLVersion = "mysql80" // MySQLPort maps to Docker ports in docker/docker-compose.yaml. var MySQLPort = map[string]string{ "mysql80": "33800", + "mysql84": "33840", "mysql57": "33570", "ps57": "33900", }