mirror of
https://github.com/TronoSfera/backupy-agent.git
synced 2026-05-18 18:13:30 +03:00
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:
parent
6a56577dab
commit
3cfac4daca
3 changed files with 56 additions and 12 deletions
|
|
@ -17,7 +17,11 @@ type mockRunner struct {
|
|||
outputResp map[string][]byte // key = first arg (e.g. "--version")
|
||||
streamResp []byte
|
||||
streamErr error
|
||||
calls []mockCall
|
||||
// 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
|
||||
}
|
||||
|
||||
type mockCall struct {
|
||||
|
|
@ -41,6 +45,11 @@ func (m *mockRunner) RunStream(_ context.Context, _ string, args []string, env [
|
|||
if m.streamErr != nil {
|
||||
return m.streamErr
|
||||
}
|
||||
if m.streamSideEffect != nil {
|
||||
if err := m.streamSideEffect(args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(m.streamResp) > 0 {
|
||||
_, _ = out.Write(m.streamResp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(out)
|
||||
defer gz.Close()
|
||||
// `.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) }()
|
||||
|
||||
// sqlite3 expects a single positional argument (the database path)
|
||||
// followed by a dot-command. `.backup` writes a consistent snapshot
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
mock := &mockRunner{
|
||||
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}
|
||||
|
||||
|
|
@ -111,11 +128,12 @@ func TestSqlite_Dump_WrapsOutputInGzip(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
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)
|
||||
streamCall := mock.calls[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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue