Files
backup-master/internal/archive/archive_test.go
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:52:40 +03:00

116 lines
3.4 KiB
Go

package archive
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestMatcher(t *testing.T) {
m := NewMatcher([]string{"/opt/stoat/data/db", "/etc/x-ui/x-ui.db*", "*.swp", "/opt/p/pgdata/"})
cases := map[string]bool{
"/opt/stoat/data/db": true,
"/opt/stoat/data/db/WiredTiger": true,
"/opt/stoat/data/dbx": false,
"/etc/x-ui/x-ui.db": true,
"/etc/x-ui/x-ui.db-wal": true,
"/etc/x-ui/config.json": false,
"/var/scripts/.a.sh.swp": true,
"/opt/p/pgdata/base/1": true,
"/opt/p/pgdata2": false,
}
for p, want := range cases {
if got := m.Match(p); got != want {
t.Errorf("Match(%q) = %v, want %v", p, got, want)
}
}
}
func TestRoundtrip(t *testing.T) {
src := t.TempDir()
must(t, os.MkdirAll(filepath.Join(src, "a", "b"), 0o750))
must(t, os.WriteFile(filepath.Join(src, "a", "b", "f.txt"), []byte("hello"), 0o640))
must(t, os.WriteFile(filepath.Join(src, "a", "skip.swp"), []byte("x"), 0o644))
must(t, os.MkdirAll(filepath.Join(src, "a", "pgdata"), 0o700))
must(t, os.WriteFile(filepath.Join(src, "a", "pgdata", "PG_VERSION"), []byte("17"), 0o600))
symlinks := runtime.GOOS != "windows"
if symlinks {
must(t, os.Symlink("b/f.txt", filepath.Join(src, "a", "link")))
}
var buf bytes.Buffer
w, err := NewWriter(&buf)
must(t, err)
must(t, w.AddBytes("manifest.json", []byte(`{"domain":"x.example"}`), 0o644))
skip := NewMatcher([]string{"*.swp", filepath.ToSlash(filepath.Join(src, "a", "pgdata"))})
must(t, w.AddTree(filepath.Join(src, "a"), func(p string) string {
rel, _ := filepath.Rel(src, p)
return "cat/files/" + filepath.ToSlash(rel)
}, skip))
must(t, w.Close())
if len(w.Warnings) > 0 {
t.Fatalf("warnings: %v", w.Warnings)
}
var m struct{ Domain string }
must(t, ReadManifest(bytes.NewReader(buf.Bytes()), &m))
if m.Domain != "x.example" {
t.Fatalf("manifest domain = %q", m.Domain)
}
dst := t.TempDir()
e := &Extractor{Route: func(name string) (string, bool) {
rest, ok := strings.CutPrefix(name, "cat/files/")
if !ok {
return "", false
}
return filepath.Join(dst, filepath.FromSlash(rest)), true
}}
must(t, e.Extract(bytes.NewReader(buf.Bytes())))
data, err := os.ReadFile(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if string(data) != "hello" {
t.Fatalf("content = %q", data)
}
if _, err := os.Stat(filepath.Join(dst, "a", "skip.swp")); !os.IsNotExist(err) {
t.Fatalf("skip.swp должен быть исключён")
}
if _, err := os.Stat(filepath.Join(dst, "a", "pgdata")); !os.IsNotExist(err) {
t.Fatalf("pgdata должен быть исключён")
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if fi.Mode().Perm() != 0o640 {
t.Fatalf("mode = %v", fi.Mode().Perm())
}
fi, err = os.Stat(filepath.Join(dst, "a", "b"))
must(t, err)
if fi.Mode().Perm() != 0o750 {
t.Fatalf("dir mode = %v", fi.Mode().Perm())
}
}
if symlinks {
target, err := os.Readlink(filepath.Join(dst, "a", "link"))
must(t, err)
if target != "b/f.txt" {
t.Fatalf("symlink = %q", target)
}
}
// повторная распаковка поверх существующих файлов
e2 := &Extractor{Route: e.Route}
must(t, e2.Extract(bytes.NewReader(buf.Bytes())))
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}