From 695fd32fe1d6e78fc4612bb8e9b2ad40911f0c8f Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 22:56:31 +0530 Subject: [PATCH 01/14] feat(driver/_mysqlcommon): shared DSN/args/version/conn helpers - Add DSN builder, mysqldump/mysql arg builders, fork detection, and binary-parameterized Backup/Restore subprocess runners. - Mirror the postgres stderr-discard pattern (no StderrPipe race); pass password via MYSQL_PWD env. - Add go-sql-driver/mysql; underscore dir stays out of go build ./.... --- go.mod | 2 + go.sum | 4 + internal/driver/_mysqlcommon/args.go | 49 ++++++++++++ internal/driver/_mysqlcommon/args_test.go | 81 ++++++++++++++++++++ internal/driver/_mysqlcommon/conn.go | 68 ++++++++++++++++ internal/driver/_mysqlcommon/dsn.go | 29 +++++++ internal/driver/_mysqlcommon/version.go | 31 ++++++++ internal/driver/_mysqlcommon/version_test.go | 14 ++++ 8 files changed, 278 insertions(+) create mode 100644 internal/driver/_mysqlcommon/args.go create mode 100644 internal/driver/_mysqlcommon/args_test.go create mode 100644 internal/driver/_mysqlcommon/conn.go create mode 100644 internal/driver/_mysqlcommon/dsn.go create mode 100644 internal/driver/_mysqlcommon/version.go create mode 100644 internal/driver/_mysqlcommon/version_test.go diff --git a/go.mod b/go.mod index ecdc759..e608e28 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -52,6 +53,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0f6ea90..1f74082 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -95,6 +97,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/internal/driver/_mysqlcommon/args.go b/internal/driver/_mysqlcommon/args.go new file mode 100644 index 0000000..e5727f7 --- /dev/null +++ b/internal/driver/_mysqlcommon/args.go @@ -0,0 +1,49 @@ +package mysqlcommon + +import ( + "strconv" + + "github.com/nixrajput/siphon/internal/driver" +) + +// BuildDumpArgs assembles the mysqldump argument vector for a backup. The +// binary name itself is supplied by the caller (mysqldump vs mariadb-dump). +func BuildDumpArgs(p driver.Profile, opt driver.BackupOpts) []string { + args := []string{ + "-h", p.Host, + "-P", strconv.Itoa(p.Port), + "-u", p.User, + "--single-transaction", + "--routines", + "--triggers", + "--events", + "--no-tablespaces", + "--skip-comments", + p.Database, + } + if opt.SchemaOnly { + args = append(args, "--no-data") + } + if opt.DataOnly { + args = append(args, "--no-create-info") + } + args = append(args, opt.IncludeTables...) + for _, t := range opt.ExcludeTables { + args = append(args, "--ignore-table="+p.Database+"."+t) + } + return args +} + +// BuildRestoreArgs assembles the mysql client argument vector for a restore. +// The dump file is authoritative for shape; the restore client just pipes it +// in. Clean is a no-op here because mysqldump output already emits +// DROP TABLE IF EXISTS / CREATE TABLE, making the restore idempotent. +func BuildRestoreArgs(p driver.Profile, _ driver.RestoreOpts) []string { + return []string{ + "-h", p.Host, + "-P", strconv.Itoa(p.Port), + "-u", p.User, + "--default-character-set=utf8mb4", + p.Database, + } +} diff --git a/internal/driver/_mysqlcommon/args_test.go b/internal/driver/_mysqlcommon/args_test.go new file mode 100644 index 0000000..95bf85e --- /dev/null +++ b/internal/driver/_mysqlcommon/args_test.go @@ -0,0 +1,81 @@ +package mysqlcommon + +import ( + "reflect" + "testing" + + "github.com/nixrajput/siphon/internal/driver" +) + +func TestBuildDumpArgs_Defaults(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Database: "shop"} + got := BuildDumpArgs(p, driver.BackupOpts{}) + want := []string{ + "-h", "db.local", + "-P", "3306", + "-u", "root", + "--single-transaction", + "--routines", + "--triggers", + "--events", + "--no-tablespaces", + "--skip-comments", + "shop", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildDumpArgs() = %v\nwant %v", got, want) + } +} + +func TestBuildDumpArgs_Tables(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Database: "shop"} + opt := driver.BackupOpts{ + SchemaOnly: true, + IncludeTables: []string{"orders", "items"}, + ExcludeTables: []string{"sessions"}, + } + got := BuildDumpArgs(p, opt) + want := []string{ + "-h", "db.local", + "-P", "3306", + "-u", "root", + "--single-transaction", + "--routines", + "--triggers", + "--events", + "--no-tablespaces", + "--skip-comments", + "shop", + "--no-data", + "orders", "items", + "--ignore-table=shop.sessions", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildDumpArgs() = %v\nwant %v", got, want) + } +} + +func TestDSN(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Password: "pw", Database: "shop"} + got := DSN(p) + want := "root:pw@tcp(db.local:3306)/shop?parseTime=true&tls=preferred" + if got != want { + t.Fatalf("DSN() = %q\nwant %q", got, want) + } +} + +func TestTLSParam(t *testing.T) { + cases := map[string]string{ + "disable": "false", + "require": "true", + "verify-ca": "true", + "verify-full": "true", + "": "preferred", + "prefer": "preferred", + } + for mode, want := range cases { + if got := tlsParam(mode); got != want { + t.Errorf("tlsParam(%q) = %q; want %q", mode, got, want) + } + } +} diff --git a/internal/driver/_mysqlcommon/conn.go b/internal/driver/_mysqlcommon/conn.go new file mode 100644 index 0000000..e715fa5 --- /dev/null +++ b/internal/driver/_mysqlcommon/conn.go @@ -0,0 +1,68 @@ +package mysqlcommon + +import ( + "context" + "database/sql" + "errors" + "io" + "os" + "os/exec" + + "github.com/nixrajput/siphon/internal/driver" + "github.com/nixrajput/siphon/internal/errs" + + _ "github.com/go-sql-driver/mysql" // register "mysql" as a database/sql driver +) + +// Open returns a database/sql handle for the profile. It does not probe the +// connection; callers ping as needed. +func Open(p driver.Profile) (*sql.DB, error) { + return sql.Open("mysql", DSN(p)) +} + +// BackupWith spawns the given dump binary (mysqldump or mariadb-dump) and +// streams its stdout to w. ctx cancellation propagates via exec.CommandContext. +func BackupWith(ctx context.Context, binary string, p driver.Profile, opt driver.BackupOpts, w io.Writer) error { + cmd := exec.CommandContext(ctx, binary, BuildDumpArgs(p, opt)...) + cmd.Env = append(os.Environ(), "MYSQL_PWD="+p.Password) + cmd.Stdout = w + // Discard stderr directly. StderrPipe + a drain goroutine would race with + // cmd.Wait() (Wait closes the pipe once the process exits). Phase F will + // wire stderr through progress parsing for all drivers; until then, + // discarding is safe. + cmd.Stderr = io.Discard + + if err := cmd.Run(); err != nil { + return toolErr(binary, binary+".backup", err) + } + return nil +} + +// RestoreWith spawns the given client binary (mysql or mariadb) and pipes r +// into its stdin. ctx cancellation propagates via exec.CommandContext. +func RestoreWith(ctx context.Context, binary string, p driver.Profile, opt driver.RestoreOpts, r io.Reader) error { + cmd := exec.CommandContext(ctx, binary, BuildRestoreArgs(p, opt)...) + cmd.Env = append(os.Environ(), "MYSQL_PWD="+p.Password) + cmd.Stdin = r + cmd.Stderr = io.Discard // don't leak client diagnostics to the user's terminal; Phase F captures these + + if err := cmd.Run(); err != nil { + return toolErr(binary, binary+".restore", err) + } + return nil +} + +// toolErr maps a missing binary to errs.ErrToolMissing and any other failure +// to a plain system error. +func toolErr(binary, op string, err error) *errs.Error { + var execErr *exec.Error + if errors.As(err, &execErr) && errors.Is(execErr.Err, exec.ErrNotFound) { + return &errs.Error{ + Op: op, + Code: errs.CodeSystem, + Cause: errs.ErrToolMissing, + Hint: "install the " + binary + " client", + } + } + return &errs.Error{Op: op, Code: errs.CodeSystem, Cause: err} +} diff --git a/internal/driver/_mysqlcommon/dsn.go b/internal/driver/_mysqlcommon/dsn.go new file mode 100644 index 0000000..63639a3 --- /dev/null +++ b/internal/driver/_mysqlcommon/dsn.go @@ -0,0 +1,29 @@ +// Package mysqlcommon holds the shared implementation between the MySQL and +// MariaDB drivers (forks with near-identical tooling). The underscore-prefixed +// directory keeps it out of "go build ./..." while remaining importable by the +// sibling driver packages. +package mysqlcommon + +import ( + "fmt" + + "github.com/nixrajput/siphon/internal/driver" +) + +// DSN builds a go-sql-driver/mysql connection string: +// user:pass@tcp(host:port)/db?parseTime=true&tls= +func DSN(p driver.Profile) string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&tls=%s", + p.User, p.Password, p.Host, p.Port, p.Database, tlsParam(p.SSLMode)) +} + +func tlsParam(mode string) string { + switch mode { + case "require", "verify-ca", "verify-full": + return "true" + case "disable": + return "false" + default: + return "preferred" + } +} diff --git a/internal/driver/_mysqlcommon/version.go b/internal/driver/_mysqlcommon/version.go new file mode 100644 index 0000000..0c35e5e --- /dev/null +++ b/internal/driver/_mysqlcommon/version.go @@ -0,0 +1,31 @@ +package mysqlcommon + +import ( + "context" + "database/sql" + "strings" +) + +// Fork identifies which MySQL-family engine a connection is talking to. +type Fork int + +const ( + ForkUnknown Fork = iota + ForkMySQL + ForkMariaDB +) + +// DetectFork queries SELECT VERSION() and classifies the server as MySQL or +// MariaDB. MariaDB embeds "mariadb" in its version string; everything else +// that responds is treated as MySQL. Exported for cross-driver use even where +// callers don't yet branch on the result. +func DetectFork(ctx context.Context, db *sql.DB) (Fork, string, error) { + var version string + if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil { + return ForkUnknown, "", err + } + if strings.Contains(strings.ToLower(version), "mariadb") { + return ForkMariaDB, version, nil + } + return ForkMySQL, version, nil +} diff --git a/internal/driver/_mysqlcommon/version_test.go b/internal/driver/_mysqlcommon/version_test.go new file mode 100644 index 0000000..a263bd3 --- /dev/null +++ b/internal/driver/_mysqlcommon/version_test.go @@ -0,0 +1,14 @@ +package mysqlcommon + +import "testing" + +func TestForkValuesAreDistinct(t *testing.T) { + forks := []Fork{ForkUnknown, ForkMySQL, ForkMariaDB} + seen := make(map[Fork]bool, len(forks)) + for _, f := range forks { + if seen[f] { + t.Fatalf("duplicate Fork value: %d", f) + } + seen[f] = true + } +} From 9080ba49b9233d8f5c3ce7a292515d1ffa5117be Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:09:49 +0530 Subject: [PATCH 02/14] feat(driver/_mysqlcommon): shared Conn implementing driver.Conn for both forks --- internal/driver/_mysqlcommon/args_test.go | 44 ++++++++++ internal/driver/_mysqlcommon/conn.go | 102 ++++++++++++++++++++++ internal/driver/_mysqlcommon/dsn.go | 5 ++ 3 files changed, 151 insertions(+) diff --git a/internal/driver/_mysqlcommon/args_test.go b/internal/driver/_mysqlcommon/args_test.go index 95bf85e..d9bc94c 100644 --- a/internal/driver/_mysqlcommon/args_test.go +++ b/internal/driver/_mysqlcommon/args_test.go @@ -55,6 +55,50 @@ func TestBuildDumpArgs_Tables(t *testing.T) { } } +func TestBuildDumpArgs_DataOnly(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Database: "shop"} + got := BuildDumpArgs(p, driver.BackupOpts{DataOnly: true}) + want := []string{ + "-h", "db.local", + "-P", "3306", + "-u", "root", + "--single-transaction", + "--routines", + "--triggers", + "--events", + "--no-tablespaces", + "--skip-comments", + "shop", + "--no-create-info", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildDumpArgs() = %v\nwant %v", got, want) + } +} + +func TestBuildDumpArgs_MultiExclude(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Database: "shop"} + opt := driver.BackupOpts{ExcludeTables: []string{"sessions", "cache"}} + got := BuildDumpArgs(p, opt) + want := []string{ + "-h", "db.local", + "-P", "3306", + "-u", "root", + "--single-transaction", + "--routines", + "--triggers", + "--events", + "--no-tablespaces", + "--skip-comments", + "shop", + "--ignore-table=shop.sessions", + "--ignore-table=shop.cache", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildDumpArgs() = %v\nwant %v", got, want) + } +} + func TestDSN(t *testing.T) { p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Password: "pw", Database: "shop"} got := DSN(p) diff --git a/internal/driver/_mysqlcommon/conn.go b/internal/driver/_mysqlcommon/conn.go index e715fa5..f60ef85 100644 --- a/internal/driver/_mysqlcommon/conn.go +++ b/internal/driver/_mysqlcommon/conn.go @@ -2,14 +2,18 @@ package mysqlcommon import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "errors" "io" "os" "os/exec" + "time" "github.com/nixrajput/siphon/internal/driver" "github.com/nixrajput/siphon/internal/errs" + "github.com/nixrajput/siphon/internal/jobs" _ "github.com/go-sql-driver/mysql" // register "mysql" as a database/sql driver ) @@ -20,6 +24,104 @@ func Open(p driver.Profile) (*sql.DB, error) { return sql.Open("mysql", DSN(p)) } +// Conn is the shared MySQL/MariaDB connection. The only per-fork difference is +// the dump/client binary names, injected at construction, so both drivers reuse +// this single implementation of the driver.Conn contract. +type Conn struct { + db *sql.DB + p driver.Profile + dumpBinary string // "mysqldump" or "mariadb-dump" + clientBinary string // "mysql" or "mariadb" +} + +var _ driver.Conn = (*Conn)(nil) + +// NewConn opens + pings the database and returns a ready Conn. The ping is +// wrapped in a bounded retry (jobs.Retry, 3 attempts) — same policy as the +// Postgres driver (spec §4.3). connOp is the error-wrapping op label, e.g. +// "mysql.connect" / "mariadb.connect", so errors name the right driver. +func NewConn(ctx context.Context, p driver.Profile, dumpBinary, clientBinary, connOp string) (*Conn, error) { + db, err := Open(p) + if err != nil { + return nil, connErr(connOp, err) + } + if err := jobs.Retry(ctx, 3, func() error { return db.PingContext(ctx) }); err != nil { + _ = db.Close() + return nil, connErr(connOp, err) + } + return &Conn{db: db, p: p, dumpBinary: dumpBinary, clientBinary: clientBinary}, nil +} + +// connErr wraps a connection failure in the shape the harness asserts on +// (errors.Is(err, errs.ErrConnectionFailed)), matching the postgres driver. +func connErr(op string, err error) error { + return &errs.Error{ + Op: op, + Code: errs.CodeSystem, + Cause: errs.ErrConnectionFailed, + Hint: err.Error(), + } +} + +// Close releases the underlying connection pool. +func (c *Conn) Close() error { return c.db.Close() } + +// inspectQuery lists tables in the target schema with row estimates and +// on-disk size (data + index). TABLE_ROWS is an estimate for InnoDB and NULL +// for some engines/views; IFNULL clamps the nullable columns to 0. +const inspectQuery = ` +SELECT TABLE_NAME, + IFNULL(TABLE_ROWS, 0), + IFNULL(DATA_LENGTH + INDEX_LENGTH, 0) +FROM information_schema.tables +WHERE TABLE_SCHEMA = ? +ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC +` + +func (c *Conn) Inspect(ctx context.Context) (*driver.Schema, error) { + rows, err := c.db.QueryContext(ctx, inspectQuery, c.p.Database) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := &driver.Schema{} + for rows.Next() { + var t driver.TableMeta + if err := rows.Scan(&t.Name, &t.Rows, &t.SizeBytes); err != nil { + return nil, err + } + out.Tables = append(out.Tables, t) + } + return out, rows.Err() +} + +// Backup streams a dump of the database to w via the fork's dump binary. +func (c *Conn) Backup(ctx context.Context, opt driver.BackupOpts, w io.Writer) error { + return BackupWith(ctx, c.dumpBinary, c.p, opt, w) +} + +// Restore pipes r into the fork's client binary. +func (c *Conn) Restore(ctx context.Context, opt driver.RestoreOpts, r io.Reader) error { + return RestoreWith(ctx, c.clientBinary, c.p, opt, r) +} + +// Verify performs a checksum-only check on the dump stream. Header-format +// checks land in Phase F when the siphon envelope exists. +func (c *Conn) Verify(_ context.Context, r io.Reader) (*driver.VerifyReport, error) { + started := time.Now() + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return nil, err + } + return &driver.VerifyReport{ + Checksum: "sha256:" + hex.EncodeToString(h.Sum(nil)), + OK: true, + Started: started, + Finished: time.Now(), + }, nil +} + // BackupWith spawns the given dump binary (mysqldump or mariadb-dump) and // streams its stdout to w. ctx cancellation propagates via exec.CommandContext. func BackupWith(ctx context.Context, binary string, p driver.Profile, opt driver.BackupOpts, w io.Writer) error { diff --git a/internal/driver/_mysqlcommon/dsn.go b/internal/driver/_mysqlcommon/dsn.go index 63639a3..03f3a85 100644 --- a/internal/driver/_mysqlcommon/dsn.go +++ b/internal/driver/_mysqlcommon/dsn.go @@ -12,6 +12,11 @@ import ( // DSN builds a go-sql-driver/mysql connection string: // user:pass@tcp(host:port)/db?parseTime=true&tls= +// +// This trusts p.User to be free of ':' — the go-sql-driver DSN format splits +// user:pass on the first ':', so passwords need no escaping (the library +// tolerates them), but a ':' in the username would corrupt parsing. MySQL +// usernames don't contain ':' in practice. func DSN(p driver.Profile) string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&tls=%s", p.User, p.Password, p.Host, p.Port, p.Database, tlsParam(p.SSLMode)) From 10def9364f901431088bdb6b9cc47be6963c568b Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:11:24 +0530 Subject: [PATCH 03/14] feat(driver/mysql): MySQL driver via shared mysqlcommon.Conn --- internal/driver/mysql/driver.go | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/driver/mysql/driver.go diff --git a/internal/driver/mysql/driver.go b/internal/driver/mysql/driver.go new file mode 100644 index 0000000..68e6e6f --- /dev/null +++ b/internal/driver/mysql/driver.go @@ -0,0 +1,42 @@ +// Package mysql implements siphon's MySQL driver. It is a thin wrapper over the +// shared mysqlcommon implementation; only the binary names and the honestly- +// declared Capabilities differ from the MariaDB driver. +package mysql + +import ( + "context" + + "github.com/nixrajput/siphon/internal/driver" + mysqlcommon "github.com/nixrajput/siphon/internal/driver/_mysqlcommon" +) + +func init() { driver.Register(&Driver{}) } + +type Driver struct{} + +func (Driver) Name() string { return "mysql" } + +func (Driver) Capabilities() driver.Capabilities { + return driver.Capabilities{ + Incremental: false, // Phase F (binlog) + NativeStream: true, + PerTable: true, + SchemaOnly: true, + DataOnly: true, + Parallel: false, // mysqldump is single-threaded + Compression: true, + BinaryFormat: false, // SQL text dump, not a binary archive + CrossEngineSource: false, // Phase F + CrossEngineTarget: false, // Phase F + CDC: false, // Phase F + NativeBackpressure: true, + // CrossVersionIncremental defaults false + } +} + +func (Driver) Connect(ctx context.Context, p driver.Profile) (driver.Conn, error) { + return mysqlcommon.NewConn(ctx, p, "mysqldump", "mysql", "mysql.connect") +} + +// Compile-time check. +var _ driver.Driver = Driver{} From f2b80da44f69fcd0a120e4d97db600f6abc2981f Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:12:28 +0530 Subject: [PATCH 04/14] feat(driver/mariadb): MariaDB driver via shared mysqlcommon.Conn --- internal/driver/mariadb/driver.go | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/driver/mariadb/driver.go diff --git a/internal/driver/mariadb/driver.go b/internal/driver/mariadb/driver.go new file mode 100644 index 0000000..6f80a10 --- /dev/null +++ b/internal/driver/mariadb/driver.go @@ -0,0 +1,42 @@ +// Package mariadb implements siphon's MariaDB driver. Same shape as the MySQL +// driver — a thin wrapper over the shared mysqlcommon implementation — but +// shells out to the fork's renamed binaries (mariadb-dump / mariadb). +package mariadb + +import ( + "context" + + "github.com/nixrajput/siphon/internal/driver" + mysqlcommon "github.com/nixrajput/siphon/internal/driver/_mysqlcommon" +) + +func init() { driver.Register(&Driver{}) } + +type Driver struct{} + +func (Driver) Name() string { return "mariadb" } + +func (Driver) Capabilities() driver.Capabilities { + return driver.Capabilities{ + Incremental: false, // Phase F (binlog) + NativeStream: true, + PerTable: true, + SchemaOnly: true, + DataOnly: true, + Parallel: false, // mariadb-dump is single-threaded + Compression: true, + BinaryFormat: false, // SQL text dump, not a binary archive + CrossEngineSource: false, // Phase F + CrossEngineTarget: false, // Phase F + CDC: false, // Phase F + NativeBackpressure: true, + // CrossVersionIncremental defaults false + } +} + +func (Driver) Connect(ctx context.Context, p driver.Profile) (driver.Conn, error) { + return mysqlcommon.NewConn(ctx, p, "mariadb-dump", "mariadb", "mariadb.connect") +} + +// Compile-time check. +var _ driver.Driver = Driver{} From 7b2d3e6620896520957d30230b1165018b1628b7 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:17:55 +0530 Subject: [PATCH 05/14] test(driver/mysql): integration suite via RunDriverSuite harness --- go.mod | 3 +- go.sum | 2 + internal/driver/mysql/integration_test.go | 84 +++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 internal/driver/mysql/integration_test.go diff --git a/go.mod b/go.mod index e608e28..d0846d3 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,12 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20260527151214-009e6338d40d + github.com/go-sql-driver/mysql v1.10.0 github.com/jackc/pgx/v5 v5.9.2 github.com/muesli/termenv v0.16.0 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 + github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -53,7 +55,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 1f74082..58b17a6 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0 h1:Yhv1k7vDpyzZePntg5R5Oj4ZMCyWpAfpJeRu1ROsgiU= +github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0/go.mod h1:Z7SCTuiZlghAdRjkv3Ir0iXJKC2T2avbtxLR0DRe+ng= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= diff --git a/internal/driver/mysql/integration_test.go b/internal/driver/mysql/integration_test.go new file mode 100644 index 0000000..f24ee2a --- /dev/null +++ b/internal/driver/mysql/integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package mysql + +import ( + "context" + "database/sql" + "errors" + "testing" + + mysqlctr "github.com/testcontainers/testcontainers-go/modules/mysql" + + "github.com/nixrajput/siphon/internal/driver" + mysqlcommon "github.com/nixrajput/siphon/internal/driver/_mysqlcommon" + drivertesting "github.com/nixrajput/siphon/internal/driver/_testing" +) + +func startMySQL(t *testing.T) (driver.Profile, func(), func() (*sql.DB, error)) { + t.Helper() + ctx := context.Background() + c, err := mysqlctr.Run(ctx, "mysql:8.0", + mysqlctr.WithDatabase("test"), + mysqlctr.WithUsername("root"), + mysqlctr.WithPassword("rootpass"), + ) + if err != nil { + t.Fatalf("start mysql container: %v", err) + } + + host, err := c.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := c.MappedPort(ctx, "3306/tcp") + if err != nil { + t.Fatalf("container mapped port: %v", err) + } + + prof := driver.Profile{ + Driver: "mysql", + Host: host, + Port: int(port.Num()), + User: "root", + Password: "rootpass", + Database: "test", + SSLMode: "disable", + } + cleanup := func() { _ = c.Terminate(ctx) } + opener := func() (*sql.DB, error) { return mysqlcommon.Open(prof) } + return prof, cleanup, opener +} + +func TestSuite_MySQL(t *testing.T) { + prof, cleanup, opener := startMySQL(t) + drivertesting.RunDriverSuite(t, func() driver.Driver { return Driver{} }, + drivertesting.Fixtures{ + Profile: prof, + Cleanup: cleanup, + SQLOpener: opener, + Seed: func(ctx context.Context, db *sql.DB) error { + stmts := []string{ + `DROP TABLE IF EXISTS widgets`, + `CREATE TABLE widgets(id INT PRIMARY KEY, name VARCHAR(64))`, + `INSERT INTO widgets VALUES (1,'wrench'),(2,'hammer')`, + } + for _, s := range stmts { + if _, err := db.ExecContext(ctx, s); err != nil { + return err + } + } + return nil + }, + VerifyRestore: func(ctx context.Context, db *sql.DB) error { + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n); err != nil { + return err + } + if n != 2 { + return errors.New("expected 2 widgets after restore") + } + return nil + }, + }) +} From c4ca221ea57957a3935bc935d250c4c0f189ff3c Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:19:43 +0530 Subject: [PATCH 06/14] test(driver/mariadb): integration suite via RunDriverSuite harness --- go.mod | 1 + go.sum | 2 + internal/driver/mariadb/integration_test.go | 84 +++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 internal/driver/mariadb/integration_test.go diff --git a/go.mod b/go.mod index d0846d3..bc76031 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/muesli/termenv v0.16.0 github.com/oklog/ulid/v2 v2.1.1 github.com/spf13/cobra v1.10.2 + github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 58b17a6..2c2ab9c 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0 h1:ZfWUJSIDnbNgoLAXMV1fc7lqcxBIX3zdnhwjaVUo7N0= +github.com/testcontainers/testcontainers-go/modules/mariadb v0.42.0/go.mod h1:0kV+yHee7zAgp0yccydxjNnHvlC1EOavTLCeg/lnRBY= github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0 h1:Yhv1k7vDpyzZePntg5R5Oj4ZMCyWpAfpJeRu1ROsgiU= github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0/go.mod h1:Z7SCTuiZlghAdRjkv3Ir0iXJKC2T2avbtxLR0DRe+ng= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= diff --git a/internal/driver/mariadb/integration_test.go b/internal/driver/mariadb/integration_test.go new file mode 100644 index 0000000..ce09371 --- /dev/null +++ b/internal/driver/mariadb/integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package mariadb + +import ( + "context" + "database/sql" + "errors" + "testing" + + mariadbctr "github.com/testcontainers/testcontainers-go/modules/mariadb" + + "github.com/nixrajput/siphon/internal/driver" + mysqlcommon "github.com/nixrajput/siphon/internal/driver/_mysqlcommon" + drivertesting "github.com/nixrajput/siphon/internal/driver/_testing" +) + +func startMariaDB(t *testing.T) (driver.Profile, func(), func() (*sql.DB, error)) { + t.Helper() + ctx := context.Background() + c, err := mariadbctr.Run(ctx, "mariadb:11", + mariadbctr.WithDatabase("test"), + mariadbctr.WithUsername("root"), + mariadbctr.WithPassword("rootpass"), + ) + if err != nil { + t.Fatalf("start mariadb container: %v", err) + } + + host, err := c.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := c.MappedPort(ctx, "3306/tcp") + if err != nil { + t.Fatalf("container mapped port: %v", err) + } + + prof := driver.Profile{ + Driver: "mariadb", + Host: host, + Port: int(port.Num()), + User: "root", + Password: "rootpass", + Database: "test", + SSLMode: "disable", + } + cleanup := func() { _ = c.Terminate(ctx) } + opener := func() (*sql.DB, error) { return mysqlcommon.Open(prof) } + return prof, cleanup, opener +} + +func TestSuite_MariaDB(t *testing.T) { + prof, cleanup, opener := startMariaDB(t) + drivertesting.RunDriverSuite(t, func() driver.Driver { return Driver{} }, + drivertesting.Fixtures{ + Profile: prof, + Cleanup: cleanup, + SQLOpener: opener, + Seed: func(ctx context.Context, db *sql.DB) error { + stmts := []string{ + `DROP TABLE IF EXISTS widgets`, + `CREATE TABLE widgets(id INT PRIMARY KEY, name VARCHAR(64))`, + `INSERT INTO widgets VALUES (1,'wrench'),(2,'hammer')`, + } + for _, s := range stmts { + if _, err := db.ExecContext(ctx, s); err != nil { + return err + } + } + return nil + }, + VerifyRestore: func(ctx context.Context, db *sql.DB) error { + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n); err != nil { + return err + } + if n != 2 { + return errors.New("expected 2 widgets after restore") + } + return nil + }, + }) +} From 51537ba2002c871b1626315dfaf96778625aeb12 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:27:39 +0530 Subject: [PATCH 07/14] feat(app): register mysql + mariadb drivers via side-effect import --- internal/app/drivers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/app/drivers.go b/internal/app/drivers.go index 3c8a49b..9a4eb49 100644 --- a/internal/app/drivers.go +++ b/internal/app/drivers.go @@ -2,6 +2,8 @@ package app import ( "github.com/nixrajput/siphon/internal/driver" + _ "github.com/nixrajput/siphon/internal/driver/mariadb" // register the mariadb driver + _ "github.com/nixrajput/siphon/internal/driver/mysql" // register the mysql driver _ "github.com/nixrajput/siphon/internal/driver/postgres" // register the postgres driver ) From c67848bacb72142d2a54ec9440185889df7c7ac2 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:35:44 +0530 Subject: [PATCH 08/14] ci: install DB clients and run integration suite on Ubuntu --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 166bbcd..787a21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,23 @@ jobs: run: go vet ./... - name: go test run: go test ./... + # Integration tests use testcontainers, which needs a Docker daemon. + # Only ubuntu-latest runners have Docker available by default, so the + # DB client install and integration suite are scoped to Ubuntu. + # macOS/Windows run unit tests only (the integration-tagged files that + # shell out to the client binaries are tagged out of the unit build). + - name: Install DB client tools (integration, Ubuntu only) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + # postgresql-client -> pg_dump/pg_restore + # mysql-client -> mysqldump/mysql + # mariadb-client -> mariadb-dump/mariadb + # NOTE: mysql-client and mariadb-client can conflict on some Ubuntu + # releases (both provide the mysql* binaries via alternatives). If apt + # reports a conflict in CI, split them or drop mysql-client (mariadb-client + # provides mysql-compatible binaries) as a one-line follow-up. + sudo apt-get install -y postgresql-client mysql-client mariadb-client + - name: Integration tests (Ubuntu only — needs Docker) + if: matrix.os == 'ubuntu-latest' + run: go test -tags=integration ./... From 9559175e9018a55e0587b9922e679629c972c3bd Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:39:36 +0530 Subject: [PATCH 09/14] docs: mark Phase E (MySQL + MariaDB) complete in README, CHANGELOG, DRIVERS.md --- CHANGELOG.md | 5 +++++ README.md | 25 ++++++++++++++----------- docs/DRIVERS.md | 6 ++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb9c99..34f5130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Capability gating helper: `app.RequireCapability` resolves a profile to its driver and rejects unsupported affordances with a `CodeUser` error. The helper and the `Capabilities` flags are in place; verbs are wired to it only where the gated feature is implemented. Streaming (`NativeStream`) and parallel backup (`Parallel`) are deferred to Phase F, so `sync --stream` / `backup --jobs` are not yet gated — the wiring lands with those features. - Connection-probe retry on Postgres `Connect` (3 attempts, exponential backoff) per spec §4.3; the generic `Retry` helper moved to `internal/jobs` to keep the driver layer free of an app-layer import. - `docs/DRIVERS.md` contributor guide documenting the driver contract, registration, the test harness, capability flags, and error mapping. +- Phase E MySQL + MariaDB drivers: + - Shared `internal/driver/_mysqlcommon` package: DSN builder, `mysqldump`/`mariadb-dump` arg builder, fork detection, and a shared `Conn` implementing the full `driver.Conn` contract (Inspect via `information_schema`, Backup by shelling to the dump tool, Restore by piping SQL into the client, sha256 Verify, bounded connect-probe retry). Backup/Restore pass the password via `MYSQL_PWD` and stream through stdout/stdin. + - `mysql` and `mariadb` drivers as thin wrappers that inject the fork-specific binary names and declare capabilities honestly (`Parallel: false` — the dump tools are single-threaded). Registered via side-effect import in `internal/app/drivers.go`. + - Integration suites for both engines run on the Phase D `RunDriverSuite` harness via testcontainers (`mysql:8.0`, `mariadb:11`). + - CI installs the Postgres/MySQL/MariaDB client tools and runs the full integration suite on the Ubuntu runner (where Docker is available); unit tests continue to run on Linux/macOS/Windows. diff --git a/README.md b/README.md index 34166a8..e39f74d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ --- > [!WARNING] -> **Pre-1.0 — active development.** Postgres backup/restore/sync/verify/inspect work end-to-end today (Phase B), and bare `siphon` opens an interactive multi-panel dashboard (Phase C). The driver layer is hardened with a shared cross-driver test harness, capability gating, and connection retry (Phase D), so MySQL/MariaDB can land mechanically next. MySQL/MariaDB, incremental backups, and ops features are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a`, `phase-b`, `phase-c`, `phase-d`, …). +> **Pre-1.0 — active development.** Postgres, MySQL, and MariaDB backup/restore/sync/verify/inspect work end-to-end today (Phases B + E), and bare `siphon` opens an interactive multi-panel dashboard (Phase C). The driver layer is hardened with a shared cross-driver test harness, capability gating, and connection retry (Phase D). Incremental backups, cross-engine sync, and ops features are on the [roadmap](#roadmap). APIs, flags, and the on-disk dump format may change before 1.0. Track progress via the milestone tags (`phase-a`, `phase-b`, `phase-c`, `phase-d`, `phase-e`, …). ## Table of contents @@ -38,7 +38,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ ## Why siphon -- **One CLI, many databases.** Postgres works today; MySQL and MariaDB land in v1.0 (sharing a common backend). The driver interface is engine-agnostic, so SQLite, MongoDB, SQL Server, and ClickHouse can follow. +- **One CLI, many databases.** Postgres, MySQL, and MariaDB all work today (MySQL and MariaDB share a common `_mysqlcommon` backend). The driver interface is engine-agnostic, so SQLite, MongoDB, SQL Server, and ClickHouse can follow. - **Native, not reimplemented.** siphon shells out to `pg_dump`/`pg_restore` (and `mysqldump`/`mariadb-dump` in v1.0) for the actual data movement — you inherit 20+ years of correctness from the official tools, wrapped in a consistent UX. - **Integrity by default.** Every dump is checksummed (SHA-256) and recorded in a sidecar metadata file. `siphon verify` re-hashes the dump and flags corruption or tampering — and fails with a distinct exit code so CI can catch it. - **Built for scripts and humans.** A Cobra command tree with predictable flags and POSIX exit codes for automation; an interactive Bubble Tea dashboard when you invoke `siphon` bare. @@ -53,7 +53,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ | **B** — Postgres walking skeleton | `backup`, `restore`, `sync`, `verify`, `inspect`, `dumps`, `config`, `profile` working end-to-end against PostgreSQL | ✅ Complete | | **C** — TUI dashboard | Multi-panel Bubble Tea dashboard (profiles · dumps · jobs) with live job progress, backup/restore modal forms, and snapshot tests | ✅ Complete | | **D** — Driver hardening | Shared cross-driver test harness (`RunDriverSuite`), capability-gating helper (`RequireCapability`), Postgres connection-probe retry, and a `docs/DRIVERS.md` contributor guide | ✅ Complete | -| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package | ⏳ Planned | +| **E** — MySQL + MariaDB | Both drivers via a shared `_mysqlcommon` package (shared `Conn`, `mysqldump`/`mariadb-dump` backup, client-pipe restore), exercised by the Phase D `RunDriverSuite` harness | ✅ Complete | | **F** — Advanced transfer | Incremental backups, bounded-buffer streaming, cross-engine sync, CDC | ⏳ Planned | | **G** — Ops features | Cloud storage, secret backends, profile groups + 2FA, team mode, audit log, retention, telemetry | ⏳ Planned | | **H** — Distribution | GoReleaser, Homebrew tap, Scoop bucket, install script, docs site | ⏳ Planned | @@ -61,14 +61,17 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ ## Requirements - **[Go](https://go.dev/dl/) 1.26 or newer** — to build from source (the only install method until Phase H). -- **PostgreSQL client tools** — `pg_dump`, `pg_restore`, and `psql` must be on your `PATH`. siphon shells out to them; it does not embed a Postgres client. - - | Platform | Install | - | ------------- | --------------------------------------------------------------------------------------------------------------- | - | macOS | `brew install postgresql@16` | - | Debian/Ubuntu | `sudo apt install postgresql-client` | - | Fedora/RHEL | `sudo dnf install postgresql` | - | Windows | Install the [EDB PostgreSQL](https://www.postgresql.org/download/windows/) package and add its `bin/` to `PATH` | +- **Database client tools** — siphon shells out to the native dump/restore tools; it does not embed a client. You only need the tools for the engines you actually use: + - **PostgreSQL** profiles need `pg_dump`, `pg_restore`, `psql`. + - **MySQL** profiles need `mysqldump`, `mysql`. + - **MariaDB** profiles need `mariadb-dump`, `mariadb`. + + | Platform | PostgreSQL | MySQL / MariaDB | + | ------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | + | macOS | `brew install postgresql@16` | `brew install mysql-client` / `brew install mariadb` | + | Debian/Ubuntu | `sudo apt install postgresql-client` | `sudo apt install mysql-client mariadb-client` | + | Fedora/RHEL | `sudo dnf install postgresql` | `sudo dnf install mysql mariadb` | + | Windows | Install the [EDB PostgreSQL](https://www.postgresql.org/download/windows/) package and add its `bin/` to `PATH` | Install the MySQL / MariaDB client and add to `PATH` | - **Docker** _(optional)_ — only needed to run the integration test suite (`make test-integration`). diff --git a/docs/DRIVERS.md b/docs/DRIVERS.md index 16c0cf2..442acd4 100644 --- a/docs/DRIVERS.md +++ b/docs/DRIVERS.md @@ -216,6 +216,12 @@ The `Fixtures` struct (in `internal/driver/_testing/fixtures.go`) you populate: The worked example is `internal/driver/postgres/integration_test.go` — copy its shape. The suite runs behind the `integration` build tag (`make test-integration`). +`internal/driver/mysql/` and `internal/driver/mariadb/` are a second worked example: +two engines that share almost everything live in `internal/driver/_mysqlcommon/` +(a shared `Conn` plus arg/DSN builders), leaving each driver a ~30-line wrapper +that only injects the fork-specific binary names and capabilities. If you're +adding an engine that's a fork of an existing one, follow that shared-helper +pattern rather than copying a whole driver. ## Error mapping From 8caf1f4bcd238c412d46cedbf6c3536b321b0295 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Sun, 21 Jun 2026 23:50:03 +0530 Subject: [PATCH 10/14] test: run _mysqlcommon unit tests in make + CI; note MariaDB binary requirement go test ./... skips underscore-prefixed dirs, so internal/driver/_mysqlcommon unit tests were silently excluded from make test and CI. Name _*-prefixed packages explicitly so the arg-builder/version tests actually run. Also document that the MariaDB driver requires the renamed mariadb-dump/mariadb binaries. --- .github/workflows/ci.yml | 6 ++++-- Makefile | 11 ++++++++--- README.md | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 787a21d..b2fc2cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,10 @@ jobs: cache: true - name: go vet run: go vet ./... + # `./...` skips underscore-prefixed dirs, so name _*-prefixed packages + # explicitly (e.g. internal/driver/_mysqlcommon) to run their unit tests. - name: go test - run: go test ./... + run: go test ./... ./internal/driver/_*/ # Integration tests use testcontainers, which needs a Docker daemon. # Only ubuntu-latest runners have Docker available by default, so the # DB client install and integration suite are scoped to Ubuntu. @@ -59,4 +61,4 @@ jobs: sudo apt-get install -y postgresql-client mysql-client mariadb-client - name: Integration tests (Ubuntu only — needs Docker) if: matrix.os == 'ubuntu-latest' - run: go test -tags=integration ./... + run: go test -tags=integration ./... ./internal/driver/_*/ diff --git a/Makefile b/Makefile index 3caf6c9..ed6bece 100644 --- a/Makefile +++ b/Makefile @@ -9,14 +9,19 @@ help: ## Show this help lint: ## Run golangci-lint golangci-lint run +# NOTE: `go test ./...` skips directories whose names start with `_` (Go tool +# convention), so underscore-prefixed packages with real tests (e.g. +# internal/driver/_mysqlcommon) are silently excluded. Name them explicitly via +# the `_*` glob so their unit tests actually run. The glob auto-picks up any +# future _*-prefixed package under internal/driver/. test: ## Run unit tests - go test ./... + go test ./... ./internal/driver/_*/ test-verbose: ## Run unit tests verbosely - go test -v ./... + go test -v ./... ./internal/driver/_*/ test-integration: ## Run integration tests (build tag: integration) - go test -tags=integration ./... + go test -tags=integration ./... ./internal/driver/_*/ build: ## Build the siphon binary into ./bin @mkdir -p bin diff --git a/README.md b/README.md index e39f74d..2b08901 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ - **Database client tools** — siphon shells out to the native dump/restore tools; it does not embed a client. You only need the tools for the engines you actually use: - **PostgreSQL** profiles need `pg_dump`, `pg_restore`, `psql`. - **MySQL** profiles need `mysqldump`, `mysql`. - - **MariaDB** profiles need `mariadb-dump`, `mariadb`. + - **MariaDB** profiles need `mariadb-dump`, `mariadb` (the renamed binaries shipped by MariaDB 10.5+; older installs that only ship `mysqldump`/`mysql` are not yet supported). | Platform | PostgreSQL | MySQL / MariaDB | | ------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | From c20d1b0ee3763c1c677e0d07792baae6bfdc0f9e Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Mon, 22 Jun 2026 00:02:55 +0530 Subject: [PATCH 11/14] ci: fix Windows test glob + Ubuntu mysql/mariadb apt conflict Two CI failures from PR #4: - Windows: ./internal/driver/_*/ is a bash glob PowerShell doesn't expand, so go test got a literal path and failed. Name _mysqlcommon by import path instead (portable across shells); mirror in the Makefile. - Ubuntu: mariadb-client Conflicts with mysql-client-core, so they can't co-install. Install mysql-client from Ubuntu and pull mariadb-client from MariaDB's official APT repo, which is built to coexist. --- .github/workflows/ci.yml | 25 +++++++++++++------------ Makefile | 14 ++++++++------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2fc2cd..3d2238a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,11 @@ jobs: cache: true - name: go vet run: go vet ./... - # `./...` skips underscore-prefixed dirs, so name _*-prefixed packages - # explicitly (e.g. internal/driver/_mysqlcommon) to run their unit tests. + # `./...` skips underscore-prefixed dirs, so name the _mysqlcommon package + # explicitly by import path to run its unit tests. Use the import path (not + # a `_*` shell glob) so it works on PowerShell (Windows) as well as bash. - name: go test - run: go test ./... ./internal/driver/_*/ + run: go test ./... github.com/nixrajput/siphon/internal/driver/_mysqlcommon # Integration tests use testcontainers, which needs a Docker daemon. # Only ubuntu-latest runners have Docker available by default, so the # DB client install and integration suite are scoped to Ubuntu. @@ -51,14 +52,14 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - # postgresql-client -> pg_dump/pg_restore - # mysql-client -> mysqldump/mysql - # mariadb-client -> mariadb-dump/mariadb - # NOTE: mysql-client and mariadb-client can conflict on some Ubuntu - # releases (both provide the mysql* binaries via alternatives). If apt - # reports a conflict in CI, split them or drop mysql-client (mariadb-client - # provides mysql-compatible binaries) as a one-line follow-up. - sudo apt-get install -y postgresql-client mysql-client mariadb-client + # postgresql-client -> pg_dump/pg_restore ; mysql-client -> mysqldump/mysql + sudo apt-get install -y postgresql-client mysql-client + # Ubuntu's mariadb-client Conflicts with mysql-client-core, so it can't + # co-install. Pull mariadb-dump/mariadb from MariaDB's official APT repo + # (the repo's mariadb-client is built to coexist with MySQL's client). + curl -fsSL https://r.mariadb.com/downloads/mariadb_repo_setup | sudo bash + sudo apt-get update + sudo apt-get install -y mariadb-client - name: Integration tests (Ubuntu only — needs Docker) if: matrix.os == 'ubuntu-latest' - run: go test -tags=integration ./... ./internal/driver/_*/ + run: go test -tags=integration ./... github.com/nixrajput/siphon/internal/driver/_mysqlcommon diff --git a/Makefile b/Makefile index ed6bece..4d625f8 100644 --- a/Makefile +++ b/Makefile @@ -11,17 +11,19 @@ lint: ## Run golangci-lint # NOTE: `go test ./...` skips directories whose names start with `_` (Go tool # convention), so underscore-prefixed packages with real tests (e.g. -# internal/driver/_mysqlcommon) are silently excluded. Name them explicitly via -# the `_*` glob so their unit tests actually run. The glob auto-picks up any -# future _*-prefixed package under internal/driver/. +# internal/driver/_mysqlcommon) are silently excluded. Name them explicitly by +# import path so their unit tests run. Use the import path (not a `_*` shell +# glob) so this is identical to what CI runs and works regardless of shell. +UNDERSCORE_PKGS := github.com/nixrajput/siphon/internal/driver/_mysqlcommon + test: ## Run unit tests - go test ./... ./internal/driver/_*/ + go test ./... $(UNDERSCORE_PKGS) test-verbose: ## Run unit tests verbosely - go test -v ./... ./internal/driver/_*/ + go test -v ./... $(UNDERSCORE_PKGS) test-integration: ## Run integration tests (build tag: integration) - go test -tags=integration ./... ./internal/driver/_*/ + go test -tags=integration ./... $(UNDERSCORE_PKGS) build: ## Build the siphon binary into ./bin @mkdir -p bin From 7a3e1344f9586894444563d264a4c8a51418bfeb Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Mon, 22 Jun 2026 00:06:34 +0530 Subject: [PATCH 12/14] test(driver): make cancel-propagation subtest engine-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cancel_PropagatesToSubprocess subtest raced a 150ms sleep against the dump duration, then asserted Backup returned non-nil. That's flaky across engines: mysqldump/mariadb-dump dump the 5 MiB seed far faster than pg_dump, so the process finished before cancel() fired and Backup returned nil (CI: TestSuite_MySQL/MariaDB failed, Postgres passed). Cancel the context BEFORE starting Backup instead. exec.CommandContext then refuses to start (or immediately kills) the subprocess, so Backup returns a non-nil error deterministically on every engine — proving ctx is wired to the process without a timing bet. Drops the now-unneeded seedCancelLoad helper. --- internal/driver/_testing/suite.go | 67 ++++++------------------------- 1 file changed, 12 insertions(+), 55 deletions(-) diff --git a/internal/driver/_testing/suite.go b/internal/driver/_testing/suite.go index 72a85ca..89c51d0 100644 --- a/internal/driver/_testing/suite.go +++ b/internal/driver/_testing/suite.go @@ -4,8 +4,6 @@ import ( "bytes" "context" "errors" - "strconv" - "strings" "testing" "time" @@ -13,48 +11,6 @@ import ( "github.com/nixrajput/siphon/internal/errs" ) -// seedCancelLoad bulk-loads a table of wide rows via the fixture's raw SQL -// connection so a subsequent Backup has enough data to stay in-flight past the -// cancel delay. Uses only portable SQL (CREATE TABLE + parameterized INSERT in -// one transaction), so it works for any engine the harness is pointed at. -func seedCancelLoad(t *testing.T, fx Fixtures) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - db, err := fx.SQLOpener() - if err != nil { - t.Fatalf("seedCancelLoad: SQLOpener: %v", err) - } - defer func() { _ = db.Close() }() - - if _, err := db.ExecContext(ctx, - `CREATE TABLE cancel_load (id integer primary key, payload text)`); err != nil { - t.Fatalf("seedCancelLoad: create table: %v", err) - } - - // ~5000 rows of ~1 KiB each ≈ 5 MiB — large enough that the dump can't - // complete within the 150ms cancel window, small enough to load quickly. - // The payload is a fixed safe literal (no user input), so it's inlined - // rather than parameterized — this keeps the SQL placeholder-agnostic - // ($1 vs ? differs by engine) so the harness stays portable across drivers. - payload := strings.Repeat("x", 1024) - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("seedCancelLoad: begin: %v", err) - } - for i := 0; i < 5000; i++ { - if _, err := tx.ExecContext(ctx, - "INSERT INTO cancel_load (id, payload) VALUES ("+strconv.Itoa(i)+", '"+payload+"')"); err != nil { - _ = tx.Rollback() - t.Fatalf("seedCancelLoad: insert %d: %v", i, err) - } - } - if err := tx.Commit(); err != nil { - t.Fatalf("seedCancelLoad: commit: %v", err) - } -} - // RunDriverSuite exercises the full Driver contract against a real // database. ctor returns the driver-under-test; fx provides the env. // @@ -123,13 +79,15 @@ func RunDriverSuite(t *testing.T, ctor func() driver.Driver, fx Fixtures) { }) t.Run("Cancel_PropagatesToSubprocess", func(t *testing.T) { - // Inflate the database so the dump takes long enough that cancel() - // reliably lands while pg_dump (or the engine's equivalent) is still - // streaming. With only the round-trip subtest's tiny fixture, the dump - // can finish in well under the cancel delay on a fast host, making - // Backup return a clean nil and the assertion below spuriously fail. - seedCancelLoad(t, fx) - + // Verify ctx cancellation propagates to the dump subprocess. We cancel + // the context BEFORE starting Backup rather than racing a sleep against + // the dump's duration: a fixed (sleep, data-size) pair is inherently + // flaky across engines (mysqldump is far faster than pg_dump, so a dump + // sized to outlast the delay for one engine finishes early on another). + // With an already-cancelled ctx, exec.CommandContext refuses to start + // (or immediately kills) the process, so Backup must return a non-nil + // error deterministically on every engine — proving the ctx is wired to + // the subprocess without a timing bet. conn, err := d.Connect(context.Background(), fx.Profile) if err != nil { t.Fatalf("Connect: %v", err) @@ -137,17 +95,16 @@ func RunDriverSuite(t *testing.T, ctor func() driver.Driver, fx Fixtures) { defer func() { _ = conn.Close() }() ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel up front + ch := make(chan error, 1) buf := &bytes.Buffer{} go func() { ch <- conn.Backup(ctx, driver.BackupOpts{}, buf) }() - time.Sleep(150 * time.Millisecond) - cancel() - select { case err := <-ch: if err == nil { - t.Fatal("Backup returned nil after cancel; want non-nil error") + t.Fatal("Backup returned nil for a cancelled context; want non-nil error") } case <-time.After(10 * time.Second): t.Fatal("Backup did not return within 10s after cancel — subprocess leak?") From 7801d5d42e7972a10d5bc5ecce3c7b279b679a83 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Mon, 22 Jun 2026 00:17:40 +0530 Subject: [PATCH 13/14] fix(driver/_mysqlcommon): propagate SSLMode to dump/restore, canonical DSN, dedup MYSQL_PWD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on PR #4: - BuildDumpArgs/BuildRestoreArgs now pass --ssl-mode (mapped from Profile.SSLMode) so backup/restore honor the same TLS policy the connect DSN does, instead of falling back to the client default (PREFERRED). Adds tests. - DSN now uses mysql.Config.FormatDSN (the driver's canonical builder) instead of hand-formatting. Note: FormatDSN escapes the DBName path but not user/passwd, so the ':'-in-username limitation stands (documented honestly). - withMySQLPwd strips any pre-existing MYSQL_PWD from the child env before setting ours, guaranteeing a single, correct value. - README: drop the stale 'in v1.0' qualifier — MySQL/MariaDB tools work today. Skipped the CI apt-package finding: it targets an outdated diff (the mysql-client + mariadb-client install line it flags no longer exists; CI now uses mysql-client from Ubuntu + mariadb-client from MariaDB's repo, the native-client design). --- README.md | 2 +- internal/driver/_mysqlcommon/args.go | 21 ++++++++ internal/driver/_mysqlcommon/args_test.go | 59 +++++++++++++++++++++++ internal/driver/_mysqlcommon/conn.go | 20 +++++++- internal/driver/_mysqlcommon/dsn.go | 27 +++++++---- 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2b08901..63eda1b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ A single binary that turns the painful, error-prone sprawl of `pg_dump` → `pg_ ## Why siphon - **One CLI, many databases.** Postgres, MySQL, and MariaDB all work today (MySQL and MariaDB share a common `_mysqlcommon` backend). The driver interface is engine-agnostic, so SQLite, MongoDB, SQL Server, and ClickHouse can follow. -- **Native, not reimplemented.** siphon shells out to `pg_dump`/`pg_restore` (and `mysqldump`/`mariadb-dump` in v1.0) for the actual data movement — you inherit 20+ years of correctness from the official tools, wrapped in a consistent UX. +- **Native, not reimplemented.** siphon shells out to `pg_dump`/`pg_restore`, `mysqldump`/`mysql`, and `mariadb-dump`/`mariadb` for the actual data movement — you inherit 20+ years of correctness from the official tools, wrapped in a consistent UX. - **Integrity by default.** Every dump is checksummed (SHA-256) and recorded in a sidecar metadata file. `siphon verify` re-hashes the dump and flags corruption or tampering — and fails with a distinct exit code so CI can catch it. - **Built for scripts and humans.** A Cobra command tree with predictable flags and POSIX exit codes for automation; an interactive Bubble Tea dashboard when you invoke `siphon` bare. - **Named profiles + secret refs.** Store connection details once; reference secrets as `env:VAR` today, with OS keychain / Vault / 1Password / AWS Secrets Manager backends on the roadmap. Plaintext passwords never have to live in your config. diff --git a/internal/driver/_mysqlcommon/args.go b/internal/driver/_mysqlcommon/args.go index e5727f7..28ff656 100644 --- a/internal/driver/_mysqlcommon/args.go +++ b/internal/driver/_mysqlcommon/args.go @@ -13,6 +13,7 @@ func BuildDumpArgs(p driver.Profile, opt driver.BackupOpts) []string { "-h", p.Host, "-P", strconv.Itoa(p.Port), "-u", p.User, + "--ssl-mode=" + cliSSLMode(p.SSLMode), "--single-transaction", "--routines", "--triggers", @@ -43,7 +44,27 @@ func BuildRestoreArgs(p driver.Profile, _ driver.RestoreOpts) []string { "-h", p.Host, "-P", strconv.Itoa(p.Port), "-u", p.User, + "--ssl-mode=" + cliSSLMode(p.SSLMode), "--default-character-set=utf8mb4", p.Database, } } + +// cliSSLMode maps a profile SSLMode to the mysqldump/mysql client's +// --ssl-mode value, so backup/restore honor the same TLS policy the DSN does +// (otherwise the tools fall back to the client default, PREFERRED). The inputs +// mirror tlsParam in dsn.go. +func cliSSLMode(mode string) string { + switch mode { + case "disable": + return "DISABLED" + case "require": + return "REQUIRED" + case "verify-ca": + return "VERIFY_CA" + case "verify-full": + return "VERIFY_IDENTITY" + default: + return "PREFERRED" + } +} diff --git a/internal/driver/_mysqlcommon/args_test.go b/internal/driver/_mysqlcommon/args_test.go index d9bc94c..1bab9a6 100644 --- a/internal/driver/_mysqlcommon/args_test.go +++ b/internal/driver/_mysqlcommon/args_test.go @@ -14,6 +14,7 @@ func TestBuildDumpArgs_Defaults(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", + "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -39,6 +40,7 @@ func TestBuildDumpArgs_Tables(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", + "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -62,6 +64,7 @@ func TestBuildDumpArgs_DataOnly(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", + "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -84,6 +87,7 @@ func TestBuildDumpArgs_MultiExclude(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", + "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -99,6 +103,61 @@ func TestBuildDumpArgs_MultiExclude(t *testing.T) { } } +func TestBuildRestoreArgs_Defaults(t *testing.T) { + p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Database: "shop"} + got := BuildRestoreArgs(p, driver.RestoreOpts{}) + want := []string{ + "-h", "db.local", + "-P", "3306", + "-u", "root", + "--ssl-mode=PREFERRED", + "--default-character-set=utf8mb4", + "shop", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildRestoreArgs() = %v\nwant %v", got, want) + } +} + +// TestBuildArgs_SSLModePropagated verifies the profile's SSLMode reaches the +// dump/restore CLI tools as --ssl-mode (not just the connect DSN). +func TestBuildArgs_SSLModePropagated(t *testing.T) { + p := driver.Profile{Host: "h", Port: 3306, User: "u", Database: "d", SSLMode: "verify-full"} + dump := BuildDumpArgs(p, driver.BackupOpts{}) + if !contains(dump, "--ssl-mode=VERIFY_IDENTITY") { + t.Fatalf("BuildDumpArgs missing --ssl-mode=VERIFY_IDENTITY: %v", dump) + } + restore := BuildRestoreArgs(p, driver.RestoreOpts{}) + if !contains(restore, "--ssl-mode=VERIFY_IDENTITY") { + t.Fatalf("BuildRestoreArgs missing --ssl-mode=VERIFY_IDENTITY: %v", restore) + } +} + +func TestCLISSLMode(t *testing.T) { + cases := map[string]string{ + "disable": "DISABLED", + "require": "REQUIRED", + "verify-ca": "VERIFY_CA", + "verify-full": "VERIFY_IDENTITY", + "": "PREFERRED", + "prefer": "PREFERRED", + } + for mode, want := range cases { + if got := cliSSLMode(mode); got != want { + t.Errorf("cliSSLMode(%q) = %q; want %q", mode, got, want) + } + } +} + +func contains(args []string, want string) bool { + for _, a := range args { + if a == want { + return true + } + } + return false +} + func TestDSN(t *testing.T) { p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Password: "pw", Database: "shop"} got := DSN(p) diff --git a/internal/driver/_mysqlcommon/conn.go b/internal/driver/_mysqlcommon/conn.go index f60ef85..615f68d 100644 --- a/internal/driver/_mysqlcommon/conn.go +++ b/internal/driver/_mysqlcommon/conn.go @@ -9,6 +9,7 @@ import ( "io" "os" "os/exec" + "strings" "time" "github.com/nixrajput/siphon/internal/driver" @@ -126,7 +127,7 @@ func (c *Conn) Verify(_ context.Context, r io.Reader) (*driver.VerifyReport, err // streams its stdout to w. ctx cancellation propagates via exec.CommandContext. func BackupWith(ctx context.Context, binary string, p driver.Profile, opt driver.BackupOpts, w io.Writer) error { cmd := exec.CommandContext(ctx, binary, BuildDumpArgs(p, opt)...) - cmd.Env = append(os.Environ(), "MYSQL_PWD="+p.Password) + cmd.Env = withMySQLPwd(os.Environ(), p.Password) cmd.Stdout = w // Discard stderr directly. StderrPipe + a drain goroutine would race with // cmd.Wait() (Wait closes the pipe once the process exits). Phase F will @@ -144,7 +145,7 @@ func BackupWith(ctx context.Context, binary string, p driver.Profile, opt driver // into its stdin. ctx cancellation propagates via exec.CommandContext. func RestoreWith(ctx context.Context, binary string, p driver.Profile, opt driver.RestoreOpts, r io.Reader) error { cmd := exec.CommandContext(ctx, binary, BuildRestoreArgs(p, opt)...) - cmd.Env = append(os.Environ(), "MYSQL_PWD="+p.Password) + cmd.Env = withMySQLPwd(os.Environ(), p.Password) cmd.Stdin = r cmd.Stderr = io.Discard // don't leak client diagnostics to the user's terminal; Phase F captures these @@ -168,3 +169,18 @@ func toolErr(binary, op string, err error) *errs.Error { } return &errs.Error{Op: op, Code: errs.CodeSystem, Cause: err} } + +// withMySQLPwd returns base with any pre-existing MYSQL_PWD entry removed and a +// single MYSQL_PWD=pw appended. Without the filter, a MYSQL_PWD already in the +// parent environment would remain alongside ours; the tool's behavior on a +// duplicated key is unspecified, so we guarantee exactly one. +func withMySQLPwd(base []string, pw string) []string { + out := make([]string, 0, len(base)+1) + for _, kv := range base { + if strings.HasPrefix(kv, "MYSQL_PWD=") { + continue + } + out = append(out, kv) + } + return append(out, "MYSQL_PWD="+pw) +} diff --git a/internal/driver/_mysqlcommon/dsn.go b/internal/driver/_mysqlcommon/dsn.go index 03f3a85..3c0d5ae 100644 --- a/internal/driver/_mysqlcommon/dsn.go +++ b/internal/driver/_mysqlcommon/dsn.go @@ -7,19 +7,28 @@ package mysqlcommon import ( "fmt" + gomysql "github.com/go-sql-driver/mysql" + "github.com/nixrajput/siphon/internal/driver" ) -// DSN builds a go-sql-driver/mysql connection string: -// user:pass@tcp(host:port)/db?parseTime=true&tls= -// -// This trusts p.User to be free of ':' — the go-sql-driver DSN format splits -// user:pass on the first ':', so passwords need no escaping (the library -// tolerates them), but a ':' in the username would corrupt parsing. MySQL -// usernames don't contain ':' in practice. +// DSN builds a go-sql-driver/mysql connection string via mysql.Config.FormatDSN, +// the driver's own canonical builder, rather than hand-formatting. FormatDSN +// path-escapes the DBName and round-trips with the driver's ParseDSN, so we +// stay aligned with whatever the library considers valid. (Note: it does not +// percent-encode the user/password — those still rely on positional parsing — +// so a ':' in the username remains unsupported; MySQL usernames don't contain +// one in practice.) func DSN(p driver.Profile) string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&tls=%s", - p.User, p.Password, p.Host, p.Port, p.Database, tlsParam(p.SSLMode)) + cfg := gomysql.NewConfig() + cfg.User = p.User + cfg.Passwd = p.Password + cfg.Net = "tcp" + cfg.Addr = fmt.Sprintf("%s:%d", p.Host, p.Port) + cfg.DBName = p.Database + cfg.ParseTime = true + cfg.TLSConfig = tlsParam(p.SSLMode) + return cfg.FormatDSN() } func tlsParam(mode string) string { From f68e95e83bb8f358772d1ab1f10b0674eb75f3d4 Mon Sep 17 00:00:00 2001 From: Nikhil Rajput Date: Mon, 22 Jun 2026 00:29:07 +0530 Subject: [PATCH 14/14] revert: drop --ssl-mode from dump/restore args (not portable across forks) The --ssl-mode flag added for CodeRabbit's SSLMode-propagation finding broke CI: mariadb-dump rejects --ssl-mode (MariaDB uses --ssl/--skip-ssl, not MySQL's flag), so both BackupRestore_Roundtrip suites failed with exit 7. SSLMode is still honored by the connect DSN; propagating it to the dump/restore tools needs per-fork flag handling and is tracked as a follow-up (see the NOTE in args.go). Keeps the other review fixes (canonical DSN, MYSQL_PWD dedup, README). --- internal/driver/_mysqlcommon/args.go | 26 ++++---------- internal/driver/_mysqlcommon/args_test.go | 44 ----------------------- 2 files changed, 6 insertions(+), 64 deletions(-) diff --git a/internal/driver/_mysqlcommon/args.go b/internal/driver/_mysqlcommon/args.go index 28ff656..5bbc16f 100644 --- a/internal/driver/_mysqlcommon/args.go +++ b/internal/driver/_mysqlcommon/args.go @@ -13,7 +13,6 @@ func BuildDumpArgs(p driver.Profile, opt driver.BackupOpts) []string { "-h", p.Host, "-P", strconv.Itoa(p.Port), "-u", p.User, - "--ssl-mode=" + cliSSLMode(p.SSLMode), "--single-transaction", "--routines", "--triggers", @@ -44,27 +43,14 @@ func BuildRestoreArgs(p driver.Profile, _ driver.RestoreOpts) []string { "-h", p.Host, "-P", strconv.Itoa(p.Port), "-u", p.User, - "--ssl-mode=" + cliSSLMode(p.SSLMode), "--default-character-set=utf8mb4", p.Database, } } -// cliSSLMode maps a profile SSLMode to the mysqldump/mysql client's -// --ssl-mode value, so backup/restore honor the same TLS policy the DSN does -// (otherwise the tools fall back to the client default, PREFERRED). The inputs -// mirror tlsParam in dsn.go. -func cliSSLMode(mode string) string { - switch mode { - case "disable": - return "DISABLED" - case "require": - return "REQUIRED" - case "verify-ca": - return "VERIFY_CA" - case "verify-full": - return "VERIFY_IDENTITY" - default: - return "PREFERRED" - } -} +// NOTE: Profile.SSLMode is honored by the connect DSN (see dsn.go) but is NOT +// yet propagated to the dump/restore CLI tools. The flag differs across forks — +// MySQL's mysqldump uses --ssl-mode= +// while MariaDB's mariadb-dump uses --ssl / --skip-ssl — so it needs per-fork +// handling (and the dump tools' stderr surfaced so a rejected flag isn't opaque). +// Tracked as a follow-up; see the PR #4 discussion. diff --git a/internal/driver/_mysqlcommon/args_test.go b/internal/driver/_mysqlcommon/args_test.go index 1bab9a6..55b5b9f 100644 --- a/internal/driver/_mysqlcommon/args_test.go +++ b/internal/driver/_mysqlcommon/args_test.go @@ -14,7 +14,6 @@ func TestBuildDumpArgs_Defaults(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", - "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -40,7 +39,6 @@ func TestBuildDumpArgs_Tables(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", - "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -64,7 +62,6 @@ func TestBuildDumpArgs_DataOnly(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", - "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -87,7 +84,6 @@ func TestBuildDumpArgs_MultiExclude(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", - "--ssl-mode=PREFERRED", "--single-transaction", "--routines", "--triggers", @@ -110,7 +106,6 @@ func TestBuildRestoreArgs_Defaults(t *testing.T) { "-h", "db.local", "-P", "3306", "-u", "root", - "--ssl-mode=PREFERRED", "--default-character-set=utf8mb4", "shop", } @@ -119,45 +114,6 @@ func TestBuildRestoreArgs_Defaults(t *testing.T) { } } -// TestBuildArgs_SSLModePropagated verifies the profile's SSLMode reaches the -// dump/restore CLI tools as --ssl-mode (not just the connect DSN). -func TestBuildArgs_SSLModePropagated(t *testing.T) { - p := driver.Profile{Host: "h", Port: 3306, User: "u", Database: "d", SSLMode: "verify-full"} - dump := BuildDumpArgs(p, driver.BackupOpts{}) - if !contains(dump, "--ssl-mode=VERIFY_IDENTITY") { - t.Fatalf("BuildDumpArgs missing --ssl-mode=VERIFY_IDENTITY: %v", dump) - } - restore := BuildRestoreArgs(p, driver.RestoreOpts{}) - if !contains(restore, "--ssl-mode=VERIFY_IDENTITY") { - t.Fatalf("BuildRestoreArgs missing --ssl-mode=VERIFY_IDENTITY: %v", restore) - } -} - -func TestCLISSLMode(t *testing.T) { - cases := map[string]string{ - "disable": "DISABLED", - "require": "REQUIRED", - "verify-ca": "VERIFY_CA", - "verify-full": "VERIFY_IDENTITY", - "": "PREFERRED", - "prefer": "PREFERRED", - } - for mode, want := range cases { - if got := cliSSLMode(mode); got != want { - t.Errorf("cliSSLMode(%q) = %q; want %q", mode, got, want) - } - } -} - -func contains(args []string, want string) bool { - for _, a := range args { - if a == want { - return true - } - } - return false -} - func TestDSN(t *testing.T) { p := driver.Profile{Host: "db.local", Port: 3306, User: "root", Password: "pw", Database: "shop"} got := DSN(p)