From 8e0e62327a8852127714a36a8a99494616bdc1da Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:03:04 +0000 Subject: [PATCH 01/16] feat: extend Provider interface with SupportedTopologies and CreateReplica Add ErrNotSupported sentinel error and two new methods to the Provider interface. MySQL and ProxySQL providers return ErrNotSupported from CreateReplica; PostgreSQL (upcoming) will be the first real user. --- providers/mysql/mysql.go | 8 ++++++++ providers/provider.go | 7 +++++++ providers/provider_test.go | 22 ++++++++++++++++++++++ providers/proxysql/proxysql.go | 8 ++++++++ 4 files changed, 45 insertions(+) diff --git a/providers/mysql/mysql.go b/providers/mysql/mysql.go index 0623e93..ab22eac 100644 --- a/providers/mysql/mysql.go +++ b/providers/mysql/mysql.go @@ -48,6 +48,14 @@ func (p *MySQLProvider) StopSandbox(dir string) error { return fmt.Errorf("MySQLProvider.StopSandbox: use sandbox package directly (not yet migrated)") } +func (p *MySQLProvider) SupportedTopologies() []string { + return []string{"single", "multiple", "replication", "group", "fan-in", "all-masters", "ndb", "pxc"} +} + +func (p *MySQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { + return nil, providers.ErrNotSupported +} + func Register(reg *providers.Registry) error { return reg.Register(NewMySQLProvider()) } diff --git a/providers/provider.go b/providers/provider.go index 2b3c5ce..892bd53 100644 --- a/providers/provider.go +++ b/providers/provider.go @@ -1,10 +1,13 @@ package providers import ( + "errors" "fmt" "sort" ) +var ErrNotSupported = errors.New("operation not supported by this provider") + // SandboxConfig holds provider-agnostic sandbox configuration. type SandboxConfig struct { Version string @@ -38,6 +41,10 @@ type Provider interface { StartSandbox(dir string) error // StopSandbox stops a running sandbox. StopSandbox(dir string) error + // SupportedTopologies returns the list of topology names this provider supports. + SupportedTopologies() []string + // CreateReplica creates a replica sandbox joined to an existing primary. + CreateReplica(primary SandboxInfo, config SandboxConfig) (*SandboxInfo, error) } type PortRange struct { diff --git a/providers/provider_test.go b/providers/provider_test.go index fd18264..81f48d2 100644 --- a/providers/provider_test.go +++ b/providers/provider_test.go @@ -11,6 +11,28 @@ func (m *mockProvider) FindBinary(version string) (string, error) { return "/usr func (m *mockProvider) CreateSandbox(config SandboxConfig) (*SandboxInfo, error) { return &SandboxInfo{Dir: "/tmp/mock"}, nil } func (m *mockProvider) StartSandbox(dir string) error { return nil } func (m *mockProvider) StopSandbox(dir string) error { return nil } +func (m *mockProvider) SupportedTopologies() []string { + return []string{"single", "multiple"} +} +func (m *mockProvider) CreateReplica(primary SandboxInfo, config SandboxConfig) (*SandboxInfo, error) { + return nil, ErrNotSupported +} + +func TestErrNotSupported(t *testing.T) { + mock := &mockProvider{name: "test"} + _, err := mock.CreateReplica(SandboxInfo{}, SandboxConfig{}) + if err != ErrNotSupported { + t.Errorf("expected ErrNotSupported, got %v", err) + } +} + +func TestSupportedTopologies(t *testing.T) { + mock := &mockProvider{name: "test"} + topos := mock.SupportedTopologies() + if len(topos) != 2 || topos[0] != "single" { + t.Errorf("unexpected topologies: %v", topos) + } +} func TestRegistryRegisterAndGet(t *testing.T) { reg := NewRegistry() diff --git a/providers/proxysql/proxysql.go b/providers/proxysql/proxysql.go index 21b3ef5..e4374b7 100644 --- a/providers/proxysql/proxysql.go +++ b/providers/proxysql/proxysql.go @@ -146,6 +146,14 @@ func (p *ProxySQLProvider) StopSandbox(dir string) error { return nil } +func (p *ProxySQLProvider) SupportedTopologies() []string { + return []string{"single"} +} + +func (p *ProxySQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { + return nil, providers.ErrNotSupported +} + func Register(reg *providers.Registry) error { return reg.Register(NewProxySQLProvider()) } From aa7a81e7caef1b214b830e5ccd8d2968768ff8b5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:05:27 +0000 Subject: [PATCH 02/16] feat: add PostgreSQL provider core structure Implements the PostgreSQL provider skeleton with Name, ValidateVersion, DefaultPorts, SupportedTopologies, VersionToPort, FindBinary, StartSandbox, StopSandbox, and stub CreateSandbox/CreateReplica. Includes full test coverage; CreateSandbox and CreateReplica will be filled in Tasks 4 and 6 respectively. --- providers/postgresql/postgresql.go | 115 ++++++++++++++++++++++++ providers/postgresql/postgresql_test.go | 99 ++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 providers/postgresql/postgresql.go create mode 100644 providers/postgresql/postgresql_test.go diff --git a/providers/postgresql/postgresql.go b/providers/postgresql/postgresql.go new file mode 100644 index 0000000..52feb77 --- /dev/null +++ b/providers/postgresql/postgresql.go @@ -0,0 +1,115 @@ +package postgresql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/ProxySQL/dbdeployer/providers" +) + +const ProviderName = "postgresql" + +type PostgreSQLProvider struct{} + +func NewPostgreSQLProvider() *PostgreSQLProvider { return &PostgreSQLProvider{} } + +func (p *PostgreSQLProvider) Name() string { return ProviderName } + +func (p *PostgreSQLProvider) ValidateVersion(version string) error { + parts := strings.Split(version, ".") + if len(parts) != 2 { + return fmt.Errorf("invalid PostgreSQL version format: %q (expected major.minor, e.g. 16.13)", version) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return fmt.Errorf("invalid PostgreSQL major version %q: %w", parts[0], err) + } + if major < 12 { + return fmt.Errorf("PostgreSQL major version must be >= 12, got %d", major) + } + if _, err := strconv.Atoi(parts[1]); err != nil { + return fmt.Errorf("invalid PostgreSQL minor version %q: %w", parts[1], err) + } + return nil +} + +func (p *PostgreSQLProvider) DefaultPorts() providers.PortRange { + return providers.PortRange{BasePort: 15000, PortsPerInstance: 1} +} + +func (p *PostgreSQLProvider) SupportedTopologies() []string { + return []string{"single", "multiple", "replication"} +} + +// VersionToPort converts a PostgreSQL version to a port number. +// Formula: BasePort + major*100 + minor +func VersionToPort(version string) (int, error) { + parts := strings.Split(version, ".") + if len(parts) != 2 { + return 0, fmt.Errorf("invalid version format: %q", version) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, err + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, err + } + return 15000 + major*100 + minor, nil +} + +func (p *PostgreSQLProvider) FindBinary(version string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + binPath := filepath.Join(home, "opt", "postgresql", version, "bin", "postgres") + if _, err := os.Stat(binPath); err != nil { + return "", fmt.Errorf("PostgreSQL binary not found at %s: %w", binPath, err) + } + return binPath, nil +} + +func basedirFromVersion(version string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, "opt", "postgresql", version), nil +} + +func (p *PostgreSQLProvider) 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 *PostgreSQLProvider) 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 +} + +// Stubs for CreateSandbox and CreateReplica — implemented in later tasks +func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*providers.SandboxInfo, error) { + return nil, fmt.Errorf("PostgreSQLProvider.CreateSandbox: not yet implemented") +} + +func (p *PostgreSQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { + return nil, fmt.Errorf("PostgreSQLProvider.CreateReplica: not yet implemented") +} + +func Register(reg *providers.Registry) error { + return reg.Register(NewPostgreSQLProvider()) +} diff --git a/providers/postgresql/postgresql_test.go b/providers/postgresql/postgresql_test.go new file mode 100644 index 0000000..affb386 --- /dev/null +++ b/providers/postgresql/postgresql_test.go @@ -0,0 +1,99 @@ +package postgresql + +import ( + "testing" + + "github.com/ProxySQL/dbdeployer/providers" +) + +func TestPostgreSQLProviderName(t *testing.T) { + p := NewPostgreSQLProvider() + if p.Name() != "postgresql" { + t.Errorf("expected 'postgresql', got %q", p.Name()) + } +} + +func TestPostgreSQLProviderValidateVersion(t *testing.T) { + p := NewPostgreSQLProvider() + tests := []struct { + version string + wantErr bool + }{ + {"16.13", false}, + {"17.1", false}, + {"12.0", false}, + {"11.5", true}, // major < 12 + {"16", true}, // missing minor + {"16.13.1", true}, // three parts + {"abc", true}, + {"", 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 TestPostgreSQLProviderDefaultPorts(t *testing.T) { + p := NewPostgreSQLProvider() + ports := p.DefaultPorts() + if ports.BasePort != 15000 { + t.Errorf("expected BasePort 15000, got %d", ports.BasePort) + } + if ports.PortsPerInstance != 1 { + t.Errorf("expected PortsPerInstance 1, got %d", ports.PortsPerInstance) + } +} + +func TestPostgreSQLProviderSupportedTopologies(t *testing.T) { + p := NewPostgreSQLProvider() + topos := p.SupportedTopologies() + expected := map[string]bool{"single": true, "multiple": true, "replication": true} + if len(topos) != len(expected) { + t.Fatalf("expected %d topologies, got %d: %v", len(expected), len(topos), topos) + } + for _, topo := range topos { + if !expected[topo] { + t.Errorf("unexpected topology %q", topo) + } + } +} + +func TestPostgreSQLVersionToPort(t *testing.T) { + tests := []struct { + version string + expected int + }{ + {"16.13", 16613}, + {"16.3", 16603}, + {"17.1", 16701}, + {"17.10", 16710}, + {"12.0", 16200}, + } + for _, tt := range tests { + port, err := VersionToPort(tt.version) + if err != nil { + t.Errorf("VersionToPort(%q) unexpected error: %v", tt.version, err) + continue + } + if port != tt.expected { + t.Errorf("VersionToPort(%q) = %d, want %d", tt.version, port, tt.expected) + } + } +} + +func TestPostgreSQLProviderRegister(t *testing.T) { + reg := providers.NewRegistry() + if err := Register(reg); err != nil { + t.Fatalf("Register failed: %v", err) + } + p, err := reg.Get("postgresql") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if p.Name() != "postgresql" { + t.Errorf("expected 'postgresql', got %q", p.Name()) + } +} From 4be2b3b62bcfb11281405a6eeb52b9538372e50f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:06:33 +0000 Subject: [PATCH 03/16] feat: add PostgreSQL config generation (postgresql.conf, pg_hba.conf) --- providers/postgresql/config.go | 46 +++++++++++++++++++ providers/postgresql/config_test.go | 70 +++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 providers/postgresql/config.go create mode 100644 providers/postgresql/config_test.go diff --git a/providers/postgresql/config.go b/providers/postgresql/config.go new file mode 100644 index 0000000..65a5f5e --- /dev/null +++ b/providers/postgresql/config.go @@ -0,0 +1,46 @@ +package postgresql + +import ( + "fmt" + "strings" +) + +type PostgresqlConfOptions struct { + Port int + ListenAddresses string + UnixSocketDir string + LogDir string + Replication bool +} + +func GeneratePostgresqlConf(opts PostgresqlConfOptions) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("port = %d\n", opts.Port)) + b.WriteString(fmt.Sprintf("listen_addresses = '%s'\n", opts.ListenAddresses)) + b.WriteString(fmt.Sprintf("unix_socket_directories = '%s'\n", opts.UnixSocketDir)) + b.WriteString("logging_collector = on\n") + b.WriteString(fmt.Sprintf("log_directory = '%s'\n", opts.LogDir)) + + if opts.Replication { + b.WriteString("\n# Replication settings\n") + b.WriteString("wal_level = replica\n") + b.WriteString("max_wal_senders = 10\n") + b.WriteString("hot_standby = on\n") + } + + return b.String() +} + +func GeneratePgHbaConf(replication bool) string { + var b strings.Builder + b.WriteString("# TYPE DATABASE USER ADDRESS METHOD\n") + b.WriteString("local all all trust\n") + b.WriteString("host all all 127.0.0.1/32 trust\n") + b.WriteString("host all all ::1/128 trust\n") + + if replication { + b.WriteString("host replication all 127.0.0.1/32 trust\n") + } + + return b.String() +} diff --git a/providers/postgresql/config_test.go b/providers/postgresql/config_test.go new file mode 100644 index 0000000..f328650 --- /dev/null +++ b/providers/postgresql/config_test.go @@ -0,0 +1,70 @@ +package postgresql + +import ( + "strings" + "testing" +) + +func TestGeneratePostgresqlConf(t *testing.T) { + conf := GeneratePostgresqlConf(PostgresqlConfOptions{ + Port: 5433, + ListenAddresses: "127.0.0.1", + UnixSocketDir: "/tmp/sandbox/data", + LogDir: "/tmp/sandbox/data/log", + Replication: false, + }) + if !strings.Contains(conf, "port = 5433") { + t.Error("missing port setting") + } + if !strings.Contains(conf, "listen_addresses = '127.0.0.1'") { + t.Error("missing listen_addresses") + } + if !strings.Contains(conf, "unix_socket_directories = '/tmp/sandbox/data'") { + t.Error("missing unix_socket_directories") + } + if !strings.Contains(conf, "logging_collector = on") { + t.Error("missing logging_collector") + } + if strings.Contains(conf, "wal_level") { + t.Error("should not contain wal_level when replication is false") + } +} + +func TestGeneratePostgresqlConfWithReplication(t *testing.T) { + conf := GeneratePostgresqlConf(PostgresqlConfOptions{ + Port: 5433, + ListenAddresses: "127.0.0.1", + UnixSocketDir: "/tmp/sandbox/data", + LogDir: "/tmp/sandbox/data/log", + Replication: true, + }) + if !strings.Contains(conf, "wal_level = replica") { + t.Error("missing wal_level = replica") + } + if !strings.Contains(conf, "max_wal_senders = 10") { + t.Error("missing max_wal_senders") + } + if !strings.Contains(conf, "hot_standby = on") { + t.Error("missing hot_standby") + } +} + +func TestGeneratePgHbaConf(t *testing.T) { + conf := GeneratePgHbaConf(false) + if !strings.Contains(conf, "local all") { + t.Error("missing local all entry") + } + if !strings.Contains(conf, "host all") { + t.Error("missing host all entry") + } + if strings.Contains(conf, "replication") { + t.Error("should not contain replication when replication is false") + } +} + +func TestGeneratePgHbaConfWithReplication(t *testing.T) { + conf := GeneratePgHbaConf(true) + if !strings.Contains(conf, "host replication") { + t.Error("missing replication entry") + } +} From 65e555126e91fd17c3faaa77b70eca6e9dd81a55 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:08:20 +0000 Subject: [PATCH 04/16] feat: implement PostgreSQL CreateSandbox with initdb, config gen, and lifecycle scripts --- providers/postgresql/postgresql.go | 5 -- providers/postgresql/postgresql_test.go | 39 +++++++++++ providers/postgresql/sandbox.go | 91 +++++++++++++++++++++++++ providers/postgresql/scripts.go | 41 +++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 providers/postgresql/sandbox.go create mode 100644 providers/postgresql/scripts.go diff --git a/providers/postgresql/postgresql.go b/providers/postgresql/postgresql.go index 52feb77..15c2a51 100644 --- a/providers/postgresql/postgresql.go +++ b/providers/postgresql/postgresql.go @@ -101,11 +101,6 @@ func (p *PostgreSQLProvider) StopSandbox(dir string) error { return nil } -// Stubs for CreateSandbox and CreateReplica — implemented in later tasks -func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*providers.SandboxInfo, error) { - return nil, fmt.Errorf("PostgreSQLProvider.CreateSandbox: not yet implemented") -} - func (p *PostgreSQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { return nil, fmt.Errorf("PostgreSQLProvider.CreateReplica: not yet implemented") } diff --git a/providers/postgresql/postgresql_test.go b/providers/postgresql/postgresql_test.go index affb386..32f6903 100644 --- a/providers/postgresql/postgresql_test.go +++ b/providers/postgresql/postgresql_test.go @@ -1,6 +1,7 @@ package postgresql import ( + "strings" "testing" "github.com/ProxySQL/dbdeployer/providers" @@ -97,3 +98,41 @@ func TestPostgreSQLProviderRegister(t *testing.T) { t.Errorf("expected 'postgresql', got %q", p.Name()) } } + +func TestGenerateScripts(t *testing.T) { + opts := ScriptOptions{ + SandboxDir: "/tmp/pg_sandbox", + DataDir: "/tmp/pg_sandbox/data", + BinDir: "/opt/postgresql/16.13/bin", + LibDir: "/opt/postgresql/16.13/lib", + Port: 16613, + LogFile: "/tmp/pg_sandbox/postgresql.log", + } + scripts := GenerateScripts(opts) + + expectedScripts := []string{"start", "stop", "status", "restart", "use", "clear"} + for _, name := range expectedScripts { + if _, ok := scripts[name]; !ok { + t.Errorf("missing script %q", name) + } + } + + start := scripts["start"] + if !strings.Contains(start, "pg_ctl") { + t.Error("start script missing pg_ctl") + } + if !strings.Contains(start, "LD_LIBRARY_PATH") { + t.Error("start script missing LD_LIBRARY_PATH") + } + if !strings.Contains(start, "unset PGDATA") { + t.Error("start script missing PGDATA unset") + } + + use := scripts["use"] + if !strings.Contains(use, "psql") { + t.Error("use script missing psql") + } + if !strings.Contains(use, "16613") { + t.Error("use script missing port") + } +} diff --git a/providers/postgresql/sandbox.go b/providers/postgresql/sandbox.go new file mode 100644 index 0000000..df28b26 --- /dev/null +++ b/providers/postgresql/sandbox.go @@ -0,0 +1,91 @@ +package postgresql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/ProxySQL/dbdeployer/providers" +) + +func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*providers.SandboxInfo, error) { + basedir, err := p.resolveBasedir(config) + if err != nil { + return nil, err + } + binDir := filepath.Join(basedir, "bin") + libDir := filepath.Join(basedir, "lib") + dataDir := filepath.Join(config.Dir, "data") + logDir := filepath.Join(dataDir, "log") + logFile := filepath.Join(config.Dir, "postgresql.log") + + replication := config.Options["replication"] == "true" + + // Create log directory + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("creating log directory: %w", err) + } + + // Run initdb + initdbPath := filepath.Join(binDir, "initdb") + initCmd := exec.Command(initdbPath, "-D", dataDir, "--auth=trust", "--username=postgres") + initCmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", libDir)) + if output, err := initCmd.CombinedOutput(); err != nil { + os.RemoveAll(config.Dir) // cleanup on failure + return nil, fmt.Errorf("initdb failed: %s: %w", string(output), err) + } + + // Generate and write postgresql.conf + pgConf := GeneratePostgresqlConf(PostgresqlConfOptions{ + Port: config.Port, + ListenAddresses: "127.0.0.1", + UnixSocketDir: dataDir, + LogDir: logDir, + Replication: replication, + }) + confPath := filepath.Join(dataDir, "postgresql.conf") + if err := os.WriteFile(confPath, []byte(pgConf), 0644); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("writing postgresql.conf: %w", err) + } + + // Generate and write pg_hba.conf + hbaConf := GeneratePgHbaConf(replication) + hbaPath := filepath.Join(dataDir, "pg_hba.conf") + if err := os.WriteFile(hbaPath, []byte(hbaConf), 0644); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("writing pg_hba.conf: %w", err) + } + + // Generate and write lifecycle scripts + scripts := GenerateScripts(ScriptOptions{ + SandboxDir: config.Dir, + DataDir: dataDir, + BinDir: binDir, + LibDir: libDir, + Port: config.Port, + LogFile: logFile, + }) + for name, content := range scripts { + scriptPath := filepath.Join(config.Dir, name) + if err := os.WriteFile(scriptPath, []byte(content), 0755); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("writing script %s: %w", name, err) + } + } + + return &providers.SandboxInfo{ + Dir: config.Dir, + Port: config.Port, + Status: "stopped", + }, nil +} + +// resolveBasedir determines the PostgreSQL base directory. +func (p *PostgreSQLProvider) resolveBasedir(config providers.SandboxConfig) (string, error) { + if bd, ok := config.Options["basedir"]; ok && bd != "" { + return bd, nil + } + return basedirFromVersion(config.Version) +} diff --git a/providers/postgresql/scripts.go b/providers/postgresql/scripts.go new file mode 100644 index 0000000..da005bb --- /dev/null +++ b/providers/postgresql/scripts.go @@ -0,0 +1,41 @@ +package postgresql + +import "fmt" + +type ScriptOptions struct { + SandboxDir string + DataDir string + BinDir string + LibDir string + Port int + LogFile string +} + +const envPreamble = `#!/bin/bash +export LD_LIBRARY_PATH="%s" +unset PGDATA PGPORT PGHOST PGUSER PGDATABASE +` + +func GenerateScripts(opts ScriptOptions) map[string]string { + preamble := fmt.Sprintf(envPreamble, opts.LibDir) + + return map[string]string{ + "start": fmt.Sprintf("%s%s/pg_ctl -D %s -l %s start\n", + preamble, opts.BinDir, opts.DataDir, opts.LogFile), + + "stop": fmt.Sprintf("%s%s/pg_ctl -D %s stop -m fast\n", + preamble, opts.BinDir, opts.DataDir), + + "status": fmt.Sprintf("%s%s/pg_ctl -D %s status\n", + preamble, opts.BinDir, opts.DataDir), + + "restart": fmt.Sprintf("%s%s/pg_ctl -D %s -l %s restart\n", + preamble, opts.BinDir, opts.DataDir, opts.LogFile), + + "use": fmt.Sprintf("%s%s/psql -h 127.0.0.1 -p %d -U postgres \"$@\"\n", + preamble, opts.BinDir, opts.Port), + + "clear": fmt.Sprintf("%s%s/pg_ctl -D %s stop -m fast 2>/dev/null\nrm -rf %s\n%s/initdb -D %s --auth=trust --username=postgres\necho \"Sandbox cleared.\"\n", + preamble, opts.BinDir, opts.DataDir, opts.DataDir, opts.BinDir, opts.DataDir), + } +} From 1c8d32bd774990666706a19018308441dbb72bcc Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:09:42 +0000 Subject: [PATCH 05/16] feat: add PostgreSQL deb extraction for binary management Implements ParseDebVersion, ClassifyDebs, RequiredBinaries, and UnpackDebs to extract PostgreSQL server and client .deb packages into a structured target directory with bin/, lib/, and share/ subdirectories. --- providers/postgresql/unpack.go | 102 ++++++++++++++++++++++++++++ providers/postgresql/unpack_test.go | 65 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 providers/postgresql/unpack.go create mode 100644 providers/postgresql/unpack_test.go diff --git a/providers/postgresql/unpack.go b/providers/postgresql/unpack.go new file mode 100644 index 0000000..33b0542 --- /dev/null +++ b/providers/postgresql/unpack.go @@ -0,0 +1,102 @@ +package postgresql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var debVersionRegex = regexp.MustCompile(`^postgresql(?:-client)?-(\d+)_(\d+\.\d+)`) + +func ParseDebVersion(filename string) (string, error) { + base := filepath.Base(filename) + matches := debVersionRegex.FindStringSubmatch(base) + if matches == nil { + return "", fmt.Errorf("cannot parse PostgreSQL version from %q (expected postgresql[-client]-NN_X.Y-*)", base) + } + return matches[2], nil +} + +func ClassifyDebs(files []string) (server, client string, err error) { + for _, f := range files { + base := filepath.Base(f) + if strings.HasPrefix(base, "postgresql-client-") { + client = f + } else if strings.HasPrefix(base, "postgresql-") && strings.HasSuffix(base, ".deb") { + server = f + } + } + if server == "" { + return "", "", fmt.Errorf("no server deb found (expected postgresql-NN_*.deb)") + } + if client == "" { + return "", "", fmt.Errorf("no client deb found (expected postgresql-client-NN_*.deb)") + } + return server, client, nil +} + +func RequiredBinaries() []string { + return []string{"postgres", "initdb", "pg_ctl", "psql", "pg_basebackup"} +} + +func UnpackDebs(serverDeb, clientDeb, targetDir string) error { + tmpDir, err := os.MkdirTemp("", "dbdeployer-pg-unpack-*") + if err != nil { + return fmt.Errorf("creating temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + for _, deb := range []string{serverDeb, clientDeb} { + cmd := exec.Command("dpkg-deb", "-x", deb, tmpDir) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("extracting %s: %s: %w", filepath.Base(deb), string(output), err) + } + } + + version, err := ParseDebVersion(serverDeb) + if err != nil { + return err + } + major := strings.Split(version, ".")[0] + + srcBin := filepath.Join(tmpDir, "usr", "lib", "postgresql", major, "bin") + srcLib := filepath.Join(tmpDir, "usr", "lib", "postgresql", major, "lib") + srcShare := filepath.Join(tmpDir, "usr", "share", "postgresql", major) + + dstBin := filepath.Join(targetDir, "bin") + dstLib := filepath.Join(targetDir, "lib") + dstShare := filepath.Join(targetDir, "share") + + for _, dir := range []string{dstBin, dstLib, dstShare} { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + } + + copies := []struct{ src, dst string }{ + {srcBin, dstBin}, + {srcLib, dstLib}, + {srcShare, dstShare}, + } + for _, c := range copies { + if _, err := os.Stat(c.src); os.IsNotExist(err) { + continue + } + cmd := exec.Command("cp", "-a", c.src+"/.", c.dst+"/") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("copying %s to %s: %s: %w", c.src, c.dst, string(output), err) + } + } + + for _, bin := range RequiredBinaries() { + binPath := filepath.Join(dstBin, bin) + if _, err := os.Stat(binPath); err != nil { + return fmt.Errorf("required binary %q not found at %s after extraction", bin, binPath) + } + } + + return nil +} diff --git a/providers/postgresql/unpack_test.go b/providers/postgresql/unpack_test.go new file mode 100644 index 0000000..881ce19 --- /dev/null +++ b/providers/postgresql/unpack_test.go @@ -0,0 +1,65 @@ +package postgresql + +import "testing" + +func TestParseDebVersion(t *testing.T) { + tests := []struct { + filename string + wantVer string + wantErr bool + }{ + {"postgresql-16_16.13-0ubuntu0.24.04.1_amd64.deb", "16.13", false}, + {"postgresql-17_17.2-1_amd64.deb", "17.2", false}, + {"postgresql-client-16_16.13-0ubuntu0.24.04.1_amd64.deb", "16.13", false}, + {"random-file.tar.gz", "", true}, + {"postgresql-16_bad-version.deb", "", true}, + } + for _, tt := range tests { + ver, err := ParseDebVersion(tt.filename) + if (err != nil) != tt.wantErr { + t.Errorf("ParseDebVersion(%q) error = %v, wantErr %v", tt.filename, err, tt.wantErr) + continue + } + if ver != tt.wantVer { + t.Errorf("ParseDebVersion(%q) = %q, want %q", tt.filename, ver, tt.wantVer) + } + } +} + +func TestClassifyDebs(t *testing.T) { + files := []string{ + "postgresql-16_16.13-0ubuntu0.24.04.1_amd64.deb", + "postgresql-client-16_16.13-0ubuntu0.24.04.1_amd64.deb", + } + server, client, err := ClassifyDebs(files) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if server != files[0] { + t.Errorf("server = %q, want %q", server, files[0]) + } + if client != files[1] { + t.Errorf("client = %q, want %q", client, files[1]) + } +} + +func TestClassifyDebsMissingClient(t *testing.T) { + files := []string{"postgresql-16_16.13-0ubuntu0.24.04.1_amd64.deb"} + _, _, err := ClassifyDebs(files) + if err == nil { + t.Error("expected error for missing client deb") + } +} + +func TestRequiredBinaries(t *testing.T) { + expected := []string{"postgres", "initdb", "pg_ctl", "psql", "pg_basebackup"} + got := RequiredBinaries() + if len(got) != len(expected) { + t.Fatalf("expected %d binaries, got %d", len(expected), len(got)) + } + for i, name := range expected { + if got[i] != name { + t.Errorf("binary[%d] = %q, want %q", i, got[i], name) + } + } +} From ba2c992b467cbf9066740902c77f66b487edb42a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:11:33 +0000 Subject: [PATCH 06/16] feat: implement PostgreSQL CreateReplica with pg_basebackup and monitoring scripts --- providers/postgresql/postgresql.go | 4 -- providers/postgresql/postgresql_test.go | 28 ++++++++ providers/postgresql/replication.go | 93 +++++++++++++++++++++++++ providers/postgresql/scripts.go | 23 +++++- 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 providers/postgresql/replication.go diff --git a/providers/postgresql/postgresql.go b/providers/postgresql/postgresql.go index 15c2a51..890d910 100644 --- a/providers/postgresql/postgresql.go +++ b/providers/postgresql/postgresql.go @@ -101,10 +101,6 @@ func (p *PostgreSQLProvider) StopSandbox(dir string) error { return nil } -func (p *PostgreSQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { - return nil, fmt.Errorf("PostgreSQLProvider.CreateReplica: not yet implemented") -} - func Register(reg *providers.Registry) error { return reg.Register(NewPostgreSQLProvider()) } diff --git a/providers/postgresql/postgresql_test.go b/providers/postgresql/postgresql_test.go index 32f6903..6414498 100644 --- a/providers/postgresql/postgresql_test.go +++ b/providers/postgresql/postgresql_test.go @@ -99,6 +99,34 @@ func TestPostgreSQLProviderRegister(t *testing.T) { } } +func TestGenerateCheckReplicationScript(t *testing.T) { + script := GenerateCheckReplicationScript(ScriptOptions{ + BinDir: "/opt/postgresql/16.13/bin", + LibDir: "/opt/postgresql/16.13/lib", + Port: 16613, + }) + if !strings.Contains(script, "pg_stat_replication") { + t.Error("missing pg_stat_replication query") + } + if !strings.Contains(script, "16613") { + t.Error("missing primary port") + } +} + +func TestGenerateCheckRecoveryScript(t *testing.T) { + ports := []int{16614, 16615} + script := GenerateCheckRecoveryScript(ScriptOptions{ + BinDir: "/opt/postgresql/16.13/bin", + LibDir: "/opt/postgresql/16.13/lib", + }, ports) + if !strings.Contains(script, "pg_is_in_recovery") { + t.Error("missing pg_is_in_recovery query") + } + if !strings.Contains(script, "16614") || !strings.Contains(script, "16615") { + t.Error("missing replica ports") + } +} + func TestGenerateScripts(t *testing.T) { opts := ScriptOptions{ SandboxDir: "/tmp/pg_sandbox", diff --git a/providers/postgresql/replication.go b/providers/postgresql/replication.go new file mode 100644 index 0000000..f11fbaf --- /dev/null +++ b/providers/postgresql/replication.go @@ -0,0 +1,93 @@ +package postgresql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ProxySQL/dbdeployer/providers" +) + +func (p *PostgreSQLProvider) CreateReplica(primary providers.SandboxInfo, config providers.SandboxConfig) (*providers.SandboxInfo, error) { + basedir, err := p.resolveBasedir(config) + if err != nil { + return nil, err + } + binDir := filepath.Join(basedir, "bin") + libDir := filepath.Join(basedir, "lib") + dataDir := filepath.Join(config.Dir, "data") + logFile := filepath.Join(config.Dir, "postgresql.log") + + // pg_basebackup from the running primary + pgBasebackup := filepath.Join(binDir, "pg_basebackup") + bbCmd := exec.Command(pgBasebackup, + "-h", "127.0.0.1", + "-p", fmt.Sprintf("%d", primary.Port), + "-U", "postgres", + "-D", dataDir, + "-Fp", "-Xs", "-R", + ) + bbCmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", libDir)) + if output, err := bbCmd.CombinedOutput(); err != nil { + os.RemoveAll(config.Dir) // cleanup on failure + return nil, fmt.Errorf("pg_basebackup failed: %s: %w", string(output), err) + } + + // Modify replica's postgresql.conf: update port and unix_socket_directories + confPath := filepath.Join(dataDir, "postgresql.conf") + confBytes, err := os.ReadFile(confPath) + if err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("reading postgresql.conf: %w", err) + } + + conf := string(confBytes) + lines := strings.Split(conf, "\n") + var newLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "port =") || strings.HasPrefix(trimmed, "port=") { + newLines = append(newLines, fmt.Sprintf("port = %d", config.Port)) + } else if strings.HasPrefix(trimmed, "unix_socket_directories =") || strings.HasPrefix(trimmed, "unix_socket_directories=") { + newLines = append(newLines, fmt.Sprintf("unix_socket_directories = '%s'", dataDir)) + } else { + newLines = append(newLines, line) + } + } + + if err := os.WriteFile(confPath, []byte(strings.Join(newLines, "\n")), 0644); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("writing modified postgresql.conf: %w", err) + } + + // Write lifecycle scripts + scripts := GenerateScripts(ScriptOptions{ + SandboxDir: config.Dir, + DataDir: dataDir, + BinDir: binDir, + LibDir: libDir, + Port: config.Port, + LogFile: logFile, + }) + for name, content := range scripts { + scriptPath := filepath.Join(config.Dir, name) + if err := os.WriteFile(scriptPath, []byte(content), 0755); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("writing script %s: %w", name, err) + } + } + + // Start the replica + if err := p.StartSandbox(config.Dir); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("starting replica: %w", err) + } + + return &providers.SandboxInfo{ + Dir: config.Dir, + Port: config.Port, + Status: "running", + }, nil +} diff --git a/providers/postgresql/scripts.go b/providers/postgresql/scripts.go index da005bb..03b2658 100644 --- a/providers/postgresql/scripts.go +++ b/providers/postgresql/scripts.go @@ -1,6 +1,9 @@ package postgresql -import "fmt" +import ( + "fmt" + "strings" +) type ScriptOptions struct { SandboxDir string @@ -39,3 +42,21 @@ func GenerateScripts(opts ScriptOptions) map[string]string { preamble, opts.BinDir, opts.DataDir, opts.DataDir, opts.BinDir, opts.DataDir), } } + +func GenerateCheckReplicationScript(opts ScriptOptions) string { + preamble := fmt.Sprintf(envPreamble, opts.LibDir) + return fmt.Sprintf(`%s%s/psql -h 127.0.0.1 -p %d -U postgres -c \ + "SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn FROM pg_stat_replication;" +`, preamble, opts.BinDir, opts.Port) +} + +func GenerateCheckRecoveryScript(opts ScriptOptions, replicaPorts []int) string { + preamble := fmt.Sprintf(envPreamble, opts.LibDir) + var b strings.Builder + b.WriteString(preamble) + for _, port := range replicaPorts { + b.WriteString(fmt.Sprintf("echo \"=== Replica port %d ===\"\n", port)) + b.WriteString(fmt.Sprintf("%s/psql -h 127.0.0.1 -p %d -U postgres -c \"SELECT pg_is_in_recovery();\"\n", opts.BinDir, port)) + } + return b.String() +} From fa6e1d88e731370ad4336dd784c0019627ecf3e8 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:16:17 +0000 Subject: [PATCH 07/16] feat: add --provider flag and PostgreSQL routing to deploy commands Register the PostgreSQL provider in root.go and add --provider flag to single, multiple, and replication commands. Non-MySQL providers bypass fillSandboxDefinition and route to dedicated deploy functions. The DeployProxySQLForTopology function gains a backendProvider parameter to support future cross-database ProxySQL wiring. --- cmd/multiple.go | 92 ++++++++++++++++++++- cmd/replication.go | 149 ++++++++++++++++++++++++++++++++++- cmd/root.go | 2 + cmd/single.go | 94 +++++++++++++++++++++- globals/globals.go | 4 + providers/provider.go | 15 ++++ sandbox/proxysql_topology.go | 4 +- 7 files changed, 354 insertions(+), 6 deletions(-) diff --git a/cmd/multiple.go b/cmd/multiple.go index 51022c4..bd39724 100644 --- a/cmd/multiple.go +++ b/cmd/multiple.go @@ -16,17 +16,106 @@ package cmd import ( + "fmt" + "os" + "path" + "strings" + "github.com/ProxySQL/dbdeployer/common" + "github.com/ProxySQL/dbdeployer/defaults" "github.com/ProxySQL/dbdeployer/globals" "github.com/ProxySQL/dbdeployer/providers" + "github.com/ProxySQL/dbdeployer/providers/postgresql" "github.com/ProxySQL/dbdeployer/sandbox" "github.com/spf13/cobra" ) +func deployMultipleNonMySQL(cmd *cobra.Command, args []string, providerName string) { + flags := cmd.Flags() + version := args[0] + nodes, _ := flags.GetInt(globals.NodesLabel) + + p, err := providers.DefaultRegistry.Get(providerName) + if err != nil { + common.Exitf(1, "provider error: %s", err) + } + + flavor, _ := flags.GetString(globals.FlavorLabel) + if flavor != "" { + common.Exitf(1, "--flavor is only valid with --provider=mysql") + } + + if !providers.ContainsString(p.SupportedTopologies(), "multiple") { + common.Exitf(1, "provider %q does not support topology \"multiple\"\nSupported topologies: %s", + providerName, strings.Join(p.SupportedTopologies(), ", ")) + } + + if err := p.ValidateVersion(version); err != nil { + common.Exitf(1, "version validation failed: %s", err) + } + + if _, err := p.FindBinary(version); err != nil { + common.Exitf(1, "binaries not found: %s", err) + } + + basePort := p.DefaultPorts().BasePort + if providerName == "postgresql" { + basePort, _ = postgresql.VersionToPort(version) + } + + sandboxHome := defaults.Defaults().SandboxHome + topologyDir := path.Join(sandboxHome, fmt.Sprintf("%s_multi_%d", providerName, basePort)) + if common.DirExists(topologyDir) { + common.Exitf(1, "sandbox directory %s already exists", topologyDir) + } + os.MkdirAll(topologyDir, 0755) + + skipStart, _ := flags.GetBool(globals.SkipStartLabel) + + for i := 1; i <= nodes; i++ { + port := basePort + i + freePort, err := common.FindFreePort(port, []int{}, 1) + if err == nil { + port = freePort + } + + nodeDir := path.Join(topologyDir, fmt.Sprintf("node%d", i)) + config := providers.SandboxConfig{ + Version: version, + Dir: nodeDir, + Port: port, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{}, + } + + if _, err := p.CreateSandbox(config); err != nil { + common.Exitf(1, "error creating node %d: %s", i, err) + } + + if !skipStart { + if err := p.StartSandbox(nodeDir); err != nil { + common.Exitf(1, "error starting node %d: %s", i, err) + } + } + + fmt.Printf(" Node %d deployed in %s (port: %d)\n", i, nodeDir, port) + } + + fmt.Printf("%s multiple sandbox (%d nodes) deployed in %s\n", providerName, nodes, topologyDir) +} + func multipleSandbox(cmd *cobra.Command, args []string) { + flags := cmd.Flags() + providerName, _ := flags.GetString(globals.ProviderLabel) + + if providerName != "mysql" { + deployMultipleNonMySQL(cmd, args, providerName) + return + } + var sd sandbox.SandboxDef common.CheckOrigin(args) - flags := cmd.Flags() sd, err := fillSandboxDefinition(cmd, args, false) common.ErrCheckExitf(err, 1, "error filling sandbox definition") // Validate version with provider @@ -69,4 +158,5 @@ Use the "unpack" command to get the tarball into the right directory. func init() { deployCmd.AddCommand(multipleCmd) multipleCmd.PersistentFlags().IntP(globals.NodesLabel, "n", globals.NodesValue, "How many nodes will be installed") + multipleCmd.PersistentFlags().String(globals.ProviderLabel, globals.ProviderValue, "Database provider (mysql, postgresql)") } diff --git a/cmd/replication.go b/cmd/replication.go index 696f41d..2006a3c 100644 --- a/cmd/replication.go +++ b/cmd/replication.go @@ -17,17 +17,162 @@ package cmd import ( "fmt" + "os" "path" + "strings" "github.com/ProxySQL/dbdeployer/common" "github.com/ProxySQL/dbdeployer/defaults" "github.com/ProxySQL/dbdeployer/globals" "github.com/ProxySQL/dbdeployer/providers" + "github.com/ProxySQL/dbdeployer/providers/postgresql" "github.com/ProxySQL/dbdeployer/sandbox" "github.com/spf13/cobra" ) +func deployReplicationNonMySQL(cmd *cobra.Command, args []string, providerName string) { + flags := cmd.Flags() + version := args[0] + nodes, _ := flags.GetInt(globals.NodesLabel) + + p, err := providers.DefaultRegistry.Get(providerName) + if err != nil { + common.Exitf(1, "provider error: %s", err) + } + + flavor, _ := flags.GetString(globals.FlavorLabel) + if flavor != "" { + common.Exitf(1, "--flavor is only valid with --provider=mysql") + } + + if !providers.ContainsString(p.SupportedTopologies(), "replication") { + common.Exitf(1, "provider %q does not support topology \"replication\"\nSupported topologies: %s", + providerName, strings.Join(p.SupportedTopologies(), ", ")) + } + + if err := p.ValidateVersion(version); err != nil { + common.Exitf(1, "version validation failed: %s", err) + } + + if _, err := p.FindBinary(version); err != nil { + common.Exitf(1, "binaries not found: %s", err) + } + + basePort := p.DefaultPorts().BasePort + if providerName == "postgresql" { + basePort, _ = postgresql.VersionToPort(version) + } + + sandboxHome := defaults.Defaults().SandboxHome + topologyDir := path.Join(sandboxHome, fmt.Sprintf("%s_repl_%d", providerName, basePort)) + if common.DirExists(topologyDir) { + common.Exitf(1, "sandbox directory %s already exists", topologyDir) + } + os.MkdirAll(topologyDir, 0755) + + primaryPort := basePort + + // Create and start primary with replication options + primaryDir := path.Join(topologyDir, "primary") + primaryConfig := providers.SandboxConfig{ + Version: version, + Dir: primaryDir, + Port: primaryPort, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{"replication": "true"}, + } + + if _, err := p.CreateSandbox(primaryConfig); err != nil { + common.Exitf(1, "error creating primary: %s", err) + } + + skipStart, _ := flags.GetBool(globals.SkipStartLabel) + if !skipStart { + if err := p.StartSandbox(primaryDir); err != nil { + common.Exitf(1, "error starting primary: %s", err) + } + } + + fmt.Printf(" Primary deployed in %s (port: %d)\n", primaryDir, primaryPort) + + primaryInfo := providers.SandboxInfo{Dir: primaryDir, Port: primaryPort, Status: "running"} + + // Create replicas sequentially + var replicaPorts []int + for i := 1; i <= nodes-1; i++ { + replicaPort := primaryPort + i + freePort, err := common.FindFreePort(replicaPort, []int{}, 1) + if err == nil { + replicaPort = freePort + } + + replicaDir := path.Join(topologyDir, fmt.Sprintf("replica%d", i)) + replicaConfig := providers.SandboxConfig{ + Version: version, + Dir: replicaDir, + Port: replicaPort, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{}, + } + + if _, err := p.CreateReplica(primaryInfo, replicaConfig); err != nil { + // Cleanup on failure + p.StopSandbox(primaryDir) + for j := 1; j < i; j++ { + p.StopSandbox(path.Join(topologyDir, fmt.Sprintf("replica%d", j))) + } + common.Exitf(1, "error creating replica %d: %s", i, err) + } + + replicaPorts = append(replicaPorts, replicaPort) + fmt.Printf(" Replica %d deployed in %s (port: %d)\n", i, replicaDir, replicaPort) + } + + // Generate monitoring scripts + home, _ := os.UserHomeDir() + basedir := path.Join(home, "opt", "postgresql", version) + binDir := path.Join(basedir, "bin") + libDir := path.Join(basedir, "lib") + + scriptOpts := postgresql.ScriptOptions{ + BinDir: binDir, + LibDir: libDir, + Port: primaryPort, + } + + checkReplScript := postgresql.GenerateCheckReplicationScript(scriptOpts) + os.WriteFile(path.Join(topologyDir, "check_replication"), []byte(checkReplScript), 0755) + + checkRecovScript := postgresql.GenerateCheckRecoveryScript(scriptOpts, replicaPorts) + os.WriteFile(path.Join(topologyDir, "check_recovery"), []byte(checkRecovScript), 0755) + + // Handle --with-proxysql + withProxySQL, _ := flags.GetBool("with-proxysql") + if withProxySQL { + if !providers.ContainsString(providers.CompatibleAddons["proxysql"], providerName) { + common.Exitf(1, "--with-proxysql is not compatible with provider %q", providerName) + } + err := sandbox.DeployProxySQLForTopology(topologyDir, primaryPort, replicaPorts, 0, "127.0.0.1", providerName) + if err != nil { + common.Exitf(1, "ProxySQL deployment failed: %s", err) + } + } + + fmt.Printf("%s replication sandbox (1 primary + %d replicas) deployed in %s\n", + providerName, nodes-1, topologyDir) +} + func replicationSandbox(cmd *cobra.Command, args []string) { + flags := cmd.Flags() + providerName, _ := flags.GetString(globals.ProviderLabel) + + if providerName != "mysql" { + deployReplicationNonMySQL(cmd, args, providerName) + return + } + var sd sandbox.SandboxDef var semisync bool common.CheckOrigin(args) @@ -46,7 +191,6 @@ func replicationSandbox(cmd *cobra.Command, args []string) { common.Exitf(1, "flavor '%s' is not suitable to create replication sandboxes", common.TiDbFlavor) } sd.ReplOptions = sandbox.SingleTemplates[globals.TmplReplicationOptions].Contents - flags := cmd.Flags() semisync, _ = flags.GetBool(globals.SemiSyncLabel) ndbNodes, _ := flags.GetInt(globals.NdbNodesLabel) nodes, _ := flags.GetInt(globals.NodesLabel) @@ -132,7 +276,7 @@ func replicationSandbox(cmd *cobra.Command, args []string) { slavePorts = append(slavePorts, nodeDesc.Port[0]) } - err = sandbox.DeployProxySQLForTopology(sandboxDir, masterPort, slavePorts, 0, "127.0.0.1") + err = sandbox.DeployProxySQLForTopology(sandboxDir, masterPort, slavePorts, 0, "127.0.0.1", "") if err != nil { common.Exitf(1, "ProxySQL deployment failed: %s", err) } @@ -190,4 +334,5 @@ func init() { replicationCmd.PersistentFlags().Bool(globals.ReplHistoryDirLabel, false, "uses the replication directory to store mysql client history") setPflag(replicationCmd, globals.ChangeMasterOptions, "", "CHANGE_MASTER_OPTIONS", "", "options to add to CHANGE MASTER TO", true) replicationCmd.PersistentFlags().Bool("with-proxysql", false, "Deploy ProxySQL alongside the replication sandbox") + replicationCmd.PersistentFlags().String(globals.ProviderLabel, globals.ProviderValue, "Database provider (mysql, postgresql)") } diff --git a/cmd/root.go b/cmd/root.go index 6fee26b..3f2bf10 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" + postgresqlprovider "github.com/ProxySQL/dbdeployer/providers/postgresql" proxysqlprovider "github.com/ProxySQL/dbdeployer/providers/proxysql" "github.com/ProxySQL/dbdeployer/sandbox" ) @@ -150,6 +151,7 @@ func init() { panic(fmt.Sprintf("failed to register MySQL provider: %v", err)) } _ = proxysqlprovider.Register(providers.DefaultRegistry) + _ = postgresqlprovider.Register(providers.DefaultRegistry) cobra.OnInitialize(checkDefaultsFile) rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.PersistentFlags().StringVar(&defaults.CustomConfigurationFile, globals.ConfigLabel, defaults.ConfigurationFile, "configuration file") diff --git a/cmd/single.go b/cmd/single.go index 5d07870..d79ca79 100644 --- a/cmd/single.go +++ b/cmd/single.go @@ -26,6 +26,7 @@ import ( "github.com/ProxySQL/dbdeployer/defaults" "github.com/ProxySQL/dbdeployer/globals" "github.com/ProxySQL/dbdeployer/providers" + "github.com/ProxySQL/dbdeployer/providers/postgresql" "github.com/ProxySQL/dbdeployer/sandbox" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -438,7 +439,96 @@ func fillSandboxDefinition(cmd *cobra.Command, args []string, usingImport bool) return sd, nil } +func deploySingleNonMySQL(cmd *cobra.Command, args []string, providerName string) { + flags := cmd.Flags() + version := args[0] + + p, err := providers.DefaultRegistry.Get(providerName) + if err != nil { + common.Exitf(1, "provider error: %s", err) + } + + // Flavor validation + flavor, _ := flags.GetString(globals.FlavorLabel) + if flavor != "" { + common.Exitf(1, "--flavor is only valid with --provider=mysql") + } + + // Topology validation + if !providers.ContainsString(p.SupportedTopologies(), "single") { + common.Exitf(1, "provider %q does not support topology \"single\"\nSupported topologies: %s", + providerName, strings.Join(p.SupportedTopologies(), ", ")) + } + + if err := p.ValidateVersion(version); err != nil { + common.Exitf(1, "version validation failed: %s", err) + } + + if _, err := p.FindBinary(version); err != nil { + common.Exitf(1, "binaries not found: %s", err) + } + + // Compute port + port := p.DefaultPorts().BasePort + if providerName == "postgresql" { + port, _ = postgresql.VersionToPort(version) + } + freePort, portErr := common.FindFreePort(port, []int{}, p.DefaultPorts().PortsPerInstance) + if portErr == nil { + port = freePort + } + + sandboxHome := defaults.Defaults().SandboxHome + sandboxDir := path.Join(sandboxHome, fmt.Sprintf("%s_sandbox_%d", providerName, port)) + if common.DirExists(sandboxDir) { + common.Exitf(1, "sandbox directory %s already exists", sandboxDir) + } + + skipStart, _ := flags.GetBool(globals.SkipStartLabel) + config := providers.SandboxConfig{ + Version: version, + Dir: sandboxDir, + Port: port, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{}, + } + + if _, err := p.CreateSandbox(config); err != nil { + common.Exitf(1, "error creating sandbox: %s", err) + } + + if !skipStart { + if err := p.StartSandbox(sandboxDir); err != nil { + common.Exitf(1, "error starting sandbox: %s", err) + } + } + + // Handle --with-proxysql + withProxySQL, _ := flags.GetBool("with-proxysql") + if withProxySQL { + if !providers.ContainsString(providers.CompatibleAddons["proxysql"], providerName) { + common.Exitf(1, "--with-proxysql is not compatible with provider %q", providerName) + } + err := sandbox.DeployProxySQLForTopology(sandboxDir, port, nil, 0, "127.0.0.1", providerName) + if err != nil { + common.Exitf(1, "ProxySQL deployment failed: %s", err) + } + } + + fmt.Printf("%s %s sandbox deployed in %s (port: %d)\n", providerName, version, sandboxDir, port) +} + func singleSandbox(cmd *cobra.Command, args []string) { + flags := cmd.Flags() + providerName, _ := flags.GetString(globals.ProviderLabel) + + // Non-MySQL providers: bypass fillSandboxDefinition entirely + if providerName != "mysql" { + deploySingleNonMySQL(cmd, args, providerName) + return + } + var sd sandbox.SandboxDef var err error common.CheckOrigin(args) @@ -462,7 +552,6 @@ func singleSandbox(cmd *cobra.Command, args []string) { common.Exitf(1, globals.ErrCreatingSandbox, err) } - flags := cmd.Flags() withProxySQL, _ := flags.GetBool("with-proxysql") if withProxySQL { // Determine the sandbox directory that was created @@ -482,7 +571,7 @@ func singleSandbox(cmd *cobra.Command, args []string) { } masterPort := sbDesc.Port[0] - err = sandbox.DeployProxySQLForTopology(sandboxDir, masterPort, nil, 0, "127.0.0.1") + err = sandbox.DeployProxySQLForTopology(sandboxDir, masterPort, nil, 0, "127.0.0.1", "") if err != nil { common.Exitf(1, "ProxySQL deployment failed: %s", err) } @@ -515,4 +604,5 @@ func init() { singleCmd.PersistentFlags().Int(globals.ServerIdLabel, 0, "Overwrite default server-id") setPflag(singleCmd, globals.PromptLabel, "", "", globals.PromptValue, "Default prompt for the single client", false) singleCmd.PersistentFlags().Bool("with-proxysql", false, "Deploy ProxySQL alongside the single sandbox") + singleCmd.PersistentFlags().String(globals.ProviderLabel, globals.ProviderValue, "Database provider (mysql, postgresql)") } diff --git a/globals/globals.go b/globals/globals.go index 31896ac..307dc3a 100644 --- a/globals/globals.go +++ b/globals/globals.go @@ -117,6 +117,10 @@ const ( ShellPathLabel = "shell-path" ShellPathValue = "/bin/bash" + // Provider selection + ProviderLabel = "provider" + ProviderValue = "mysql" // default provider + // Instantiated in cmd/info.go EarliestLabel = "earliest" LimitLabel = "limit" diff --git a/providers/provider.go b/providers/provider.go index 892bd53..3f4c55e 100644 --- a/providers/provider.go +++ b/providers/provider.go @@ -87,3 +87,18 @@ func (r *Registry) List() []string { } var DefaultRegistry = NewRegistry() + +// ContainsString checks if a string slice contains a given value. +func ContainsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// CompatibleAddons maps addon names to the list of providers they work with. +var CompatibleAddons = map[string][]string{ + "proxysql": {"mysql", "postgresql"}, +} diff --git a/sandbox/proxysql_topology.go b/sandbox/proxysql_topology.go index be3ff69..c41f9aa 100644 --- a/sandbox/proxysql_topology.go +++ b/sandbox/proxysql_topology.go @@ -32,7 +32,8 @@ import ( // - slavePorts: MySQL slave ports (empty for single topology) // - proxysqlPort: port for ProxySQL admin interface (0 = auto-assign) // - host: bind address (typically "127.0.0.1") -func DeployProxySQLForTopology(sandboxDir string, masterPort int, slavePorts []int, proxysqlPort int, host string) error { +// - backendProvider: database provider name (e.g. "mysql", "postgresql", or "" for mysql default) +func DeployProxySQLForTopology(sandboxDir string, masterPort int, slavePorts []int, proxysqlPort int, host string, backendProvider string) error { reg := providers.DefaultRegistry p, err := reg.Get("proxysql") if err != nil { @@ -73,6 +74,7 @@ func DeployProxySQLForTopology(sandboxDir string, masterPort int, slavePorts []i "monitor_user": "msandbox", "monitor_password": "msandbox", "backends": strings.Join(backendParts, ","), + "backend_provider": backendProvider, }, } From a93fb3c1a160c5d884327aeb1715031e83620f76 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:18:47 +0000 Subject: [PATCH 08/16] feat: add --provider=postgresql support to dbdeployer unpack for deb extraction --- cmd/unpack.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/cmd/unpack.go b/cmd/unpack.go index 191901e..f00d0db 100644 --- a/cmd/unpack.go +++ b/cmd/unpack.go @@ -17,8 +17,11 @@ package cmd import ( "fmt" + "os" + "path/filepath" "github.com/ProxySQL/dbdeployer/ops" + "github.com/ProxySQL/dbdeployer/providers/postgresql" "github.com/spf13/cobra" "github.com/ProxySQL/dbdeployer/common" @@ -37,6 +40,31 @@ func unpackTarball(cmd *cobra.Command, args []string) { flavor, _ := flags.GetString(globals.FlavorLabel) dryRun, _ := flags.GetBool(globals.DryRunLabel) Version, _ := flags.GetString(globals.UnpackVersionLabel) + providerName, _ := flags.GetString(globals.ProviderLabel) + if providerName == "postgresql" { + if len(args) < 2 { + common.Exitf(1, "PostgreSQL unpack requires both server and client .deb files\n"+ + "Usage: dbdeployer unpack --provider=postgresql postgresql-16_*.deb postgresql-client-16_*.deb") + } + server, client, err := postgresql.ClassifyDebs(args) + if err != nil { + common.Exitf(1, "error classifying deb files: %s", err) + } + version := Version + if version == "" { + version, err = postgresql.ParseDebVersion(server) + if err != nil { + common.Exitf(1, "cannot detect version from filename: %s\nUse --unpack-version to specify", err) + } + } + home, _ := os.UserHomeDir() + targetDir := filepath.Join(home, "opt", "postgresql", version) + if err := postgresql.UnpackDebs(server, client, targetDir); err != nil { + common.Exitf(1, "error unpacking PostgreSQL debs: %s", err) + } + fmt.Printf("PostgreSQL %s unpacked to %s\n", version, targetDir) + return + } if !common.DirExists(Basedir) { common.Exit(1, fmt.Sprintf(globals.ErrDirectoryNotFound, Basedir), @@ -64,7 +92,7 @@ func unpackTarball(cmd *cobra.Command, args []string) { // unpackCmd represents the unpack command var unpackCmd = &cobra.Command{ Use: "unpack MySQL-tarball", - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), Aliases: []string{"extract", "untar", "unzip", "inflate", "expand"}, Short: "unpack a tarball into the binary directory", Long: `If you want to create a sandbox from a tarball (.tar.gz or .tar.xz), you first need to unpack it @@ -99,4 +127,5 @@ func init() { unpackCmd.PersistentFlags().Bool(globals.DryRunLabel, false, "Show unpack operations, but do not run them") unpackCmd.PersistentFlags().String(globals.TargetServerLabel, "", "Uses a different server to unpack a shell tarball") unpackCmd.PersistentFlags().String(globals.FlavorLabel, "", "Defines the tarball flavor (MySQL, NDB, Percona Server, etc)") + unpackCmd.PersistentFlags().String(globals.ProviderLabel, globals.ProviderValue, "Database provider (mysql, postgresql)") } From 8038ebd1e55f5afeb345ab1dd7f4126ba57e01ab Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:21:04 +0000 Subject: [PATCH 09/16] feat: add ProxySQL PostgreSQL backend wiring (pgsql_servers/pgsql_users config) Add BackendProvider field to ProxySQLConfig; GenerateConfig now branches on backend_provider="postgresql" to emit pgsql_variables/pgsql_servers/pgsql_users instead of the mysql_* equivalents. use_proxy script uses psql for PostgreSQL backends. Tests cover both MySQL and PostgreSQL config generation paths. --- providers/proxysql/config.go | 54 ++++++++++++++++++---------- providers/proxysql/config_test.go | 60 +++++++++++++++++++++++++++++++ providers/proxysql/proxysql.go | 28 +++++++++------ 3 files changed, 113 insertions(+), 29 deletions(-) diff --git a/providers/proxysql/config.go b/providers/proxysql/config.go index d6247a9..8a63320 100644 --- a/providers/proxysql/config.go +++ b/providers/proxysql/config.go @@ -13,15 +13,16 @@ type BackendServer struct { } type ProxySQLConfig struct { - AdminHost string - AdminPort int - AdminUser string - AdminPassword string - MySQLPort int - DataDir string - Backends []BackendServer - MonitorUser string - MonitorPass string + AdminHost string + AdminPort int + AdminUser string + AdminPassword string + MySQLPort int + DataDir string + Backends []BackendServer + MonitorUser string + MonitorPass string + BackendProvider string } func GenerateConfig(cfg ProxySQLConfig) string { @@ -34,16 +35,33 @@ func GenerateConfig(cfg ProxySQLConfig) string { 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") + isPgsql := cfg.BackendProvider == "postgresql" + + if isPgsql { + b.WriteString("pgsql_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("}\n\n") + } else { + 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") + } + + serversKey := "mysql_servers" + usersKey := "mysql_users" + if isPgsql { + serversKey = "pgsql_servers" + usersKey = "pgsql_users" + } if len(cfg.Backends) > 0 { - b.WriteString("mysql_servers=\n(\n") + b.WriteString(fmt.Sprintf("%s=\n(\n", serversKey)) for i, srv := range cfg.Backends { b.WriteString(" {\n") b.WriteString(fmt.Sprintf(" address=\"%s\"\n", srv.Host)) @@ -63,7 +81,7 @@ func GenerateConfig(cfg ProxySQLConfig) string { b.WriteString(")\n\n") } - b.WriteString("mysql_users=\n(\n") + b.WriteString(fmt.Sprintf("%s=\n(\n", usersKey)) b.WriteString(" {\n") b.WriteString(fmt.Sprintf(" username=\"%s\"\n", cfg.MonitorUser)) b.WriteString(fmt.Sprintf(" password=\"%s\"\n", cfg.MonitorPass)) diff --git a/providers/proxysql/config_test.go b/providers/proxysql/config_test.go index b38f1d1..e212672 100644 --- a/providers/proxysql/config_test.go +++ b/providers/proxysql/config_test.go @@ -48,3 +48,63 @@ func TestGenerateConfigWithBackends(t *testing.T) { t.Error("missing reader hostgroup") } } + +func TestGenerateConfigMySQL(t *testing.T) { + cfg := ProxySQLConfig{ + AdminHost: "127.0.0.1", + AdminPort: 6032, + AdminUser: "admin", + AdminPassword: "admin", + MySQLPort: 6033, + DataDir: "/tmp/proxysql/data", + MonitorUser: "msandbox", + MonitorPass: "msandbox", + Backends: []BackendServer{ + {Host: "127.0.0.1", Port: 3306, Hostgroup: 0, MaxConns: 200}, + }, + } + config := GenerateConfig(cfg) + if !strings.Contains(config, "mysql_servers") { + t.Error("expected mysql_servers block") + } + if !strings.Contains(config, "mysql_variables") { + t.Error("expected mysql_variables block") + } + if !strings.Contains(config, "mysql_users") { + t.Error("expected mysql_users block") + } +} + +func TestGenerateConfigPostgreSQL(t *testing.T) { + cfg := ProxySQLConfig{ + AdminHost: "127.0.0.1", + AdminPort: 6032, + AdminUser: "admin", + AdminPassword: "admin", + MySQLPort: 6033, + DataDir: "/tmp/proxysql/data", + MonitorUser: "postgres", + MonitorPass: "postgres", + BackendProvider: "postgresql", + Backends: []BackendServer{ + {Host: "127.0.0.1", Port: 16613, Hostgroup: 0, MaxConns: 200}, + {Host: "127.0.0.1", Port: 16614, Hostgroup: 1, MaxConns: 200}, + }, + } + config := GenerateConfig(cfg) + if !strings.Contains(config, "pgsql_servers") { + t.Error("expected pgsql_servers block") + } + if !strings.Contains(config, "pgsql_users") { + t.Error("expected pgsql_users block") + } + if !strings.Contains(config, "pgsql_variables") { + t.Error("expected pgsql_variables block") + } + if strings.Contains(config, "mysql_servers") { + t.Error("should not contain mysql_servers for postgresql backend") + } + if strings.Contains(config, "mysql_variables") { + t.Error("should not contain mysql_variables for postgresql backend") + } +} diff --git a/providers/proxysql/proxysql.go b/providers/proxysql/proxysql.go index e4374b7..d8806be 100644 --- a/providers/proxysql/proxysql.go +++ b/providers/proxysql/proxysql.go @@ -80,15 +80,16 @@ func (p *ProxySQLProvider) CreateSandbox(config providers.SandboxConfig) (*provi } proxyCfg := ProxySQLConfig{ - AdminHost: host, - AdminPort: adminPort, - AdminUser: adminUser, - AdminPassword: adminPassword, - MySQLPort: mysqlPort, - DataDir: dataDir, - MonitorUser: monitorUser, - MonitorPass: monitorPass, - Backends: parseBackends(config.Options), + AdminHost: host, + AdminPort: adminPort, + AdminUser: adminUser, + AdminPassword: adminPassword, + MySQLPort: mysqlPort, + DataDir: dataDir, + MonitorUser: monitorUser, + MonitorPass: monitorPass, + Backends: parseBackends(config.Options), + BackendProvider: config.Options["backend_provider"], } cfgContent := GenerateConfig(proxyCfg) @@ -110,8 +111,13 @@ func (p *ProxySQLProvider) CreateSandbox(config providers.SandboxConfig) (*provi pidFile), "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), + } + if config.Options["backend_provider"] == "postgresql" { + scripts["use_proxy"] = fmt.Sprintf("#!/bin/bash\npsql -h %s -p %d -U %s \"$@\"\n", + host, mysqlPort, monitorUser) + } else { + scripts["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 { From 768d9c4558b54f732d9fdaa80d8854f4bb5a4ed4 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:21:49 +0000 Subject: [PATCH 10/16] feat: add cross-database topology constraint validation Add tests for ContainsString, CompatibleAddons, and SupportedTopologies to verify provider compatibility constraints across database types. --- providers/provider_test.go | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/providers/provider_test.go b/providers/provider_test.go index 81f48d2..044dd6e 100644 --- a/providers/provider_test.go +++ b/providers/provider_test.go @@ -77,3 +77,42 @@ func TestRegistryList(t *testing.T) { t.Errorf("expected sorted [a b], got %v", names) } } + +func TestContainsString(t *testing.T) { + slice := []string{"single", "multiple", "replication"} + if !ContainsString(slice, "single") { + t.Error("expected single to be found") + } + if ContainsString(slice, "group") { + t.Error("did not expect group to be found") + } + if ContainsString(nil, "single") { + t.Error("did not expect match in nil slice") + } +} + +func TestCompatibleAddons(t *testing.T) { + if !ContainsString(CompatibleAddons["proxysql"], "mysql") { + t.Error("proxysql should be compatible with mysql") + } + if !ContainsString(CompatibleAddons["proxysql"], "postgresql") { + t.Error("proxysql should be compatible with postgresql") + } + if ContainsString(CompatibleAddons["proxysql"], "fake") { + t.Error("proxysql should not be compatible with fake") + } +} + +func TestSupportedTopologiesMySQL(t *testing.T) { + reg := NewRegistry() + mock := &mockProvider{name: "mysql-like"} + _ = reg.Register(mock) + p, _ := reg.Get("mysql-like") + topos := p.SupportedTopologies() + if !ContainsString(topos, "single") { + t.Error("expected single in topologies") + } + if ContainsString(topos, "group") { + t.Error("mock should not support group") + } +} From 99dd2b909e34c54c4ff1e6c62190e4888586e2e3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:22:40 +0000 Subject: [PATCH 11/16] feat: add 'dbdeployer deploy postgresql' standalone command Adds the deploy postgresql subcommand following the deploy proxysql pattern. Supports version validation, binary discovery, port allocation, sandbox creation, and optional skip-start flag. --- cmd/deploy_postgresql.go | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 cmd/deploy_postgresql.go diff --git a/cmd/deploy_postgresql.go b/cmd/deploy_postgresql.go new file mode 100644 index 0000000..ee49d6f --- /dev/null +++ b/cmd/deploy_postgresql.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "fmt" + "path" + + "github.com/ProxySQL/dbdeployer/common" + "github.com/ProxySQL/dbdeployer/defaults" + "github.com/ProxySQL/dbdeployer/providers" + "github.com/ProxySQL/dbdeployer/providers/postgresql" + "github.com/spf13/cobra" +) + +func deploySandboxPostgreSQL(cmd *cobra.Command, args []string) { + version := args[0] + flags := cmd.Flags() + skipStart, _ := flags.GetBool("skip-start") + + p, err := providers.DefaultRegistry.Get("postgresql") + if err != nil { + common.Exitf(1, "PostgreSQL provider not available: %s", err) + } + + if err := p.ValidateVersion(version); err != nil { + common.Exitf(1, "invalid version: %s", err) + } + + if _, err := p.FindBinary(version); err != nil { + common.Exitf(1, "PostgreSQL binaries not found: %s\nRun: dbdeployer unpack --provider=postgresql ", err) + } + + port, err := postgresql.VersionToPort(version) + if err != nil { + common.Exitf(1, "error computing port: %s", err) + } + freePort, portErr := common.FindFreePort(port, []int{}, 1) + if portErr == nil { + port = freePort + } + + sandboxHome := defaults.Defaults().SandboxHome + sandboxDir := path.Join(sandboxHome, fmt.Sprintf("pg_sandbox_%d", port)) + + if common.DirExists(sandboxDir) { + common.Exitf(1, "sandbox directory %s already exists", sandboxDir) + } + + config := providers.SandboxConfig{ + Version: version, + Dir: sandboxDir, + Port: port, + Host: "127.0.0.1", + DbUser: "postgres", + DbPassword: "", + Options: map[string]string{}, + } + + if _, err := p.CreateSandbox(config); err != nil { + common.Exitf(1, "error creating PostgreSQL sandbox: %s", err) + } + + if !skipStart { + if err := p.StartSandbox(sandboxDir); err != nil { + common.Exitf(1, "error starting PostgreSQL: %s", err) + } + } + + fmt.Printf("PostgreSQL %s sandbox deployed in %s (port: %d)\n", version, sandboxDir, port) +} + +var deployPostgreSQLCmd = &cobra.Command{ + Use: "postgresql version", + Short: "deploys a PostgreSQL sandbox", + Long: `postgresql deploys a standalone PostgreSQL instance as a sandbox. +It creates a sandbox directory with data, configuration, start/stop scripts, and a +psql client script. + +Requires PostgreSQL binaries to be extracted first: + dbdeployer unpack --provider=postgresql postgresql-16_*.deb postgresql-client-16_*.deb + +Example: + dbdeployer deploy postgresql 16.13 + dbdeployer deploy postgresql 17.1 --skip-start +`, + Args: cobra.ExactArgs(1), + Run: deploySandboxPostgreSQL, +} + +func init() { + deployCmd.AddCommand(deployPostgreSQLCmd) + deployPostgreSQLCmd.Flags().Bool("skip-start", false, "Do not start PostgreSQL after deployment") +} From 4eb2fd3f5974843a918f0133f46997b883a778d6 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:23:39 +0000 Subject: [PATCH 12/16] test: add PostgreSQL integration tests (build-tagged) Add build-tagged integration tests for single sandbox and replication topologies. Tests require real PostgreSQL binaries in ~/opt/postgresql/ and are excluded from normal go test runs via //go:build integration. --- providers/postgresql/integration_test.go | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 providers/postgresql/integration_test.go diff --git a/providers/postgresql/integration_test.go b/providers/postgresql/integration_test.go new file mode 100644 index 0000000..5da81c6 --- /dev/null +++ b/providers/postgresql/integration_test.go @@ -0,0 +1,160 @@ +//go:build integration + +package postgresql + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/ProxySQL/dbdeployer/providers" +) + +func findPostgresVersion(t *testing.T) string { + t.Helper() + home, _ := os.UserHomeDir() + entries, err := os.ReadDir(filepath.Join(home, "opt", "postgresql")) + if err != nil { + t.Skipf("no PostgreSQL installations found: %v", err) + } + for _, e := range entries { + if e.IsDir() { + return e.Name() + } + } + t.Skip("no PostgreSQL version directories found") + return "" +} + +func TestIntegrationSingleSandbox(t *testing.T) { + version := findPostgresVersion(t) + p := NewPostgreSQLProvider() + + tmpDir := t.TempDir() + sandboxDir := filepath.Join(tmpDir, "pg_test") + + config := providers.SandboxConfig{ + Version: version, + Dir: sandboxDir, + Port: 15432, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{}, + } + + // Create + info, err := p.CreateSandbox(config) + if err != nil { + t.Fatalf("CreateSandbox failed: %v", err) + } + if info.Port != 15432 { + t.Errorf("expected port 15432, got %d", info.Port) + } + + // Start + if err := p.StartSandbox(sandboxDir); err != nil { + t.Fatalf("StartSandbox failed: %v", err) + } + stopped := false + defer func() { + if !stopped { + p.StopSandbox(sandboxDir) + } + }() + time.Sleep(2 * time.Second) + + // Connect via psql + home, _ := os.UserHomeDir() + psql := filepath.Join(home, "opt", "postgresql", version, "bin", "psql") + cmd := exec.Command(psql, "-h", "127.0.0.1", "-p", "15432", "-U", "postgres", "-c", "SELECT 1;") + cmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", + filepath.Join(home, "opt", "postgresql", version, "lib"))) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("psql connection failed: %s: %v", string(output), err) + } + + // Stop + if err := p.StopSandbox(sandboxDir); err != nil { + t.Fatalf("StopSandbox failed: %v", err) + } + stopped = true +} + +func TestIntegrationReplication(t *testing.T) { + version := findPostgresVersion(t) + p := NewPostgreSQLProvider() + + tmpDir := t.TempDir() + primaryDir := filepath.Join(tmpDir, "primary") + replica1Dir := filepath.Join(tmpDir, "replica1") + replica2Dir := filepath.Join(tmpDir, "replica2") + + // Create and start primary with replication + primaryConfig := providers.SandboxConfig{ + Version: version, + Dir: primaryDir, + Port: 15500, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{"replication": "true"}, + } + + _, err := p.CreateSandbox(primaryConfig) + if err != nil { + t.Fatalf("CreateSandbox (primary) failed: %v", err) + } + if err := p.StartSandbox(primaryDir); err != nil { + t.Fatalf("StartSandbox (primary) failed: %v", err) + } + defer p.StopSandbox(primaryDir) + time.Sleep(2 * time.Second) + + primaryInfo := providers.SandboxInfo{Dir: primaryDir, Port: 15500} + + // Create replicas + for i, rDir := range []string{replica1Dir, replica2Dir} { + rConfig := providers.SandboxConfig{ + Version: version, + Dir: rDir, + Port: 15501 + i, + Host: "127.0.0.1", + DbUser: "postgres", + Options: map[string]string{}, + } + _, err := p.CreateReplica(primaryInfo, rConfig) + if err != nil { + t.Fatalf("CreateReplica %d failed: %v", i+1, err) + } + defer p.StopSandbox(rDir) + } + + time.Sleep(2 * time.Second) + + // Verify replication on primary + home, _ := os.UserHomeDir() + psql := filepath.Join(home, "opt", "postgresql", version, "bin", "psql") + libDir := filepath.Join(home, "opt", "postgresql", version, "lib") + + cmd := exec.Command(psql, "-h", "127.0.0.1", "-p", "15500", "-U", "postgres", "-t", "-c", + "SELECT count(*) FROM pg_stat_replication;") + cmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", libDir)) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("replication check failed: %s: %v", string(output), err) + } + + // Verify replicas are in recovery + for _, port := range []int{15501, 15502} { + cmd := exec.Command(psql, "-h", "127.0.0.1", "-p", fmt.Sprintf("%d", port), "-U", "postgres", "-t", "-c", + "SELECT pg_is_in_recovery();") + cmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", libDir)) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("recovery check on port %d failed: %s: %v", port, string(output), err) + } + } +} From 9f7411759c3eeb201d49b6ca61d9272d8941856b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 24 Mar 2026 16:26:33 +0000 Subject: [PATCH 13/16] fix: update export test for new postgresql deploy subcommand The TestExportImport test checks the command tree structure. Adding the 'deploy postgresql' subcommand increased deploy's subcommand count from 4 to 5. --- cmd/export_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/export_test.go b/cmd/export_test.go index e1bad06..449fff7 100644 --- a/cmd/export_test.go +++ b/cmd/export_test.go @@ -165,7 +165,7 @@ func TestExportImport(t *testing.T) { subCommandName: "", expectedName: "deploy", expectedAncestors: 2, - expectedSubCommands: 4, + expectedSubCommands: 5, expectedArgument: "", }, { @@ -192,6 +192,14 @@ func TestExportImport(t *testing.T) { expectedSubCommands: 0, expectedArgument: globals.ExportVersionDir, }, + { + commandName: "deploy", + subCommandName: "postgresql", + expectedName: "postgresql", + expectedAncestors: 3, + expectedSubCommands: 0, + expectedArgument: "", + }, { commandName: "export", subCommandName: "", From 00451ca68d82b5c1702635bf46e92bae914da592 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 25 Mar 2026 02:06:50 +0000 Subject: [PATCH 14/16] fix: pass -L share dir to initdb for deb-extracted binaries Deb-packaged initdb looks for share data at its compiled --prefix (/usr/share/postgresql/), not relative to the binary. Use -L to explicitly point to our extracted share directory. --- providers/postgresql/sandbox.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/providers/postgresql/sandbox.go b/providers/postgresql/sandbox.go index df28b26..b026acd 100644 --- a/providers/postgresql/sandbox.go +++ b/providers/postgresql/sandbox.go @@ -28,8 +28,12 @@ func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*pro } // Run initdb + // Use -L to point to our extracted share directory. Deb-packaged initdb + // looks for share data relative to its compiled --prefix (/usr), which + // won't match our extracted layout at ~/opt/postgresql//share/. + shareDir := filepath.Join(basedir, "share") initdbPath := filepath.Join(binDir, "initdb") - initCmd := exec.Command(initdbPath, "-D", dataDir, "--auth=trust", "--username=postgres") + initCmd := exec.Command(initdbPath, "-D", dataDir, "--auth=trust", "--username=postgres", "-L", shareDir) initCmd.Env = append(os.Environ(), fmt.Sprintf("LD_LIBRARY_PATH=%s", libDir)) if output, err := initCmd.CombinedOutput(); err != nil { os.RemoveAll(config.Dir) // cleanup on failure From d6ec6aaceaaeda4756cdc24dac3ca5f33745d3d8 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 25 Mar 2026 02:09:38 +0000 Subject: [PATCH 15/16] fix: create log dir after initdb (initdb requires empty data dir) --- providers/postgresql/sandbox.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/providers/postgresql/sandbox.go b/providers/postgresql/sandbox.go index b026acd..61fa6b1 100644 --- a/providers/postgresql/sandbox.go +++ b/providers/postgresql/sandbox.go @@ -22,12 +22,7 @@ func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*pro replication := config.Options["replication"] == "true" - // Create log directory - if err := os.MkdirAll(logDir, 0755); err != nil { - return nil, fmt.Errorf("creating log directory: %w", err) - } - - // Run initdb + // Run initdb (data dir must not exist or must be empty) // Use -L to point to our extracted share directory. Deb-packaged initdb // looks for share data relative to its compiled --prefix (/usr), which // won't match our extracted layout at ~/opt/postgresql//share/. @@ -40,6 +35,12 @@ func (p *PostgreSQLProvider) CreateSandbox(config providers.SandboxConfig) (*pro return nil, fmt.Errorf("initdb failed: %s: %w", string(output), err) } + // Create log directory (after initdb, which requires empty data dir) + if err := os.MkdirAll(logDir, 0755); err != nil { + os.RemoveAll(config.Dir) + return nil, fmt.Errorf("creating log directory: %w", err) + } + // Generate and write postgresql.conf pgConf := GeneratePostgresqlConf(PostgresqlConfOptions{ Port: config.Port, From cadfe55151ec27ea060433b326a20ac426d7d1de Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Wed, 25 Mar 2026 02:12:58 +0000 Subject: [PATCH 16/16] fix: copy share files to compat path for postgres binary (timezonesets) Deb-built postgres looks for share data at ../share/postgresql// relative to its binary. Copy extracted share files to both share/ and share/postgresql// so both initdb and postgres can find them. --- providers/postgresql/unpack.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/providers/postgresql/unpack.go b/providers/postgresql/unpack.go index 33b0542..6521ad5 100644 --- a/providers/postgresql/unpack.go +++ b/providers/postgresql/unpack.go @@ -91,6 +91,19 @@ func UnpackDebs(serverDeb, clientDeb, targetDir string) error { } } + // The postgres binary resolves share data relative to its own binary as + // ../share/postgresql// (compiled-in prefix from deb packaging). + // Copy share files there too so both initdb (-L share/) and the postgres + // server binary can find timezonesets and other share data. + pgShareCompat := filepath.Join(dstShare, "postgresql", major) + if err := os.MkdirAll(pgShareCompat, 0755); err != nil { + return fmt.Errorf("creating compat share dir: %w", err) + } + compatCmd := exec.Command("cp", "-a", srcShare+"/.", pgShareCompat+"/") + if output, err := compatCmd.CombinedOutput(); err != nil { + return fmt.Errorf("copying share to compat path: %s: %w", string(output), err) + } + for _, bin := range RequiredBinaries() { binPath := filepath.Join(dstBin, bin) if _, err := os.Stat(binPath); err != nil {