From 279abb1f645ff18c0a7a371614da8de166ad16df Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 01:38:52 +0000 Subject: [PATCH 1/2] feat: add ProxySQL provider with config generation and lifecycle scripts --- providers/proxysql/config.go | 75 +++++++++++++ providers/proxysql/config_test.go | 50 +++++++++ providers/proxysql/proxysql.go | 167 ++++++++++++++++++++++++++++ providers/proxysql/proxysql_test.go | 57 ++++++++++ 4 files changed, 349 insertions(+) create mode 100644 providers/proxysql/config.go create mode 100644 providers/proxysql/config_test.go create mode 100644 providers/proxysql/proxysql.go create mode 100644 providers/proxysql/proxysql_test.go diff --git a/providers/proxysql/config.go b/providers/proxysql/config.go new file mode 100644 index 0000000..d6247a9 --- /dev/null +++ b/providers/proxysql/config.go @@ -0,0 +1,75 @@ +package proxysql + +import ( + "fmt" + "strings" +) + +type BackendServer struct { + Host string + Port int + Hostgroup int + MaxConns int +} + +type ProxySQLConfig struct { + AdminHost string + AdminPort int + AdminUser string + AdminPassword string + MySQLPort int + DataDir string + Backends []BackendServer + MonitorUser string + MonitorPass string +} + +func GenerateConfig(cfg ProxySQLConfig) string { + var b strings.Builder + + b.WriteString(fmt.Sprintf("datadir=\"%s\"\n\n", cfg.DataDir)) + + b.WriteString("admin_variables=\n{\n") + b.WriteString(fmt.Sprintf(" admin_credentials=\"%s:%s\"\n", cfg.AdminUser, cfg.AdminPassword)) + b.WriteString(fmt.Sprintf(" mysql_ifaces=\"%s:%d\"\n", cfg.AdminHost, cfg.AdminPort)) + b.WriteString("}\n\n") + + b.WriteString("mysql_variables=\n{\n") + b.WriteString(fmt.Sprintf(" interfaces=\"%s:%d\"\n", cfg.AdminHost, cfg.MySQLPort)) + b.WriteString(fmt.Sprintf(" monitor_username=\"%s\"\n", cfg.MonitorUser)) + b.WriteString(fmt.Sprintf(" monitor_password=\"%s\"\n", cfg.MonitorPass)) + b.WriteString(" monitor_connect_interval=2000\n") + b.WriteString(" monitor_ping_interval=2000\n") + b.WriteString("}\n\n") + + if len(cfg.Backends) > 0 { + b.WriteString("mysql_servers=\n(\n") + for i, srv := range cfg.Backends { + b.WriteString(" {\n") + b.WriteString(fmt.Sprintf(" address=\"%s\"\n", srv.Host)) + b.WriteString(fmt.Sprintf(" port=%d\n", srv.Port)) + b.WriteString(fmt.Sprintf(" hostgroup=%d\n", srv.Hostgroup)) + maxConns := srv.MaxConns + if maxConns == 0 { + maxConns = 200 + } + b.WriteString(fmt.Sprintf(" max_connections=%d\n", maxConns)) + b.WriteString(" }") + if i < len(cfg.Backends)-1 { + b.WriteString(",") + } + b.WriteString("\n") + } + b.WriteString(")\n\n") + } + + b.WriteString("mysql_users=\n(\n") + b.WriteString(" {\n") + b.WriteString(fmt.Sprintf(" username=\"%s\"\n", cfg.MonitorUser)) + b.WriteString(fmt.Sprintf(" password=\"%s\"\n", cfg.MonitorPass)) + b.WriteString(" default_hostgroup=0\n") + b.WriteString(" }\n") + b.WriteString(")\n") + + return b.String() +} diff --git a/providers/proxysql/config_test.go b/providers/proxysql/config_test.go new file mode 100644 index 0000000..b38f1d1 --- /dev/null +++ b/providers/proxysql/config_test.go @@ -0,0 +1,50 @@ +package proxysql + +import ( + "strings" + "testing" +) + +func TestGenerateConfigBasic(t *testing.T) { + cfg := ProxySQLConfig{ + AdminHost: "127.0.0.1", AdminPort: 6032, + AdminUser: "admin", AdminPassword: "admin", + MySQLPort: 6033, DataDir: "/tmp/test", + MonitorUser: "msandbox", MonitorPass: "msandbox", + } + result := GenerateConfig(cfg) + checks := []string{ + `admin_credentials="admin:admin"`, + `interfaces="127.0.0.1:6033"`, + `monitor_username="msandbox"`, + `mysql_ifaces="127.0.0.1:6032"`, + } + for _, check := range checks { + if !strings.Contains(result, check) { + t.Errorf("missing %q in config output", check) + } + } +} + +func TestGenerateConfigWithBackends(t *testing.T) { + cfg := ProxySQLConfig{ + AdminHost: "127.0.0.1", AdminPort: 6032, + AdminUser: "admin", AdminPassword: "admin", + MySQLPort: 6033, DataDir: "/tmp/test", + MonitorUser: "msandbox", MonitorPass: "msandbox", + Backends: []BackendServer{ + {Host: "127.0.0.1", Port: 3306, Hostgroup: 0, MaxConns: 100}, + {Host: "127.0.0.1", Port: 3307, Hostgroup: 1, MaxConns: 100}, + }, + } + result := GenerateConfig(cfg) + if !strings.Contains(result, "mysql_servers=") { + t.Error("missing mysql_servers section") + } + if !strings.Contains(result, "port=3306") { + t.Error("missing first backend") + } + if !strings.Contains(result, "hostgroup=1") { + t.Error("missing reader hostgroup") + } +} diff --git a/providers/proxysql/proxysql.go b/providers/proxysql/proxysql.go new file mode 100644 index 0000000..a50b206 --- /dev/null +++ b/providers/proxysql/proxysql.go @@ -0,0 +1,167 @@ +package proxysql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/ProxySQL/dbdeployer/providers" +) + +const ProviderName = "proxysql" + +type ProxySQLProvider struct{} + +func NewProxySQLProvider() *ProxySQLProvider { return &ProxySQLProvider{} } + +func (p *ProxySQLProvider) Name() string { return ProviderName } + +func (p *ProxySQLProvider) ValidateVersion(version string) error { + parts := strings.Split(version, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid ProxySQL version format: %q (expected X.Y or X.Y.Z)", version) + } + return nil +} + +func (p *ProxySQLProvider) DefaultPorts() providers.PortRange { + return providers.PortRange{BasePort: 6032, PortsPerInstance: 2} +} + +func (p *ProxySQLProvider) FindBinary(version string) (string, error) { + path, err := exec.LookPath("proxysql") + if err != nil { + return "", fmt.Errorf("proxysql binary not found in PATH: %w", err) + } + return path, nil +} + +func (p *ProxySQLProvider) CreateSandbox(config providers.SandboxConfig) (*providers.SandboxInfo, error) { + binaryPath, err := p.FindBinary(config.Version) + if err != nil { + return nil, err + } + + dataDir := filepath.Join(config.Dir, "data") + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, fmt.Errorf("creating data directory: %w", err) + } + + adminPort := config.AdminPort + if adminPort == 0 { + adminPort = config.Port + } + mysqlPort := adminPort + 1 + + adminUser := config.DbUser + if adminUser == "" { + adminUser = "admin" + } + adminPassword := config.DbPassword + if adminPassword == "" { + adminPassword = "admin" + } + + monitorUser := config.Options["monitor_user"] + if monitorUser == "" { + monitorUser = "msandbox" + } + monitorPass := config.Options["monitor_password"] + if monitorPass == "" { + monitorPass = "msandbox" + } + + host := config.Host + if host == "" { + host = "127.0.0.1" + } + + proxyCfg := ProxySQLConfig{ + AdminHost: host, + AdminPort: adminPort, + AdminUser: adminUser, + AdminPassword: adminPassword, + MySQLPort: mysqlPort, + DataDir: dataDir, + MonitorUser: monitorUser, + MonitorPass: monitorPass, + Backends: parseBackends(config.Options), + } + + cfgContent := GenerateConfig(proxyCfg) + cfgPath := filepath.Join(config.Dir, "proxysql.cnf") + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + return nil, fmt.Errorf("writing config: %w", err) + } + + // Write lifecycle scripts + scripts := map[string]string{ + "start": fmt.Sprintf("#!/bin/bash\ncd %s\n%s --initial -c %s -D %s &\nSBPID=$!\necho $SBPID > %s/proxysql.pid\nsleep 2\nif kill -0 $SBPID 2>/dev/null; then\n echo 'ProxySQL started (pid '$SBPID')'\nelse\n echo 'ProxySQL failed to start'\n exit 1\nfi\n", + config.Dir, binaryPath, cfgPath, dataDir, config.Dir), + "stop": fmt.Sprintf("#!/bin/bash\nPIDFILE=%s/proxysql.pid\nif [ -f $PIDFILE ]; then\n PID=$(cat $PIDFILE)\n kill $PID 2>/dev/null\n sleep 1\n kill -0 $PID 2>/dev/null && kill -9 $PID 2>/dev/null\n rm -f $PIDFILE\n echo 'ProxySQL stopped'\nelse\n echo 'ProxySQL not running (no pid file)'\nfi\n", + config.Dir), + "status": fmt.Sprintf("#!/bin/bash\nPIDFILE=%s/proxysql.pid\nif [ -f $PIDFILE ] && kill -0 $(cat $PIDFILE) 2>/dev/null; then\n echo 'ProxySQL running (pid '$(cat $PIDFILE)')'\nelse\n echo 'ProxySQL not running'\n exit 1\nfi\n", + config.Dir), + "use": fmt.Sprintf("#!/bin/bash\nmysql -h %s -P %d -u %s -p%s --prompt 'ProxySQL Admin> ' \"$@\"\n", + host, adminPort, adminUser, adminPassword), + "use_proxy": fmt.Sprintf("#!/bin/bash\nmysql -h %s -P %d -u %s -p%s --prompt 'ProxySQL> ' \"$@\"\n", + host, mysqlPort, monitorUser, monitorPass), + } + + for name, content := range scripts { + scriptPath := filepath.Join(config.Dir, name) + if err := os.WriteFile(scriptPath, []byte(content), 0755); err != nil { + return nil, fmt.Errorf("writing script %s: %w", name, err) + } + } + + return &providers.SandboxInfo{ + Dir: config.Dir, + Port: adminPort, + Status: "stopped", + }, nil +} + +func (p *ProxySQLProvider) StartSandbox(dir string) error { + cmd := exec.Command("bash", filepath.Join(dir, "start")) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("start failed: %s: %w", string(output), err) + } + return nil +} + +func (p *ProxySQLProvider) StopSandbox(dir string) error { + cmd := exec.Command("bash", filepath.Join(dir, "stop")) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("stop failed: %s: %w", string(output), err) + } + return nil +} + +func Register(reg *providers.Registry) error { + return reg.Register(NewProxySQLProvider()) +} + +func parseBackends(options map[string]string) []BackendServer { + raw, ok := options["backends"] + if !ok || raw == "" { + return nil + } + var backends []BackendServer + for _, entry := range strings.Split(raw, ",") { + parts := strings.Split(entry, ":") + if len(parts) >= 3 { + port, _ := strconv.Atoi(parts[1]) + hg, _ := strconv.Atoi(parts[2]) + backends = append(backends, BackendServer{ + Host: parts[0], Port: port, Hostgroup: hg, MaxConns: 200, + }) + } + } + return backends +} diff --git a/providers/proxysql/proxysql_test.go b/providers/proxysql/proxysql_test.go new file mode 100644 index 0000000..36a1e15 --- /dev/null +++ b/providers/proxysql/proxysql_test.go @@ -0,0 +1,57 @@ +package proxysql + +import ( + "testing" + + "github.com/ProxySQL/dbdeployer/providers" +) + +func TestProxySQLProviderName(t *testing.T) { + p := NewProxySQLProvider() + if p.Name() != "proxysql" { + t.Errorf("expected 'proxysql', got %q", p.Name()) + } +} + +func TestProxySQLProviderValidateVersion(t *testing.T) { + p := NewProxySQLProvider() + tests := []struct { + version string + wantErr bool + }{ + {"2.7.0", false}, + {"3.0.0", false}, + {"invalid", true}, + } + for _, tt := range tests { + err := p.ValidateVersion(tt.version) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateVersion(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr) + } + } +} + +func TestProxySQLProviderRegister(t *testing.T) { + reg := providers.NewRegistry() + if err := Register(reg); err != nil { + t.Fatalf("Register failed: %v", err) + } + p, err := reg.Get("proxysql") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if p.Name() != "proxysql" { + t.Errorf("expected 'proxysql', got %q", p.Name()) + } +} + +func TestProxySQLFindBinary(t *testing.T) { + p := NewProxySQLProvider() + path, err := p.FindBinary("2.7.0") + if err != nil { + t.Skipf("proxysql not installed, skipping: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } +} From 2643d6a74ab66aa3f20e3e2974c8c469dafca21a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 01:39:27 +0000 Subject: [PATCH 2/2] feat: register ProxySQL provider at startup --- cmd/root.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index cd87fb4..6fee26b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,7 @@ import ( "github.com/ProxySQL/dbdeployer/globals" "github.com/ProxySQL/dbdeployer/providers" mysqlprovider "github.com/ProxySQL/dbdeployer/providers/mysql" + proxysqlprovider "github.com/ProxySQL/dbdeployer/providers/proxysql" "github.com/ProxySQL/dbdeployer/sandbox" ) @@ -148,6 +149,7 @@ func init() { if err := mysqlprovider.Register(providers.DefaultRegistry); err != nil { panic(fmt.Sprintf("failed to register MySQL provider: %v", err)) } + _ = proxysqlprovider.Register(providers.DefaultRegistry) cobra.OnInitialize(checkDefaultsFile) rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.PersistentFlags().StringVar(&defaults.CustomConfigurationFile, globals.ConfigLabel, defaults.ConfigurationFile, "configuration file")