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.
This commit is contained in:
TronoSfera 2026-05-18 14:46:48 +03:00
parent 6a56577dab
commit 3cfac4daca
3 changed files with 56 additions and 12 deletions

View file

@ -17,6 +17,10 @@ type mockRunner struct {
outputResp map[string][]byte // key = first arg (e.g. "--version") outputResp map[string][]byte // key = first arg (e.g. "--version")
streamResp []byte streamResp []byte
streamErr error streamErr error
// streamSideEffect, when non-nil, runs before streamResp is written
// to the supplied writer. Lets sqlite-style drivers that write to a
// real file-system path simulate the file write.
streamSideEffect func(args []string) error
calls []mockCall calls []mockCall
} }
@ -41,6 +45,11 @@ func (m *mockRunner) RunStream(_ context.Context, _ string, args []string, env [
if m.streamErr != nil { if m.streamErr != nil {
return m.streamErr return m.streamErr
} }
if m.streamSideEffect != nil {
if err := m.streamSideEffect(args); err != nil {
return err
}
}
if len(m.streamResp) > 0 { if len(m.streamResp) > 0 {
_, _ = out.Write(m.streamResp) _, _ = out.Write(m.streamResp)
} }

View file

@ -98,17 +98,34 @@ func (s *sqliteDriver) Dump(ctx context.Context, target *backupv1.Target, out io
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: cannot stat database %q: %w", path, err) return DumpInfo{}, fmt.Errorf("pipeline: sqlite: cannot stat database %q: %w", path, err)
} }
gz := gzip.NewWriter(out) // `.backup` requires a regular file as the destination — the Alpine
defer gz.Close() // 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) }()
// sqlite3 expects a single positional argument (the database path) args := []string{path, fmt.Sprintf(".backup '%s'", tmpPath)}
// followed by a dot-command. `.backup` writes a consistent snapshot if err := s.runner.RunStream(ctx, s.binary, args, nil, io.Discard); err != nil {
// to the supplied filename; we pass /dev/stdout so the bytes flow
// through stdout into our gzip writer.
args := []string{path, ".backup '/dev/stdout'"}
if err := s.runner.RunStream(ctx, s.binary, args, nil, gz); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite3 .backup exec: %w", err) 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 { if err := gz.Close(); err != nil {
return DumpInfo{}, fmt.Errorf("pipeline: sqlite: close gzip: %w", err) return DumpInfo{}, fmt.Errorf("pipeline: sqlite: close gzip: %w", err)
} }

View file

@ -8,6 +8,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -91,7 +92,23 @@ func TestSqlite_Dump_WrapsOutputInGzip(t *testing.T) {
payload := bytes.Repeat([]byte{0x53, 0x51, 0x4c}, 16) // pretend SQLite header bytes payload := bytes.Repeat([]byte{0x53, 0x51, 0x4c}, 16) // pretend SQLite header bytes
mock := &mockRunner{ mock := &mockRunner{
outputResp: map[string][]byte{"--version": []byte("3.45.1 2024-01-30\n")}, outputResp: map[string][]byte{"--version": []byte("3.45.1 2024-01-30\n")},
streamResp: payload, // The new Dump implementation stages the snapshot in a temp
// file, then re-reads it into the gzip pipe. Simulate the
// real sqlite3 process by parsing the destination path out
// of the .backup dot-command and writing the canned payload
// to that path.
streamSideEffect: func(args []string) error {
if len(args) < 2 {
return errors.New("expected at least 2 args")
}
dot := args[1]
const prefix = ".backup '"
if !strings.HasPrefix(dot, prefix) || !strings.HasSuffix(dot, "'") {
return errors.New("malformed .backup dot-command")
}
path := dot[len(prefix) : len(dot)-1]
return os.WriteFile(path, payload, 0o600)
},
} }
d := &sqliteDriver{binary: "sqlite3", runner: mock, statFn: os.Stat} d := &sqliteDriver{binary: "sqlite3", runner: mock, statFn: os.Stat}
@ -111,11 +128,12 @@ func TestSqlite_Dump_WrapsOutputInGzip(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, payload, got) require.Equal(t, payload, got)
// Confirm `.backup '/dev/stdout'` was invoked with the right path. // Confirm the dump invoked `.backup '<tmpfile>'`.
require.NotEmpty(t, mock.calls) require.NotEmpty(t, mock.calls)
streamCall := mock.calls[0] streamCall := mock.calls[0]
require.Equal(t, tmp, streamCall.Args[0]) require.Equal(t, tmp, streamCall.Args[0])
require.Equal(t, ".backup '/dev/stdout'", streamCall.Args[1]) require.True(t, strings.HasPrefix(streamCall.Args[1], ".backup '"))
require.True(t, strings.HasSuffix(streamCall.Args[1], "'"))
} }
func TestSqlite_Dump_MissingPath(t *testing.T) { func TestSqlite_Dump_MissingPath(t *testing.T) {