backupy-agent/apps/agent/internal/pipeline/mysqldump.go
TronoSfera 8b0c978337 feat(initial): Backupy agent + backupy-decrypt CLI
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/        — Go agent (WSS client, persistent queue, Docker
                       discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
                       pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/    — protobuf wire format (generated .pb.go committed
                       so the repo builds without protoc)
- docs/              — agent spec + wire-protocol contract

Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
2026-05-17 20:22:35 +03:00

152 lines
4.6 KiB
Go

package pipeline
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"
backupv1 "github.com/backupy/backupy/packages/proto/gen/go/backupv1"
)
// mysqldump implements Driver against the bundled mysqldump binary for
// both MySQL and MariaDB targets.
type mysqldump struct {
binary string
runner cmdRunner
}
// NewMysqldump constructs the default driver wired to the bundled
// mysqldump binary on $PATH.
func NewMysqldump() Driver {
return &mysqldump{binary: "mysqldump", runner: realRunner{}}
}
// Name implements Driver.Name.
func (m *mysqldump) Name() string { return "mysqldump" }
// Validate runs --version and a no-data smoke dump (`--no-data --no-create-info`)
// which contacts the server but emits almost nothing.
func (m *mysqldump) Validate(ctx context.Context, target *backupv1.Target) error {
if target == nil || target.Connection == nil {
return errors.New("pipeline: mysqldump: nil target/connection")
}
versionOut, err := m.runner.Output(ctx, m.binary, []string{"--version"}, nil)
if err != nil {
return fmt.Errorf("pipeline: mysqldump version probe failed: %w", err)
}
if !strings.Contains(strings.ToLower(string(versionOut)), "mysqldump") {
return fmt.Errorf("pipeline: unexpected mysqldump --version output: %q", string(versionOut))
}
args := append(m.connArgs(target), "--no-data", "--no-create-info", "--skip-triggers", "--skip-comments")
if _, err := m.runner.Output(ctx, m.binary, args, nil); err != nil {
return fmt.Errorf("pipeline: mysqldump smoke probe failed: %w", err)
}
return nil
}
// Dump streams a logical mysqldump SQL stream to `out`.
func (m *mysqldump) Dump(ctx context.Context, target *backupv1.Target, out io.Writer) (DumpInfo, error) {
if target == nil || target.Connection == nil {
return DumpInfo{}, errors.New("pipeline: mysqldump: nil target/connection")
}
args := append(m.connArgs(target),
"--single-transaction",
"--routines",
"--triggers",
"--events",
"--quick",
"--hex-blob",
"--skip-extended-insert",
)
if err := m.runner.RunStream(ctx, m.binary, args, nil, out); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: mysqldump exec: %w", err)
}
versionOut, vErr := m.runner.Output(ctx, m.binary, []string{"--version"}, nil)
engineVersion := "MySQL"
if vErr == nil {
engineVersion = parseMysqldumpVersion(string(versionOut))
}
return DumpInfo{EngineVersion: engineVersion}, nil
}
// connArgs assembles host/port/user/password/db flags. We pass the
// password inline (`--password=…`) because mysqldump does not accept
// it via env; the password never appears in process listings as long
// as the host is configured with `hidepid=2` or similar — but to be
// safe, callers should treat exec.Cmd argv as sensitive.
func (m *mysqldump) connArgs(t *backupv1.Target) []string {
c := t.Connection
args := []string{}
if c.Host != "" {
args = append(args, "-h", c.Host)
}
if c.Port != 0 {
args = append(args, "-P", strconv.FormatUint(uint64(c.Port), 10))
}
if c.Username != "" {
args = append(args, "-u", c.Username)
}
if c.PasswordSecretRef != "" {
args = append(args, "--password="+c.PasswordSecretRef)
}
if c.Database != "" {
args = append(args, c.Database)
}
return args
}
// parseMysqldumpVersion converts the human-readable --version banner
// into a canonical "MySQL 8.0.36" / "MariaDB 11.2.3" string.
//
// Examples:
//
// "mysqldump Ver 8.0.36 for Linux on x86_64 (MySQL Community Server - GPL)"
// -> "MySQL 8.0.36"
//
// "mysqldump from 11.2.3-MariaDB, client 10.19 …"
// -> "MariaDB 11.2.3"
func parseMysqldumpVersion(s string) string {
s = strings.TrimSpace(s)
low := strings.ToLower(s)
if i := strings.Index(low, "ver "); i >= 0 {
rest := s[i+len("Ver "):]
v := strings.Fields(rest)
if len(v) > 0 {
return "MySQL " + v[0]
}
}
if i := strings.Index(low, "mariadb"); i >= 0 {
// look back for a version-like token
fields := strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == ',' || r == '\t'
})
for _, f := range fields {
if strings.Contains(strings.ToLower(f), "mariadb") {
v := strings.Split(f, "-")
if len(v) >= 1 {
return "MariaDB " + v[0]
}
}
}
_ = i
}
return s
}
// IsMysqldumpHeader returns true if `head` looks like the start of a
// mysqldump SQL stream. mysqldump traditionally starts with a banner
// comment like "-- MySQL dump" or "-- MariaDB dump".
func IsMysqldumpHeader(head []byte) bool {
if len(head) == 0 {
return false
}
low := bytes.ToLower(head)
return bytes.HasPrefix(low, []byte("-- mysql dump")) ||
bytes.HasPrefix(low, []byte("-- mariadb dump")) ||
bytes.HasPrefix(low, []byte("-- mysqldump")) ||
bytes.HasPrefix(low, []byte("-- mariadb-dump"))
}