backupy-agent/apps/agent/internal/pipeline/sqlite.go
TronoSfera 3cfac4daca fix(sqlite): stage snapshot in temp file instead of /dev/stdout
The Alpine sqlite3 binary refuses to open /dev/stdout when running as
a non-root uid in a container ('Error: cannot open "/dev/stdout"'),
which breaks every backup attempt. Switch the dump path to stage the
snapshot in a temp file, then stream that file through gzip into the
pipeline. Adds streamSideEffect to the test mockRunner so the existing
gzip-wrap test can simulate the sqlite3 process writing to its
destination path.
2026-05-18 14:46:48 +03:00

212 lines
7.1 KiB
Go

// B14: SQLite driver.
//
// Streams a consistent snapshot of a SQLite database file using
// `sqlite3 <path> ".backup '/dev/stdout'"`. The `.backup` command takes
// an online-safe copy of the database — readers continue to see the
// previous state while we drain. The resulting bytes are a complete
// SQLite database file (not SQL text), which we pipe through gzip so
// the stream is self-describing and (modestly) smaller before the
// pipeline's zstd stage layers on top.
//
// Configuration:
//
// - target.Connection.Database — REQUIRED. Absolute path to the .db
// file. (Host/Port/Username are ignored.)
//
// The driver validates that the file exists and is readable at
// construction-time. WAL-mode databases are safe to back up with the
// `.backup` command — SQLite quiesces concurrent writers internally.
package pipeline
import (
"compress/gzip"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
backupv1 "github.com/backupy/backupy/packages/proto/gen/go/backupv1"
)
// sqliteDriver implements Driver against the `sqlite3` CLI binary plus
// optional pure-Go metadata probing.
type sqliteDriver struct {
binary string
runner cmdRunner
// statFn is overridable for tests.
statFn func(path string) (os.FileInfo, error)
// metaFn is overridable for tests. Returns (user_version, table_count, err).
metaFn func(ctx context.Context, path string) (int64, int, error)
}
// NewSqliteDriver constructs the default driver wired to the bundled
// sqlite3 binary on $PATH.
func NewSqliteDriver() Driver {
return &sqliteDriver{
binary: "sqlite3",
runner: streamingRunner{},
statFn: os.Stat,
metaFn: probeSqliteMetadata,
}
}
// Name implements Driver.Name.
func (s *sqliteDriver) Name() string { return "sqlite" }
// Validate verifies sqlite3 is installed and the configured database
// file exists and is readable.
func (s *sqliteDriver) Validate(ctx context.Context, target *backupv1.Target) error {
if target == nil || target.Connection == nil {
return errors.New("pipeline: sqlite: nil target/connection")
}
path := target.Connection.Database
if path == "" {
return errors.New("pipeline: sqlite: target.connection.database must be the path to the .db file")
}
versionOut, err := s.runner.Output(ctx, s.binary, []string{"--version"}, nil)
if err != nil {
return fmt.Errorf("pipeline: sqlite3 version probe failed (is sqlite3 installed?): %w", err)
}
if !looksLikeSqliteVersion(string(versionOut)) {
return fmt.Errorf("pipeline: unexpected sqlite3 --version output: %q", string(versionOut))
}
if _, err := s.statFn(path); err != nil {
return fmt.Errorf("pipeline: sqlite: cannot stat database %q: %w", path, err)
}
return nil
}
// Dump streams a gzip-wrapped binary SQLite backup to out.
//
// We invoke sqlite3 with `.backup '/dev/stdout'`. This is the canonical
// way to take an online-safe snapshot — concurrent readers see the old
// state, concurrent writers are not blocked, and the resulting file is
// a fully self-contained .db that `sqlite3 restored.db` can open.
func (s *sqliteDriver) Dump(ctx context.Context, target *backupv1.Target, out io.Writer) (DumpInfo, error) {
if target == nil || target.Connection == nil {
return DumpInfo{}, errors.New("pipeline: sqlite: nil target/connection")
}
path := target.Connection.Database
if path == "" {
return DumpInfo{}, errors.New("pipeline: sqlite: connection.database must be the path to the .db file")
}
if _, err := s.statFn(path); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: cannot stat database %q: %w", path, err)
}
// `.backup` requires a regular file as the destination — the Alpine
// sqlite3 binary refuses /dev/stdout for non-root processes. We
// stage the snapshot in a temp file and stream it to `out` after.
tmpDir := os.TempDir()
tmpFile, err := os.CreateTemp(tmpDir, "sqlite-backup-*.db")
if err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: create temp file: %w", err)
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer func() { _ = os.Remove(tmpPath) }()
args := []string{path, fmt.Sprintf(".backup '%s'", tmpPath)}
if err := s.runner.RunStream(ctx, s.binary, args, nil, io.Discard); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite3 .backup exec: %w", err)
}
src, err := os.Open(tmpPath)
if err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: open snapshot: %w", err)
}
defer src.Close()
gz := gzip.NewWriter(out)
if _, err := io.Copy(gz, src); err != nil {
_ = gz.Close()
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: gzip copy: %w", err)
}
if err := gz.Close(); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: close gzip: %w", err)
}
info := DumpInfo{EngineVersion: s.versionString(ctx)}
return info, nil
}
// versionString turns `sqlite3 --version` output into a canonical
// "SQLite <semver>" string. The raw output is e.g.
// "3.45.1 2024-01-30 16:01:20 e876e51a04…"; we keep the first token.
func (s *sqliteDriver) versionString(ctx context.Context) string {
out, err := s.runner.Output(ctx, s.binary, []string{"--version"}, nil)
if err != nil {
return "SQLite"
}
fields := strings.Fields(string(out))
if len(fields) == 0 {
return "SQLite"
}
return "SQLite " + fields[0]
}
// looksLikeSqliteVersion accepts any --version banner whose first token
// looks like a dotted version number (e.g. "3.45.1").
func looksLikeSqliteVersion(s string) bool {
fields := strings.Fields(s)
if len(fields) == 0 {
return false
}
for _, part := range strings.Split(fields[0], ".") {
if _, err := strconv.Atoi(part); err != nil {
return false
}
}
return true
}
// probeSqliteMetadata opens the database file read-only and returns
// (user_version, table_count, err). Used for metadata enrichment when
// the agent has a registered sqlite driver in database/sql.
//
// To keep the agent dependency-free for MVP we only attempt the probe
// when a "sqlite" or "sqlite3" driver is registered with database/sql
// at runtime. Production builds may register one via blank-import in
// cmd/agent if richer metadata is required; the MVP build skips it.
func probeSqliteMetadata(ctx context.Context, path string) (int64, int, error) {
for _, name := range []string{"sqlite", "sqlite3"} {
if !sqlDriverRegistered(name) {
continue
}
db, err := sql.Open(name, "file:"+path+"?mode=ro")
if err != nil {
return 0, 0, err
}
defer db.Close()
var uv int64
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&uv); err != nil {
return 0, 0, err
}
var tc int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tc); err != nil {
return 0, 0, err
}
return uv, tc, nil
}
return 0, 0, errors.New("no sqlite driver registered with database/sql")
}
// sqlDriverRegistered reports whether `name` is registered via sql.Register.
func sqlDriverRegistered(name string) bool {
for _, d := range sql.Drivers() {
if d == name {
return true
}
}
return false
}
// IsSqliteGzMagic reports whether head looks like the gzip header that
// every sqliteDriver.Dump stream begins with.
func IsSqliteGzMagic(head []byte) bool {
return len(head) >= 2 && head[0] == 0x1f && head[1] == 0x8b
}