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")
|
outputResp map[string][]byte // key = first arg (e.g. "--version")
|
||||||
streamResp []byte
|
streamResp []byte
|
||||||
streamErr error
|
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 {
|
type mockCall struct {
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue