diff --git a/build/configuration.json b/build/configuration.json index c24a554..1af500d 100644 --- a/build/configuration.json +++ b/build/configuration.json @@ -1,8 +1,6 @@ { "log_level": "INFO", "common_discovery": { - "collection_frequency": { - "seconds": 10800 - } + "collection_frequency": "10800s" } } diff --git a/internal/daemon/mysql/mysql.go b/internal/daemon/mysql/mysql.go index 4820f7a..7dd633b 100644 --- a/internal/daemon/mysql/mysql.go +++ b/internal/daemon/mysql/mysql.go @@ -28,13 +28,13 @@ import ( "github.com/GoogleCloudPlatform/workloadagent/internal/mysqldiscovery" "github.com/GoogleCloudPlatform/workloadagent/internal/mysqlmetrics" "github.com/GoogleCloudPlatform/workloadagent/internal/usagemetrics" - cpb "github.com/GoogleCloudPlatform/workloadagent/protos/configuration" + configpb "github.com/GoogleCloudPlatform/workloadagent/protos/configuration" ) // Service implements the interfaces for MySQL workload agent service. type Service struct { - Config *cpb.Configuration - CloudProps *cpb.CloudProperties + Config *configpb.Configuration + CloudProps *configpb.CloudProperties CommonCh chan commondiscovery.Result processes commondiscovery.Result mySQLProcesses []commondiscovery.ProcessWrapper @@ -129,8 +129,13 @@ func runMetricCollection(ctx context.Context, a any) { log.CtxLogger(ctx).Debugw("MySQL metric collection args", "args", args) ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() + m, err := mysqlmetrics.New(ctx, args.s.Config) + if err != nil { + log.CtxLogger(ctx).Errorf("failed to create MySQL metrics: %v", err) + return + } for { - mysqlmetrics.CollectMetricsOnce(ctx) + m.CollectMetricsOnce(ctx) select { case <-ctx.Done(): log.CtxLogger(ctx).Info("MySQL metric collection cancellation requested") diff --git a/internal/mysqlmetrics/mysqlmetrics.go b/internal/mysqlmetrics/mysqlmetrics.go index 27b7a54..102d02a 100644 --- a/internal/mysqlmetrics/mysqlmetrics.go +++ b/internal/mysqlmetrics/mysqlmetrics.go @@ -19,12 +19,133 @@ package mysqlmetrics import ( "context" + "fmt" + "strconv" + "strings" + "github.com/GoogleCloudPlatform/sapagent/shared/commandlineexecutor" + "github.com/GoogleCloudPlatform/sapagent/shared/gce" "github.com/GoogleCloudPlatform/sapagent/shared/log" + "github.com/GoogleCloudPlatform/workloadagent/internal/secret" + "github.com/GoogleCloudPlatform/workloadagent/internal/usagemetrics" + configpb "github.com/GoogleCloudPlatform/workloadagent/protos/configuration" ) +type gceInterface interface { + GetSecret(ctx context.Context, projectID, secretName string) (string, error) +} + +// MySQLMetrics contains variables and methods to collect metrics for MySQL databases running on the current host. +type MySQLMetrics struct { + Execute commandlineexecutor.Execute + Config *configpb.Configuration + GCEService gceInterface + password secret.String +} + +// initPassword initializes the password for the MySQL database. +// If the password is set in the configuration, it is used directly (not recommended). +// Otherwise, if the secret configuration is set, the secret is fetched from GCE. +// Without either, the password is not set and requests to the MySQL database will fail. +func (m *MySQLMetrics) initPassword(ctx context.Context) error { + pw := "" + if m.Config.GetMysqlConfiguration().GetPassword() != "" { + m.password = secret.String(m.Config.GetMysqlConfiguration().GetPassword()) + return nil + } + secretCfg := m.Config.GetMysqlConfiguration().GetSecret() + if secretCfg.GetSecretName() != "" && secretCfg.GetProjectId() != "" { + var err error + pw, err = m.GCEService.GetSecret(ctx, secretCfg.GetProjectId(), secretCfg.GetSecretName()) + if err != nil { + return fmt.Errorf("failed to get secret: %v", err) + } + } + m.password = secret.String(pw) + return nil +} + +// New creates a new MySQLMetrics object with default values. +func New(ctx context.Context, config *configpb.Configuration) (*MySQLMetrics, error) { + gceService, err := gce.NewGCEClient(ctx) + if err != nil { + usagemetrics.Error(usagemetrics.GCEServiceCreationFailure) + return nil, fmt.Errorf("initializing GCE services: %w", err) + } + m := &MySQLMetrics{ + Execute: commandlineexecutor.ExecuteCommand, + Config: config, + GCEService: gceService, + } + err = m.initPassword(ctx) + if err != nil { + return nil, fmt.Errorf("initializing password: %w", err) + } + return m, nil +} + +func (m *MySQLMetrics) bufferPoolSize(ctx context.Context) (int, error) { + user := m.Config.GetMysqlConfiguration().GetUser() + pw := fmt.Sprintf("-p='%s'", m.password) + cmd := commandlineexecutor.Params{ + Executable: "sudo", + Args: []string{"mysql", "-u", user, pw, "-e", "SELECT @@innodb_buffer_pool_size"}, + } + log.CtxLogger(ctx).Debugw("MySQL metric collection command", "command", cmd) + res := m.Execute(ctx, cmd) + log.CtxLogger(ctx).Debugw("MySQL metric collection result", "result", res) + lines := strings.Split(res.StdOut, "\n") + if len(lines) != 3 { + return 0, fmt.Errorf("found wrong number of lines in buffer pool size: %d", len(lines)) + } + fields := strings.Fields(lines[1]) + if len(fields) != 1 { + return 0, fmt.Errorf("found wrong number of fields in buffer pool size: %d", len(fields)) + } + size, err := strconv.Atoi(fields[0]) + if err != nil { + return 0, fmt.Errorf("failed to convert buffer pool size to integer: %v", err) + } + return size, nil +} + +func (m *MySQLMetrics) totalRAM(ctx context.Context) (int, error) { + cmd := commandlineexecutor.Params{ + Executable: "grep", + Args: []string{"MemTotal", "/proc/meminfo"}, + } + log.CtxLogger(ctx).Debugw("getTotalRAM command", "command", cmd) + res := m.Execute(ctx, cmd) + log.CtxLogger(ctx).Debugw("getTotalRAM result", "result", res) + lines := strings.Split(res.StdOut, "\n") + if len(lines) != 2 { + return 0, fmt.Errorf("found wrong number of lines in total RAM: %d", len(lines)) + } + fields := strings.Fields(lines[0]) + if len(fields) != 3 { + return 0, fmt.Errorf("found wrong number of fields in total RAM: %d", len(fields)) + } + ram, err := strconv.Atoi(fields[1]) + if err != nil { + return 0, fmt.Errorf("failed to convert total RAM to integer: %v", err) + } + units := fields[2] + if strings.ToUpper(units) == "KB" { + ram = ram * 1024 + } + return ram, nil +} + // CollectMetricsOnce collects metrics for MySQL databases running on the host. -func CollectMetricsOnce(ctx context.Context) { - log.CtxLogger(ctx).Info("MySQL metric collection not yet implemented.") - return +func (m *MySQLMetrics) CollectMetricsOnce(ctx context.Context) { + bufferPoolSize, err := m.bufferPoolSize(ctx) + if err != nil { + log.CtxLogger(ctx).Warnf("Failed to get buffer pool size: %v", err) + } + totalRAM, err := m.totalRAM(ctx) + if err != nil { + log.CtxLogger(ctx).Warnf("Failed to get total RAM: %v", err) + } + // TODO: send these metrics to Data Warehouse. + log.CtxLogger(ctx).Debugw("Buffer pool size: %s, total RAM: %s", bufferPoolSize, totalRAM) } diff --git a/internal/mysqlmetrics/mysqlmetrics_test.go b/internal/mysqlmetrics/mysqlmetrics_test.go new file mode 100644 index 0000000..f480c13 --- /dev/null +++ b/internal/mysqlmetrics/mysqlmetrics_test.go @@ -0,0 +1,276 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package mysqlmetrics + +import ( + "context" + "errors" + "testing" + + "github.com/GoogleCloudPlatform/sapagent/shared/commandlineexecutor" + + gcefake "github.com/GoogleCloudPlatform/sapagent/shared/gce/fake" + configpb "github.com/GoogleCloudPlatform/workloadagent/protos/configuration" +) + +func TestInitPassword(t *testing.T) { + tests := []struct { + name string + m MySQLMetrics + want string + wantErr bool + }{ + { + name: "Default", + m: MySQLMetrics{}, + want: "", + wantErr: false, + }, + { + name: "GCEErr", + m: MySQLMetrics{ + GCEService: &gcefake.TestGCE{ + GetSecretResp: []string{""}, + GetSecretErr: []error{errors.New("fake-error")}, + }, + Config: &configpb.Configuration{ + MysqlConfiguration: &configpb.MySQLConfiguration{ + Secret: &configpb.SecretRef{ProjectId: "fake-project-id", SecretName: "fake-secret-name"}, + }, + }, + }, + want: "", + wantErr: true, + }, + { + name: "GCESecret", + m: MySQLMetrics{ + GCEService: &gcefake.TestGCE{ + GetSecretResp: []string{"fake-password"}, + GetSecretErr: []error{nil}, + }, + Config: &configpb.Configuration{ + MysqlConfiguration: &configpb.MySQLConfiguration{ + Secret: &configpb.SecretRef{ProjectId: "fake-project-id", SecretName: "fake-secret-name"}, + }, + }, + }, + want: "fake-password", + wantErr: false, + }, + { + name: "MissingProjectId", + m: MySQLMetrics{ + GCEService: &gcefake.TestGCE{ + GetSecretResp: []string{"fake-password"}, + GetSecretErr: []error{nil}, + }, + Config: &configpb.Configuration{ + MysqlConfiguration: &configpb.MySQLConfiguration{ + Secret: &configpb.SecretRef{SecretName: "fake-secret-name"}, + }, + }, + }, + want: "", + wantErr: false, + }, + { + name: "MissingSecretName", + m: MySQLMetrics{ + GCEService: &gcefake.TestGCE{ + GetSecretResp: []string{"fake-password"}, + GetSecretErr: []error{nil}, + }, + Config: &configpb.Configuration{ + MysqlConfiguration: &configpb.MySQLConfiguration{ + Secret: &configpb.SecretRef{ProjectId: "fake-project-id"}, + }, + }, + }, + want: "", + wantErr: false, + }, + { + name: "UsingPassword", + m: MySQLMetrics{ + Config: &configpb.Configuration{ + MysqlConfiguration: &configpb.MySQLConfiguration{ + Password: "fake-password", + }, + }, + }, + want: "fake-password", + wantErr: false, + }, + } + for _, tc := range tests { + err := tc.m.initPassword(context.Background()) + if (err == nil && tc.wantErr) || (err != nil && !tc.wantErr) { + t.Errorf("initPassword() = %v, wantErr %v", err, tc.wantErr) + } + got := tc.m.password.SecretValue() + if got != tc.want { + t.Errorf("initPassword() = %v, want %v", got, tc.want) + } + } +} + +func TestBufferPoolSize(t *testing.T) { + tests := []struct { + name string + m MySQLMetrics + want int + wantErr bool + }{ + { + name: "HappyPath", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "@@innodb_buffer_pool_size\n134217728\n", + } + }, + }, + want: 134217728, + wantErr: false, + }, + { + name: "TooManyLines", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "@@innodb_buffer_pool_size\n134217728\ntesttext\ntesttext\n", + } + }, + }, + want: 0, + wantErr: true, + }, + { + name: "TooFewLines", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "testtext", + } + }, + }, + want: 0, + wantErr: true, + }, + { + name: "TooManyFields", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "@@innodb_buffer_pool_size\n134217728 testtext testtext\n", + } + }, + }, + want: 0, + wantErr: true, + }, + { + name: "NonInt", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "@@innodb_buffer_pool_size\ntesttext\n", + } + }, + }, + want: 0, + wantErr: true, + }, + } + for _, tc := range tests { + got, err := tc.m.bufferPoolSize(context.Background()) + if (err == nil && tc.wantErr) || (err != nil && !tc.wantErr) { + t.Errorf("bufferPoolSize() = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Errorf("bufferPoolSize() = %v, want %v", got, tc.want) + } + } +} + +func TestTotalRAM(t *testing.T) { + tests := []struct { + name string + m MySQLMetrics + want int + wantErr bool + }{ + { + name: "HappyPath", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "MemTotal: 4025040 kB\n", + } + }, + }, + want: 4025040 * 1024, + wantErr: false, + }, + { + name: "TooManyLines", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "MemTotal: 4025040 kB\ntesttext\ntesttext\n", + } + }, + }, + want: 0, + wantErr: true, + }, + { + name: "TooManyFields", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "MemTotal: 4025040 kB testtext testtext\n", + } + }, + }, + want: 0, + wantErr: true, + }, + { + name: "NonInt", + m: MySQLMetrics{ + Execute: func(context.Context, commandlineexecutor.Params) commandlineexecutor.Result { + return commandlineexecutor.Result{ + StdOut: "MemTotal: testtext kB\n", + } + }, + }, + want: 0, + wantErr: true, + }, + } + for _, tc := range tests { + got, err := tc.m.totalRAM(context.Background()) + if (err == nil && tc.wantErr) || (err != nil && !tc.wantErr) { + t.Errorf("totalRAM() = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Errorf("totalRAM() = %v, want %v", got, tc.want) + } + } +} diff --git a/protos/configuration/configuration.proto b/protos/configuration/configuration.proto index a1dd5be..5bd878d 100644 --- a/protos/configuration/configuration.proto +++ b/protos/configuration/configuration.proto @@ -79,6 +79,9 @@ message OracleMetrics { message MySQLConfiguration { optional bool enabled = 1; + string user = 2; + SecretRef secret = 3; + string password = 4; } message CommonDiscovery {