diff --git a/apps/agent/internal/pipeline/pg_dump_test.go b/apps/agent/internal/pipeline/pg_dump_test.go index ad9ec9c..8d412a4 100644 --- a/apps/agent/internal/pipeline/pg_dump_test.go +++ b/apps/agent/internal/pipeline/pg_dump_test.go @@ -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) } diff --git a/apps/agent/internal/pipeline/sqlite.go b/apps/agent/internal/pipeline/sqlite.go index c7d2e3b..dde5f13 100644 --- a/apps/agent/internal/pipeline/sqlite.go +++ b/apps/agent/internal/pipeline/sqlite.go @@ -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) } diff --git a/apps/agent/internal/pipeline/sqlite_test.go b/apps/agent/internal/pipeline/sqlite_test.go index 35856c3..7dce752 100644 --- a/apps/agent/internal/pipeline/sqlite_test.go +++ b/apps/agent/internal/pipeline/sqlite_test.go @@ -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 ''`. 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) {