mirror of
https://github.com/TronoSfera/backupy-agent.git
synced 2026-05-18 18:13:30 +03:00
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/ — Go agent (WSS client, persistent queue, Docker
discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/ — protobuf wire format (generated .pb.go committed
so the repo builds without protoc)
- docs/ — agent spec + wire-protocol contract
Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
286 lines
9.6 KiB
Go
286 lines
9.6 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
backupv1 "github.com/backupy/backupy/packages/proto/gen/go/backupv1"
|
|
)
|
|
|
|
// redisMockRunner is a more flexible mock than the shared mockRunner —
|
|
// it can return different output for successive Output calls so we can
|
|
// simulate the BGSAVE / LASTSAVE / CONFIG GET sequence.
|
|
type redisMockRunner struct {
|
|
// outputs is a FIFO queue keyed by the last argument of the call
|
|
// (e.g. "PING", "BGSAVE", "LASTSAVE", "dir", "dbfilename", "server").
|
|
outputs map[string][][]byte
|
|
errs map[string]error
|
|
calls [][]string
|
|
}
|
|
|
|
func newRedisMockRunner() *redisMockRunner {
|
|
return &redisMockRunner{outputs: map[string][][]byte{}, errs: map[string]error{}}
|
|
}
|
|
|
|
func (m *redisMockRunner) keyOf(args []string) string {
|
|
for i := len(args) - 1; i >= 0; i-- {
|
|
switch args[i] {
|
|
case "PING", "BGSAVE", "LASTSAVE", "--version":
|
|
return args[i]
|
|
case "dir", "dbfilename":
|
|
return args[i]
|
|
case "server":
|
|
return args[i]
|
|
}
|
|
}
|
|
if len(args) > 0 {
|
|
return args[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *redisMockRunner) Output(_ context.Context, _ string, args []string, _ []string) ([]byte, error) {
|
|
m.calls = append(m.calls, append([]string(nil), args...))
|
|
key := m.keyOf(args)
|
|
if err, ok := m.errs[key]; ok {
|
|
return nil, err
|
|
}
|
|
q, ok := m.outputs[key]
|
|
if !ok || len(q) == 0 {
|
|
return []byte("PONG\n"), nil
|
|
}
|
|
out := q[0]
|
|
if len(q) > 1 {
|
|
m.outputs[key] = q[1:]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *redisMockRunner) RunStream(_ context.Context, _ string, args []string, _ []string, _ io.Writer) error {
|
|
m.calls = append(m.calls, append([]string(nil), args...))
|
|
return nil
|
|
}
|
|
|
|
func newRedisDriverForTest(t *testing.T, runner cmdRunner, rdbContents []byte) *redisDriver {
|
|
t.Helper()
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "dump-*.rdb")
|
|
require.NoError(t, err)
|
|
_, err = tmpFile.Write(rdbContents)
|
|
require.NoError(t, err)
|
|
require.NoError(t, tmpFile.Close())
|
|
|
|
return &redisDriver{
|
|
binary: "redis-cli",
|
|
runner: runner,
|
|
fileOpen: defaultFileOpen,
|
|
now: time.Now,
|
|
pollInterval: time.Millisecond,
|
|
pollTimeout: time.Second,
|
|
}
|
|
}
|
|
|
|
func TestRedis_Validate_OK(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
m.outputs["--version"] = [][]byte{[]byte("redis-cli 7.2.4\n")}
|
|
m.outputs["PING"] = [][]byte{[]byte("PONG\n")}
|
|
d := &redisDriver{binary: "redis-cli", runner: m, fileOpen: defaultFileOpen, now: time.Now, pollInterval: time.Millisecond, pollTimeout: time.Second}
|
|
err := d.Validate(context.Background(), &backupv1.Target{
|
|
Type: backupv1.DbType_REDIS,
|
|
Connection: &backupv1.ConnectionConfig{Host: "h", Port: 6379, PasswordSecretRef: "pw"},
|
|
})
|
|
require.NoError(t, err)
|
|
// PING call should carry -a and --no-auth-warning when password set.
|
|
var sawAuth bool
|
|
for _, c := range m.calls {
|
|
for _, a := range c {
|
|
if a == "--no-auth-warning" {
|
|
sawAuth = true
|
|
}
|
|
}
|
|
}
|
|
require.True(t, sawAuth)
|
|
}
|
|
|
|
func TestRedis_Validate_PingFails(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
m.outputs["--version"] = [][]byte{[]byte("redis-cli 7.2.4\n")}
|
|
m.errs["PING"] = errors.New("connection refused")
|
|
d := &redisDriver{binary: "redis-cli", runner: m, fileOpen: defaultFileOpen, now: time.Now, pollInterval: time.Millisecond, pollTimeout: time.Second}
|
|
err := d.Validate(context.Background(), &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{}})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "PING")
|
|
}
|
|
|
|
func TestRedis_Validate_BinaryMissing(t *testing.T) {
|
|
t.Parallel()
|
|
d := &redisDriver{binary: "redis-cli", runner: &errOutputRunner{err: errors.New("not found")}, fileOpen: defaultFileOpen, now: time.Now, pollInterval: time.Millisecond, pollTimeout: time.Second}
|
|
err := d.Validate(context.Background(), &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{}})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "version probe failed")
|
|
}
|
|
|
|
func TestRedis_Dump_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
rdb := []byte("REDIS0011\x00\xff" + strings.Repeat("x", 64))
|
|
tmp, err := os.CreateTemp(t.TempDir(), "dump-*.rdb")
|
|
require.NoError(t, err)
|
|
_, _ = tmp.Write(rdb)
|
|
require.NoError(t, tmp.Close())
|
|
|
|
m := newRedisMockRunner()
|
|
m.outputs["LASTSAVE"] = [][]byte{
|
|
[]byte("1000\n"), // initial value
|
|
[]byte("1001\n"), // after BGSAVE — advanced
|
|
}
|
|
m.outputs["BGSAVE"] = [][]byte{[]byte("Background saving started\n")}
|
|
// `CONFIG GET dir` returns two lines: key + value.
|
|
m.outputs["dir"] = [][]byte{[]byte("dir\n" + tmp.Name()[:strings.LastIndex(tmp.Name(), "/")] + "\n")}
|
|
m.outputs["dbfilename"] = [][]byte{[]byte("dbfilename\n" + tmp.Name()[strings.LastIndex(tmp.Name(), "/")+1:] + "\n")}
|
|
m.outputs["server"] = [][]byte{[]byte("# Server\nredis_version:7.2.4\n")}
|
|
|
|
d := &redisDriver{
|
|
binary: "redis-cli",
|
|
runner: m,
|
|
fileOpen: defaultFileOpen,
|
|
now: time.Now,
|
|
pollInterval: time.Millisecond,
|
|
pollTimeout: time.Second,
|
|
}
|
|
target := &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{Host: "h"}}
|
|
|
|
var buf bytes.Buffer
|
|
info, err := d.Dump(context.Background(), target, &buf)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Redis 7.2.4", info.EngineVersion)
|
|
require.True(t, IsRedisTarGzMagic(buf.Bytes()))
|
|
|
|
// Round-trip the tar.gz to confirm the entry name + contents.
|
|
gz, err := gzip.NewReader(&buf)
|
|
require.NoError(t, err)
|
|
tr := tar.NewReader(gz)
|
|
hdr, err := tr.Next()
|
|
require.NoError(t, err)
|
|
require.Equal(t, "dump.rdb", hdr.Name)
|
|
got, err := io.ReadAll(tr)
|
|
require.NoError(t, err)
|
|
require.Equal(t, rdb, got)
|
|
}
|
|
|
|
func TestRedis_Dump_BgsaveTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
// LASTSAVE never advances: the same value is returned forever.
|
|
m.outputs["LASTSAVE"] = [][]byte{[]byte("100\n")}
|
|
m.outputs["BGSAVE"] = [][]byte{[]byte("Background saving started\n")}
|
|
|
|
d := &redisDriver{
|
|
binary: "redis-cli",
|
|
runner: m,
|
|
fileOpen: defaultFileOpen,
|
|
now: time.Now,
|
|
pollInterval: time.Millisecond,
|
|
pollTimeout: 10 * time.Millisecond,
|
|
}
|
|
target := &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{Host: "h"}}
|
|
var buf bytes.Buffer
|
|
_, err := d.Dump(context.Background(), target, &buf)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "BGSAVE did not complete")
|
|
}
|
|
|
|
func TestRedis_Dump_NoFilesystemAccess(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
m.outputs["LASTSAVE"] = [][]byte{[]byte("100\n"), []byte("200\n")}
|
|
m.outputs["BGSAVE"] = [][]byte{[]byte("ok\n")}
|
|
// CONFIG GET returns empty path → driver should error clearly.
|
|
m.outputs["dir"] = [][]byte{[]byte("dir\n\n")}
|
|
m.outputs["dbfilename"] = [][]byte{[]byte("dbfilename\ndump.rdb\n")}
|
|
|
|
d := &redisDriver{binary: "redis-cli", runner: m, fileOpen: defaultFileOpen, now: time.Now, pollInterval: time.Millisecond, pollTimeout: time.Second}
|
|
target := &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{Host: "h"}}
|
|
var buf bytes.Buffer
|
|
_, err := d.Dump(context.Background(), target, &buf)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "same-host filesystem access required")
|
|
}
|
|
|
|
func TestRedis_Dump_OpenFileError(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
m.outputs["LASTSAVE"] = [][]byte{[]byte("100\n"), []byte("200\n")}
|
|
m.outputs["BGSAVE"] = [][]byte{[]byte("ok\n")}
|
|
m.outputs["dir"] = [][]byte{[]byte("dir\n/nonexistent\n")}
|
|
m.outputs["dbfilename"] = [][]byte{[]byte("dbfilename\ndump.rdb\n")}
|
|
|
|
d := &redisDriver{
|
|
binary: "redis-cli",
|
|
runner: m,
|
|
fileOpen: func(path string) (io.ReadCloser, os.FileInfo, error) { return nil, nil, errors.New("missing") },
|
|
now: time.Now, pollInterval: time.Millisecond, pollTimeout: time.Second,
|
|
}
|
|
target := &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{Host: "h"}}
|
|
var buf bytes.Buffer
|
|
_, err := d.Dump(context.Background(), target, &buf)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "same-host access required")
|
|
}
|
|
|
|
func TestRedis_Dump_HonoursContextCancel(t *testing.T) {
|
|
t.Parallel()
|
|
m := newRedisMockRunner()
|
|
m.outputs["LASTSAVE"] = [][]byte{[]byte("100\n")} // never advances
|
|
m.outputs["BGSAVE"] = [][]byte{[]byte("ok\n")}
|
|
d := &redisDriver{
|
|
binary: "redis-cli", runner: m, fileOpen: defaultFileOpen,
|
|
now: time.Now, pollInterval: 10 * time.Millisecond, pollTimeout: time.Minute,
|
|
}
|
|
target := &backupv1.Target{Type: backupv1.DbType_REDIS, Connection: &backupv1.ConnectionConfig{Host: "h"}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
defer cancel()
|
|
var buf bytes.Buffer
|
|
_, err := d.Dump(ctx, target, &buf)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestRedis_Name(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, "redis", (&redisDriver{}).Name())
|
|
}
|
|
|
|
func TestIsRedisTarGzMagic(t *testing.T) {
|
|
t.Parallel()
|
|
require.True(t, IsRedisTarGzMagic([]byte{0x1f, 0x8b, 0x08}))
|
|
require.False(t, IsRedisTarGzMagic([]byte{0x00}))
|
|
}
|
|
|
|
func TestRedis_ConnArgs_DBIndex(t *testing.T) {
|
|
t.Parallel()
|
|
d := &redisDriver{}
|
|
args := d.connArgs(&backupv1.Target{Connection: &backupv1.ConnectionConfig{Host: "h", Port: 6379, Database: "3"}})
|
|
require.Contains(t, args, "-n")
|
|
require.Contains(t, args, "3")
|
|
|
|
// Non-numeric database is ignored.
|
|
args = d.connArgs(&backupv1.Target{Connection: &backupv1.ConnectionConfig{Host: "h", Database: "notanint"}})
|
|
require.NotContains(t, args, "-n")
|
|
}
|
|
|
|
func TestNewRedisDriverHelperUnused(t *testing.T) {
|
|
t.Parallel()
|
|
// Touch the helper so go vet does not flag it as dead code.
|
|
d := newRedisDriverForTest(t, newRedisMockRunner(), []byte("x"))
|
|
require.NotNil(t, d)
|
|
}
|